Workflow: sparse partial checkout (code and _incoming only), book lookup before processing, safe rebase retry; revert cancel-in-progress

This commit is contained in:
Audiobook Producer
2026-10-18 01:48:48 -05:00
parent 517e76c533
commit 153153010e
6 changed files with 77 additions and 18 deletions
+8 -2
View File
@@ -22,7 +22,7 @@ permissions:
concurrency:
group: audiobook
cancel-in-progress: true # TEMPORARY: clearing a hung run (reverted right after)
cancel-in-progress: false # one book at a time; a second drop waits
jobs:
build:
@@ -42,7 +42,13 @@ jobs:
timeout-minutes: 15 # a stalled fetch fails the run instead of hanging it for hours
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 100 # enough history to rebase onto a main that moved during a long render
filter: blob:none # do not download every book's audio and PDFs on every run
sparse-checkout: |
/_pipeline/
/_incoming/
/.gitea/
sparse-checkout-cone-mode: false
- name: Install tools (fresh container every run)
run: bash _pipeline/ci/install.sh
- name: Unit tests (dry runs only)
+17 -1
View File
@@ -12,8 +12,13 @@ BRANCH="${BRANCH:-main}"
git config user.name "audiobook-bot"
git config user.email "audiobook-bot@noreply.invalid"
# The checkout is sparse (code and _incoming only). Books are pulled in only when needed.
sparse_add() { printf '%s\n' "$@" | git sparse-checkout add --stdin; }
esc() { printf '%s' "$1" | sed -e 's/[][*?\\#!]/\\&/g'; }
pdfs=()
if [ -n "${PDF_INPUT:-}" ]; then
sparse_add "/$(esc "$PDF_INPUT")"
[ -f "$PDF_INPUT" ] || { echo "FAILED: no such file in the repo: $PDF_INPUT"; exit 1; }
pdfs=("$PDF_INPUT")
else
@@ -30,7 +35,7 @@ push_with_retry() { # push HEAD to the branch; if the branch moved, rebase and
for i in 1 2 3 4 5; do
if git push origin "HEAD:refs/heads/$BRANCH"; then return 0; fi
echo "push attempt $i failed; fetching and rebasing"
git pull --rebase origin "$BRANCH" || return 1
git fetch -q --depth=100 origin "$BRANCH" && git rebase FETCH_HEAD || { git rebase --abort 2>/dev/null; return 1; }
done
return 1
}
@@ -39,6 +44,17 @@ failed=0
for pdf in "${pdfs[@]}"; do
echo; echo "################ $pdf"
result="$(mktemp)"
rel="$(python3 _pipeline/cli.py bookpath "$pdf")" || { echo "FAILED: $rel"; failed=1; continue; }
echo "book folder: $rel"
e="$(esc "$rel")"
if git cat-file -e "HEAD:$rel" 2>/dev/null; then
if [ "$DRY_RUN" = "true" ]; then # dry run compares text only: skip audio
sparse_add "/$e/book.json" "/$e/warnings.log" "/$e/LICENSE_STATUS.md" "/$e/text/" "/$e/script/" "/$e/figures/"
else # a real run needs the existing audio so it can skip it
sparse_add "/$e/"
fi
echo "this book already exists in the repo; existing files fetched"
fi
if [ "$DRY_RUN" = "true" ]; then
export AUDIOBOOK_BOOKS_DIR="/tmp/audiobook-dry/books"; rm -rf /tmp/audiobook-dry
mkdir -p /tmp/audiobook-dry
+10
View File
@@ -71,6 +71,8 @@ def main(argv=None):
p.add_argument("--compare", help="a books folder (like the repo books/); the text-stage output is compared with the same <author>/<book> folder in it")
p.add_argument("--compare-strict", action="store_true", help="with --compare: fail when the output differs (default: report only)")
p.add_argument("--result-file", help="write {ok, book_dir, pdf} JSON here")
p = sub.add_parser("bookpath", help="print the book folder (relative to the repo root) this PDF will go to")
p.add_argument("pdf")
p = sub.add_parser("ingest")
p.add_argument("pdf")
p.add_argument("--title")
@@ -92,6 +94,14 @@ def main(argv=None):
a = ap.parse_args(argv)
cfg = settings.load(a.settings)
if a.cmd == "bookpath":
import process
try:
title, author, _ = process.resolve_meta(os.path.abspath(a.pdf))
except process.Failed as e:
sys.exit(str(e))
print(os.path.relpath(ingest.book_folder(cfg, author, title), settings.REPO_ROOT))
return
if a.cmd == "process":
import process
sys.exit(process.process(cfg, a.pdf, a.dry_run, a.force, a.title, a.author, a.compare, a.result_file, a.compare_strict))
+13 -4
View File
@@ -40,14 +40,23 @@ def author_slug(author):
return bookio.slug(" ".join([parts[-1]] + parts[:-1])) if parts else "unknown"
def book_folder(cfg, author, title):
return os.path.join(settings.resolve(cfg, "books_dir"), author_slug(author), bookio.slug(title))
def detect(pdf, title=None, author=None):
"""Title and author from the PDF itself (title: PDF metadata; author: a 'By <Name>' line on the first pages)."""
author = author or find_author([u["text"] for u in pdftext.units(pdf, first_pages=6)])
title = title or find_title(pdf)
return title, author
def run(cfg, pdf, title=None, author=None):
"""Returns the book folder path."""
pdf_units = pdftext.units(pdf)
author = author or find_author([u["text"] for u in pdf_units[:6]])
title = title or find_title(pdf)
title, author = detect(pdf, title, author)
if not author or not title:
raise SystemExit("could not find title/author in the PDF; pass --title and --author")
book_dir = os.path.join(settings.resolve(cfg, "books_dir"), author_slug(author), bookio.slug(title))
book_dir = book_folder(cfg, author, title)
os.makedirs(book_dir, exist_ok=True)
copy = os.path.join(book_dir, os.path.basename(pdf))
if os.path.exists(copy) and sha256(copy) != sha256(pdf):
+3 -2
View File
@@ -85,11 +85,12 @@ def align(raw_lines, boxes, window=8):
return result
def units(pdf):
def units(pdf, first_pages=None):
"""Reading units in order: [{'pdf_page', 'half', 'text', 'geom'}]. Text keeps the layout (indentation, hyphens).
'geom' is parallel to text.split('\\n'): the position of each line on the page (see align)."""
result = []
for p, (w, h) in enumerate(page_geometry(pdf), start=1):
geo = page_geometry(pdf)
for p, (w, h) in enumerate(geo[:first_pages] if first_pages else geo, start=1):
boxes = page_lines(pdf, p)
for half, x, width in halves(w, h):
text = _run(["pdftotext", "-layout", "-f", str(p), "-l", str(p),
+26 -9
View File
@@ -112,6 +112,26 @@ 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)
@@ -140,19 +160,16 @@ def check_ocr(cfg, book_dir):
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")
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:
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))])
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",):