#!/usr/bin/env python3 """abs-post-reorg.py — drive the Audiobookshelf side of the 2026-11-04 relayout. Runs ON Dankserver (the ABS host). Credentials come from the machine-local file ~/.config/secrets/audiobookshelf and never enter argv; requests go through urllib so neither the password nor the bearer token is world-readable in /proc. The delete arm is the DB-row arm ONLY — `hard=1` is fs.remove() of the item folder and must never reach this file (see the Audiobookshelf vault page; after the move the covering paths are whole books). Subcommands covers --list LIST.tsv copy each ABS /metadata cover onto the book folder ("srchostdstarray" per line); run BEFORE the mover so the cover rides the book's move, or AFTER with dst already final wipe [--apply] delete every library item DB row (progress included — the campaign accepted losing the 11 progress rows); refuses without --apply scan [--wait] trigger the library scan; --wait polls isFetchingLibrary then prints the item total status item total + isMissing census Paging law (measured twice): page on `page` (0-based) + `limit`; `offset` is ignored outright. Count unique ids; stop on `total`. """ import argparse import json import os import shutil import sys import time import urllib.error import urllib.request BASE = "http://127.0.0.1:8000/audiobookshelf" SECRET = os.environ.get("ABS_SECRET", "/home/dank/.config/secrets/audiobookshelf") def cred(): kv = {} with open(SECRET) as fh: for line in fh: if "=" in line: k, _, v = line.strip().partition("=") kv[k] = v return kv.get("ABS_ADMIN_USER", "frank"), kv.get("ABS_ADMIN_PASSWORD") def call(method, path, body=None, token=None, base=BASE): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(base + path, data=data, method=method) req.add_header("Content-Type", "application/json") if token: req.add_header("Authorization", f"Bearer {token}") try: with urllib.request.urlopen(req, timeout=30) as r: raw = r.read().decode() try: return r.status, json.loads(raw) except json.JSONDecodeError: return r.status, raw.strip() except urllib.error.HTTPError as e: return e.code, e.read().decode()[:200] def login(): user, password = cred() if not password: sys.exit(f"refused: {SECRET} carries no ABS_ADMIN_PASSWORD") code, body = call("POST", "/login", {"username": user, "password": password}) if code != 200: sys.exit(f"login failed {code}: {body}") return body["user"]["token"] # the token is inside user, not top level def library_id(token, name="Audiobooks"): code, libs = call("GET", "/api/libraries", token=token) if code != 200: sys.exit(f"libraries failed {code}: {libs}") for lib in libs.get("libraries", []): if lib["name"] == name: return lib["id"] sys.exit(f"no library named {name}") def all_item_ids(token, lid): ids, page = [], 0 code, one = call("GET", f"/api/libraries/{lid}/items?limit=1", token=token) total = one.get("total", -1) if code == 200 else -1 while True: code, body = call("GET", f"/api/libraries/{lid}/items?page={page}&limit=500", token=token) if code != 200: sys.exit(f"items page {page} failed {code}") results = body.get("results", []) if not results: break ids += [it["id"] for it in results] page += 1 if total >= 0 and page * 500 >= total: break return list(dict.fromkeys(ids)), total def cmd_covers(args): copied = missing = exists = 0 for line in open(args.list): src, _, dst = line.rstrip("\n").partition("\t") if not dst: continue if os.path.exists(dst): exists += 1 continue if not os.path.exists(src): missing += 1 continue os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copy2(src, dst) copied += 1 print(f"covers copied: {copied} already there: {exists} missing source: {missing}") def cmd_wipe(args, token, lid): ids, total = all_item_ids(token, lid) print(f"library {lid}: {len(ids)} items (total={total})") if not args.apply: print("dry run: nothing deleted (add --apply)") return gone = 0 for i, iid in enumerate(ids): code, _ = call("DELETE", f"/api/items/{iid}", token=token) if code == 200: gone += 1 elif i < 3: print(f"delete {iid}: {code}") if i % 500 == 499: print(f" {i + 1}/{len(ids)} deleted") print(f"deleted {gone}/{len(ids)} item rows") def cmd_scan(args, token, lid): code, body = call("POST", f"/api/libraries/{lid}/scan", token=token) print(f"scan: {code} {body if code != 200 else 'accepted'}") if not args.wait: return last = -1 while True: time.sleep(30) code, lib = call("GET", f"/api/libraries/{lid}", token=token) code2, one = call("GET", f"/api/libraries/{lid}/items?limit=1", token=token) total = one.get("total", -1) if code2 == 200 else -1 fetching = lib.get("isFetchingLibrary") if code == 200 else None print(f" fetching={fetching} items={total}") if fetching is False and total == last and total >= 0: break last = total print(f"scan settled at {total} items") def cmd_status(token, lid): missing = 0 seen, page = set(), 0 while True: code, body = call("GET", f"/api/libraries/{lid}/items?page={page}&limit=500", token=token) results = body.get("results", []) if code == 200 else [] if not results: break for it in results: if it["id"] not in seen: seen.add(it["id"]) if it.get("isMissing"): missing += 1 page += 1 code, lib = call("GET", f"/api/libraries/{lid}", token=token) print(f"items: {len(seen)} isMissing: {missing} " f"pending tasks: {lib.get('pendingTasks') if code == 200 else '?'}") def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = ap.add_subparsers(dest="cmd", required=True) c = sub.add_parser("covers") c.add_argument("--list", required=True) w = sub.add_parser("wipe") w.add_argument("--apply", action="store_true") s = sub.add_parser("scan") s.add_argument("--wait", action="store_true") sub.add_parser("status") args = ap.parse_args() token = login() lid = library_id(token) if args.cmd == "covers": cmd_covers(args) elif args.cmd == "wipe": cmd_wipe(args, token, lid) elif args.cmd == "scan": cmd_scan(args, token, lid) else: cmd_status(token, lid) if __name__ == "__main__": main()