"""One command for a whole book: PDF in, finished book folder out. python3 _pipeline/cli.py process [--dry-run] [--force] [--title T --author A] [--compare BOOKS_ROOT [--compare-strict]] [--result-file F] Stages, in order, for ALL chapters the structure stage finds: ingest (OCR only if pages lack a text layer) -> structure -> cleanup -> figures -> script -> license -> [voice -> assemble -> ACX check] (skipped with --dry-run, which instead proves the voice server is reachable) Rules: * Any failed step or failed ACX check stops the run with a clear message and exit code 1. * Warnings are logged to warnings.log in the book folder. There is no approval gate. * Idempotent: an audio file that already exists is skipped, unless its script or the voice settings changed since it was built (book.json "audio_built") or --force is given. * Output goes where the settings say: books_dir (env AUDIOBOOK_BOOKS_DIR), scratch work_dir (env AUDIOBOOK_WORK_DIR), voice endpoint (env AUDIOBOOK_VOICE_ENDPOINT). """ import difflib import hashlib import io import json import os import re import shutil import sys import acx_check import assemble import bookio import ingest import settings import voice class Failed(Exception): """A pipeline step failed. The message is what the operator reads.""" class Tee(io.TextIOBase): def __init__(self, *streams): self.streams = streams def write(self, s): for st in self.streams: st.write(s) return len(s) def flush(self): for st in self.streams: st.flush() def sidecar(pdf): """Optional .json next to the PDF: {"title": "...", "author": "..."}.""" path = os.path.splitext(pdf)[0] + ".json" if os.path.exists(path): with open(path, encoding="utf-8") as f: return json.load(f) return {} def guess_from_filename(pdf): """'The Title - by Glenn Clark (Extra).pdf' or 'Title by Glenn Clark.pdf' -> (title, author), else (None, None).""" stem = os.path.splitext(os.path.basename(pdf))[0] m = re.match(r"^(?P.+?)[\s\-–—]+by\s+(?P<author>[^()]+?)\s*(\(.*\))?\s*$", stem, re.I) if m: return m.group("title").strip(" -–—"), m.group("author").strip() return None, None def voice_fingerprint(cfg, script_path): h = hashlib.sha256() with open(script_path, "rb") as f: h.update(f.read()) v = cfg["voice"] h.update(json.dumps([v["api"], v["voice"], v.get("extra")], sort_keys=True).encode()) h.update(json.dumps(cfg["audio"], sort_keys=True).encode()) return h.hexdigest()[:16] def read_if(path): with open(path, encoding="utf-8") as f: return f.read() def book_json_for_compare(path): book = json.loads(read_if(path)) book.get("license", {}).pop("checked", None) return json.dumps(book, indent=1, sort_keys=True) def compare_books(new_dir, old_dir): """Text-stage output of new_dir against an existing book folder. Returns a list of difference descriptions.""" diffs = [] pairs = [("book.json", book_json_for_compare), ("warnings.log", read_if), ("figures/skipped_figures.md", read_if), ("LICENSE_STATUS.md", lambda p: re.sub(r"Date checked: .*", "", read_if(p)))] for sub in ("text", "script"): names = set(os.listdir(os.path.join(old_dir, sub))) | set(os.listdir(os.path.join(new_dir, sub))) pairs += [(os.path.join(sub, n), read_if) for n in sorted(names)] for rel, reader in pairs: a, b = os.path.join(old_dir, rel), os.path.join(new_dir, rel) if not os.path.exists(a) or not os.path.exists(b): diffs.append("%s: missing in %s" % (rel, "new output" if os.path.exists(a) else "existing book")) continue ta, tb = reader(a), reader(b) if ta != tb: d = list(difflib.unified_diff(ta.splitlines(), tb.splitlines(), "existing/" + rel, "new/" + rel, lineterm="", n=0)) diffs.append("%s differs (%d diff lines):\n %s" % (rel, len(d), "\n ".join(d[:12]))) return diffs def step(name): print("\n=== %s ===" % name, flush=True) def check_ocr(cfg, book_dir): """Pages with no text layer must have been OCRed. Fails the run if they were not (setting ocr.required).""" book = bookio.read_book(book_dir) missing = book["pages_without_text_layer"] if not missing: print("OCR: not needed, every page has a text layer") return ocr_pdf = os.path.join(settings.resolve(cfg, "work_dir"), os.path.basename(book_dir), "ocr.pdf") pages = ",".join(map(str, missing)) if not os.path.exists(ocr_pdf): msg = "pdf pages %s have no text layer and OCR did not produce a text copy (is ocrmypdf installed? see warnings.log)" % pages if cfg["ocr"].get("required", True): raise Failed("ingest: " + msg) print("WARNING:", msg) return import pdftext counts = pdftext.chars_per_page(ocr_pdf) got = {p: counts[p - 1] for p in missing} print("OCR applied to pdf pages %s; characters found after OCR: %s" % (pages, got)) empty = [p for p, n in got.items() if n < cfg["ocr"]["min_chars_per_page"]] if empty: bookio.write_warnings(book_dir, "process-ocr", [("ocr-no-text", "pdf pages " + ",".join(map(str, empty)), "OCR found no text (blank page or picture only)")]) def run_text_stages(cfg, pdf, title, author): """Ingest through license for every chapter. Returns (book_dir, book).""" import cli meta = sidecar(pdf) title, author = title or meta.get("title"), author or meta.get("author") step("ingest") try: book_dir = ingest.run(cfg, pdf, title, author) except SystemExit as e: t, a = guess_from_filename(pdf) if not (t and a): raise Failed("ingest: %s. Put a file with the same name and .json next to the PDF: " '{"title": "...", "author": "..."}' % e) print("ingest: title/author not found in the PDF; taking them from the file name: '%s' by '%s'" % (t, a)) book_dir = ingest.run(cfg, pdf, title or t, author or a) bookio.write_warnings(book_dir, "ingest-metadata", [("metadata-from-filename", os.path.basename(pdf), "title '%s', author '%s'" % (t, a))]) print("ingest done ->", book_dir) check_ocr(cfg, book_dir) for name in ("structure",): step(name) try: cli.run_stage(name, cfg, book_dir) except SystemExit as e: raise Failed("%s: %s" % (name, e)) book = bookio.read_book(book_dir) numbers = [c["number"] for c in book["chapters"]] if numbers != list(range(1, len(numbers) + 1)): bookio.write_warnings(book_dir, "process", [("chapter-numbers", "structure", "chapter numbers found: %s (expected 1..%d in order)" % (numbers, len(numbers)))]) print("chapters found:", numbers) for name in ("cleanup", "figures", "script", "license"): step(name) try: cli.run_stage(name, cfg, book_dir, numbers) except SystemExit as e: raise Failed("%s: %s" % (name, e)) return book_dir, bookio.read_book(book_dir) def run_audio_stages(cfg, book_dir, book, force): out_dir = settings.audio_dir(cfg, book_dir) built = book.setdefault("audio_built", {}) todo = [] for name in book["audio_files"]: fp = voice_fingerprint(cfg, os.path.join(book_dir, "script", name + ".json")) have = os.path.exists(os.path.join(out_dir, name + ".mp3")) if have and not force and built.get(name, fp) == fp: print("skip %s: audio exists and is current" % name) else: todo.append((name, fp)) if todo: step("voice (%d of %d files)" % (len(todo), len(book["audio_files"]))) h = voice.ensure_loaded(cfg) print("voice server ok:", h) names = [n for n, _ in todo] for n, fp in todo: # finished pieces from an interrupted run are reused only if script and voice settings are the same wd = voice.work_dir_for(cfg, book_dir, n) stamp = os.path.join(wd, "fingerprint") if os.path.isdir(wd) and (force or not os.path.exists(stamp) or read_if(stamp) != fp): shutil.rmtree(wd) os.makedirs(wd, exist_ok=True) with open(stamp, "w") as f: f.write(fp) voice.run(cfg, book_dir, names=names) step("assemble") for n in names: assemble.run(cfg, book_dir, only=n) for n, fp in todo: built[n] = fp bookio.write_book(book_dir, book) if cfg["voice"].get("unload_when_done"): voice.unload(cfg) print("voice model unloaded to free the GPU") else: print("nothing to render") step("ACX check") rc = acx_check.run(cfg, [out_dir]) if rc != 0: raise Failed("ACX check FAILED (see the table above); nothing should be committed") def process(cfg, pdf, dry_run=False, force=False, title=None, author=None, compare=None, result_file=None, compare_strict=False): """Returns 0 on success, 1 on failure (message printed).""" pdf = os.path.abspath(pdf) if not os.path.isfile(pdf): print("PROCESS FAILED: no such file: %s" % pdf) return 1 log = io.StringIO() real_out = sys.stdout sys.stdout = Tee(real_out, log) book_dir = None try: print("process %s%s" % (os.path.basename(pdf), " [DRY RUN: text stages only]" if dry_run else "")) book_dir, book = run_text_stages(cfg, pdf, title, author) if compare: old = os.path.join(compare, os.path.relpath(book_dir, settings.resolve(cfg, "books_dir"))) step("compare with the existing book folder %s" % old) if not os.path.isdir(old): print("no existing folder to compare with (this is a new book)") else: diffs = compare_books(book_dir, old) if diffs and compare_strict: raise Failed("output differs from the existing book folder:\n" + "\n".join(diffs)) if diffs: print("DIFFERENCES from the existing book folder (report only; add --compare-strict to fail on them):\n" + "\n".join(diffs)) else: print("identical to the existing text, script, figures list, warnings, book.json and license file") if dry_run: step("voice server reachability (no audio rendered)") try: h = voice.health(cfg) except voice.VoiceError as e: raise Failed(str(e)) print("voice server reachable:", h) else: try: run_audio_stages(cfg, book_dir, book, force) except voice.VoiceError as e: raise Failed("voice: %s" % e) print("\nPROCESS OK: %s" % book_dir) rc = 0 except Failed as e: print("\nPROCESS FAILED: %s" % e) rc = 1 except Exception as e: # any other error is also a loud failure, with the type named print("\nPROCESS FAILED: unexpected %s: %s" % (type(e).__name__, e)) import traceback traceback.print_exc(file=sys.stdout) rc = 1 finally: sys.stdout = real_out if rc == 0 and book_dir and not dry_run: out_dir = settings.audio_dir(cfg, book_dir) os.makedirs(out_dir, exist_ok=True) with open(os.path.join(out_dir, "run.log"), "w", encoding="utf-8") as f: f.write(log.getvalue()) if result_file: with open(result_file, "w", encoding="utf-8") as f: json.dump({"ok": rc == 0, "book_dir": book_dir, "pdf": pdf}, f) return rc