media-ab-reorg.py: planner for the ABS layout {Author}/{Series}/{Seq -
Year - Title {Narrator}} — claims one book folder per DB item (tracks
of duplicate recordings must not interleave), gates any book folder
that would nest above another (ABS's swallow defect), routes comic and
ebook-only items to intake, holds dedup losers under the campaign dir.
media-ab-verifydups.py: full-blake2b confirmation of size+head/tail
collision survivors. media-ebook-dedupe.py: dedupe the eBooks pile
against the Calibre library (identity key title-core+first-surname,
OPF metadata authority) and emit the calibredb ingest list.
abs-post-reorg.py: server-side ABS driver (cover export, DB-row wipe —
never hard=1 — scan, status) with credentials out of argv.
205 lines
7.1 KiB
Python
Executable File
205 lines
7.1 KiB
Python
Executable File
#!/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 ("srchost<TAB>dstarray" 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()
|