Files
serverAppConfigs/scripts/abs-post-reorg.py
T
dank fab17129e2 feat(media): dedupe + relayout campaign tools for Audiobooks and eBooks
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.
2026-11-04 14:45:29 -08:00

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()