Files
old-books/_pipeline/process.py
T
2026-10-18 01:50:39 -05:00

301 lines
12 KiB
Python

"""One command for a whole book: PDF in, finished book folder out.
python3 _pipeline/cli.py process <pdf> [--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 <pdf name>.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<title>.+?)[\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 resolve_meta(pdf, title=None, author=None):
"""(title, author, note). Order: explicit arguments, the .json next to the PDF, the PDF itself, the file name.
Raises Failed, telling the operator what to add, if none of them works."""
meta = sidecar(pdf)
title, author = title or meta.get("title"), author or meta.get("author")
try:
title, author = ingest.detect(pdf, title, author)
except Exception as e:
raise Failed("could not read the PDF: %s" % e)
note = None
if not (title and author):
t, a = guess_from_filename(pdf)
title, author = title or t, author or a
note = "title/author not found in the PDF; taking them from the file name: '%s' by '%s'" % (title, author)
if not (title and author):
raise Failed("ingest: could not find title and author in the PDF or its file name. Put a file with the same name and "
'.json next to the PDF: {"title": "...", "author": "..."}')
return title, author, note
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):
why = " | ".join(ln for ln in read_if(os.path.join(book_dir, bookio.WARN_NAME)).splitlines() if ln.startswith("ingest |"))
msg = "pdf pages %s have no text layer and OCR did not produce a text copy. Reason logged: %s" % (pages, why or "none (is ocrmypdf installed?)")
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
title, author, note = resolve_meta(pdf, title, author)
step("ingest")
if note:
print(note)
try:
book_dir = ingest.run(cfg, pdf, title, author)
except SystemExit as e:
raise Failed("ingest: %s" % e)
if note:
bookio.write_warnings(book_dir, "ingest-metadata", [("metadata-from-filename", os.path.basename(pdf), "title '%s', author '%s'" % (title, author))])
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):
"""Returns True if any audio was rendered."""
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")
rendered = bool(todo)
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")
return rendered
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
rendered = False
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:
rendered = 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 and rendered: # a re-run that renders nothing keeps the original log
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