backend/runner.py (24342 bytes)
1 """Runs one job: stage inputs, drive the aligner, collect the artifacts. 2 3 Nothing here knows which alignment backend is in use. The command to run, how 4 to read its progress and where it writes the subtitles all come from 5 `aligner.Aligner`, so swapping subplz out does not touch this file. 6 7 The backend is always a subprocess. That boundary is also what lets the public 8 deployment move alignment onto separate GPU workers without code changes. 9 """ 10 11 from __future__ import annotations 12 13 import json 14 import logging 15 import os 16 import re 17 import shutil 18 import subprocess 19 import time 20 from dataclasses import dataclass 21 from datetime import timezone 22 from pathlib import Path 23 24 from . import billing, languages, render 25 from .aligner import AlignRequest, aligner 26 from .db import Artifact, Job, JobStatus, SessionLocal, utcnow 27 from .settings import settings 28 from .storage import storage 29 30 log = logging.getLogger(__name__) 31 32 # Audio containers subplz/ffmpeg handle. Checked at upload time. 33 AUDIO_SUFFIXES = { 34 ".m4b", ".m4a", ".mp3", ".opus", ".ogg", ".oga", ".flac", ".wav", 35 ".aac", ".wma", ".mka", ".mkv", ".mp4", ".webm", ".avi", ".mov", 36 } 37 # Formats subplz reads directly, plus the ones convert.py turns into epub on 38 # the way in (fb2/mobi/azw3). 39 TEXT_SUFFIXES = { 40 ".epub", ".txt", ".srt", ".vtt", ".ass", 41 ".fb2", ".fb2.zip", ".mobi", ".azw", ".azw3", ".prc", 42 } 43 44 45 def text_suffix(name: str) -> str: 46 """Suffix used when staging a text file, keeping the .fb2.zip double.""" 47 lowered = name.lower() 48 if lowered.endswith(".fb2.zip"): 49 return ".fb2.zip" 50 return Path(lowered).suffix 51 52 # Fixed stem for staged inputs: keeps non-ASCII filenames out of the subprocess 53 # command line entirely, and makes the output path deterministic. 54 STAGE_STEM = "source" 55 56 57 class JobFailed(RuntimeError): 58 pass 59 60 61 @dataclass 62 class Paths: 63 root: Path 64 inp: Path 65 out: Path 66 67 @classmethod 68 def for_job(cls, job_id: str) -> "Paths": 69 root = settings.data_dir / "work" / job_id 70 return cls(root=root, inp=root / "input", out=root / "out") 71 72 def create(self) -> None: 73 self.inp.mkdir(parents=True, exist_ok=True) 74 self.out.mkdir(parents=True, exist_ok=True) 75 76 77 def input_prefix(job_id: str) -> str: 78 """Storage prefix mirroring a job's staged `input/` tree. 79 80 Only used when the queue is external: the worker that runs the job is 81 probably not the machine that received the upload. 82 """ 83 return f"{job_id}/input" 84 85 86 def ensure_inputs(job_id: str) -> None: 87 """Make sure the staged inputs are on this machine's disk.""" 88 paths = Paths.for_job(job_id) 89 paths.create() 90 if any(p.is_file() for p in paths.inp.rglob("*")): 91 return # this process received the upload 92 93 prefix = input_prefix(job_id) 94 keys = storage.list_prefix(prefix) 95 if not keys: 96 raise JobFailed( 97 "the uploaded files for this job are no longer available. " 98 "Upload them again." 99 ) 100 for key in keys: 101 storage.fetch_to(key, paths.inp / key[len(prefix) + 1 :]) 102 103 104 def staged_audio_path(job_id: str, original_name: str) -> Path: 105 return Paths.for_job(job_id).inp / f"{STAGE_STEM}{Path(original_name).suffix.lower()}" 106 107 108 def staged_text_path(job_id: str, original_name: str) -> Path: 109 return Paths.for_job(job_id).inp / f"{STAGE_STEM}{text_suffix(original_name)}" 110 111 112 def staged_cover_path(job_id: str, original_name: str) -> Path: 113 """Where a user-supplied cover image is staged. 114 115 Kept out of the `input/` root alongside audio and text so the aligner's 116 directory scan cannot mistake a picture for content. 117 """ 118 inp = Paths.for_job(job_id).inp 119 return inp / "cover" / f"cover{Path(original_name).suffix.lower()}" 120 121 122 def find_cover(job_id: str) -> Path | None: 123 folder = Paths.for_job(job_id).inp / "cover" 124 if not folder.is_dir(): 125 return None 126 return next((p for p in sorted(folder.iterdir()) if p.is_file()), None) 127 128 129 def staged_part_path(job_id: str, index: int, original_name: str) -> Path: 130 """Where chapter file `index` (1-based) of a multi-part audiobook is staged. 131 132 Parts get plain numeric names so the ffmpeg concat list never needs quoting 133 and playback order is unambiguous. 134 """ 135 inp = Paths.for_job(job_id).inp 136 return inp / "parts" / f"{index:04d}{Path(original_name).suffix.lower()}" 137 138 139 # Tolerate damaged input rather than aborting the whole book. ffmpeg's default 140 # max_error_rate (0.667) kills a run over one bad chapter. 141 _FFMPEG_TOLERANT = [ 142 "-max_error_rate", "1.0", 143 "-err_detect", "ignore_err", 144 "-fflags", "+discardcorrupt", 145 ] 146 147 148 def merge_parts(job_id: str, parts: list[Path]) -> Path: 149 """Join a per-chapter audiobook into one file, one chapter per part. 150 151 Keeping chapter marks matters twice over: subplz processes an m4b chapter by 152 chapter, and the runner reads chapter completions to drive progress. 153 """ 154 paths = Paths.for_job(job_id) 155 dest = paths.inp / f"{STAGE_STEM}.m4b" 156 if dest.exists(): 157 return dest 158 159 durations: list[float] = [] 160 for p in parts: 161 d = probe_duration(p) 162 if d is None: 163 raise JobFailed(f"could not read the duration of part {p.name}") 164 durations.append(d) 165 166 scratch = paths.root / "merge" 167 scratch.mkdir(parents=True, exist_ok=True) 168 169 listing = scratch / "parts.txt" 170 # Absolute paths: ffmpeg resolves a relative entry against the directory of 171 # the list file, not the working directory. 172 listing.write_text( 173 "".join(f"file '{p.resolve().as_posix()}'\n" for p in parts), 174 encoding="utf-8", 175 ) 176 177 # Chapter marks at the part boundaries, in milliseconds. 178 meta = [";FFMETADATA1"] 179 start_ms = 0 180 for i, seconds in enumerate(durations, start=1): 181 end_ms = start_ms + int(round(seconds * 1000)) 182 meta += [ 183 "[CHAPTER]", 184 "TIMEBASE=1/1000", 185 f"START={start_ms}", 186 f"END={end_ms}", 187 f"title=Part {i:02d}", 188 ] 189 start_ms = end_ms 190 metadata = scratch / "chapters.txt" 191 metadata.write_text("\n".join(meta) + "\n", encoding="utf-8") 192 193 cmd = [ 194 "ffmpeg", "-hide_banner", "-v", "error", "-y", 195 *_FFMPEG_TOLERANT, 196 "-f", "concat", "-safe", "0", "-i", str(listing), 197 "-i", str(metadata), 198 "-map", "0:a:0", "-map_metadata", "1", "-map_chapters", "1", 199 # Drop cover art and any stray tracks the parts carry, so the only 200 # media stream is the audio. (MP4 still writes a bin_data chapter 201 # track; that one is how the container stores chapters and must stay.) 202 "-vn", "-sn", 203 # Keep listenable quality: the aligner downsamples to 16 kHz mono itself 204 # when it reads the file, but the rendered video uses this same audio, 205 # and 16 kHz mono would sound awful on YouTube. 206 "-c:a", "aac", "-b:a", "128k", 207 str(dest), 208 ] 209 proc = subprocess.run(cmd, capture_output=True, timeout=settings.job_timeout_seconds) 210 if proc.returncode != 0 or not dest.exists(): 211 err = proc.stderr.decode("utf-8", errors="replace").strip().splitlines() 212 tail = " | ".join(err[-3:]) if err else "no stderr" 213 raise JobFailed(f"could not join the {len(parts)} audio parts: {tail}") 214 215 shutil.rmtree(scratch, ignore_errors=True) 216 return dest 217 218 219 def normalize_for_alignment(job_id: str, source: Path) -> Path: 220 """Produce the 16 kHz mono copy the aligner gets fed. 221 222 Two jobs at once: 223 224 * Format. The acoustic model consumes 16 kHz mono no matter what, and 225 handing subplz a 44.1 kHz stereo file has crashed ctranslate2 here 226 (integer divide by zero, part-way through a chapter). Doing the 227 conversion ourselves, once, keeps the aligner on the input that works. 228 * Repair. The error-tolerant flags drop corrupt frames rather than letting 229 ffmpeg abort the run, which is what a damaged chapter would otherwise do. 230 231 The video keeps using `source`, which stays at listenable quality. 232 """ 233 dest = Paths.for_job(job_id).inp / f"{STAGE_STEM}.align.m4a" 234 if dest.exists(): 235 return dest 236 237 cmd = [ 238 "ffmpeg", "-hide_banner", "-v", "error", "-y", 239 *_FFMPEG_TOLERANT, 240 "-i", str(source), 241 "-map", "0:a:0", "-map_chapters", "0", 242 "-vn", "-sn", 243 "-c:a", "aac", "-b:a", "64k", "-ar", "16000", "-ac", "1", 244 str(dest), 245 ] 246 proc = subprocess.run( 247 cmd, capture_output=True, timeout=settings.job_timeout_seconds 248 ) 249 if proc.returncode != 0 or not dest.exists(): 250 err = proc.stderr.decode("utf-8", errors="replace").strip().splitlines() 251 tail = " | ".join(err[-3:]) if err else "no stderr" 252 raise JobFailed(f"could not prepare the audio for alignment: {tail}") 253 return dest 254 255 256 def probe_chapters(path: Path) -> int: 257 """Chapter count, or 1 for a flat file. 258 259 subplz processes chaptered m4b files one chapter at a time, so this is what 260 turns a stream of per-chapter progress bars into an overall percentage. 261 """ 262 try: 263 out = subprocess.run( 264 ["ffprobe", "-v", "error", "-show_chapters", "-print_format", "json", 265 str(path)], 266 capture_output=True, text=True, encoding="utf-8", errors="replace", 267 timeout=120, check=True, 268 ) 269 return max(1, len(json.loads(out.stdout or "{}").get("chapters", []))) 270 except (subprocess.SubprocessError, ValueError, OSError): 271 return 1 272 273 274 def probe_duration(path: Path) -> float | None: 275 """Audio length in seconds, via ffprobe. Used for progress and metadata.""" 276 try: 277 out = subprocess.run( 278 [ 279 "ffprobe", "-v", "error", 280 "-show_entries", "format=duration", 281 "-of", "default=noprint_wrappers=1:nokey=1", 282 str(path), 283 ], 284 capture_output=True, text=True, timeout=120, check=True, 285 ) 286 return float(out.stdout.strip()) 287 except (subprocess.SubprocessError, ValueError, FileNotFoundError, OSError): 288 return None 289 290 291 def _staged(inp: Path, suffixes: set[str]) -> Path: 292 """The one staged input with a suffix in `suffixes`.""" 293 for p in sorted(inp.iterdir()): 294 if p.is_file() and p.suffix.lower() in suffixes: 295 return p 296 raise JobFailed(f"no staged input found with a supported extension in {inp.name}") 297 298 299 def build_request(job: Job, paths: Paths, audio: Path, chapters: int) -> AlignRequest: 300 """Describe the job in backend-neutral terms.""" 301 languages.require(job.language) # reject an unsupported code before we run 302 return AlignRequest( 303 audio=audio, 304 text=_staged(paths.inp, TEXT_SUFFIXES), 305 out_dir=paths.out, 306 language=job.language, 307 model=job.model, 308 device=settings.device, 309 threads=settings.threads or max(1, (os.cpu_count() or 4) - 1), 310 chapters=chapters, 311 ) 312 313 314 def _set(job_id: str, **fields) -> None: 315 with SessionLocal() as s: 316 job = s.get(Job, job_id) 317 if job is None: 318 return 319 for k, v in fields.items(): 320 setattr(job, k, v) 321 s.commit() 322 323 324 def _is_canceled(job_id: str) -> bool: 325 with SessionLocal() as s: 326 job = s.get(Job, job_id) 327 return job is not None and job.status == JobStatus.canceled 328 329 330 def run_job(job_id: str) -> None: 331 """Execute one job end to end. Never raises; failures land on the Job row.""" 332 with SessionLocal() as s: 333 job = s.get(Job, job_id) 334 if job is None: 335 return 336 if job.status == JobStatus.canceled: 337 return 338 audio_name, text_name = job.audio_filename, job.text_filename 339 parts_count = job.audio_parts or 1 340 341 paths = Paths.for_job(job_id) 342 log_path = paths.root / "subplz.log" 343 _set(job_id, status=JobStatus.running, started_at=utcnow(), 344 stage="Preparing", progress=0.01) 345 346 try: 347 # No-op when this process received the upload; pulls from shared storage 348 # when the job was enqueued on another machine. 349 ensure_inputs(job_id) 350 351 if parts_count > 1: 352 _set(job_id, stage=f"Joining {parts_count} audio parts", progress=0.02) 353 parts = sorted(p for p in (paths.inp / "parts").iterdir() if p.is_file()) 354 if len(parts) != parts_count: 355 raise JobFailed( 356 f"expected {parts_count} audio parts but found {len(parts)}" 357 ) 358 audio_in = merge_parts(job_id, parts) 359 else: 360 audio_in = staged_audio_path(job_id, audio_name) 361 362 if not audio_in.exists(): 363 raise JobFailed(f"staged audio missing: {audio_in.name}") 364 365 duration = probe_duration(audio_in) 366 chapters = probe_chapters(audio_in) 367 368 # The aligner gets a normalised 16 kHz mono copy; the video keeps the 369 # full-quality one. 370 _set(job_id, stage="Preparing audio", progress=0.03) 371 align_audio = normalize_for_alignment(job_id, audio_in) 372 373 _set(job_id, audio_duration_seconds=duration, 374 stage=f"Starting {aligner.name}", progress=0.04) 375 376 with SessionLocal() as s: 377 job = s.get(Job, job_id) 378 request = build_request(job, paths, align_audio, chapters) 379 380 returncode = _stream_aligner(job_id, request, log_path) 381 382 if _is_canceled(job_id): 383 return 384 385 if returncode != 0: 386 raise JobFailed( 387 f"{aligner.name} exited with code {returncode}. " 388 f"See the run log for details." 389 ) 390 391 _set(job_id, stage="Collecting output", progress=0.92) 392 _collect_artifacts(job_id, paths, log_path, duration, request, audio_in) 393 394 # The audio, the book and the subtitles stay in the folder of the job 395 # for debugging. The operator deletes them by hand. 396 _set(job_id, status=JobStatus.succeeded, stage="Done", progress=1.0, 397 finished_at=utcnow()) 398 399 except Exception as exc: # noqa: BLE001 - surfaced to the user on the Job row 400 if _is_canceled(job_id): 401 return 402 # Keep the log even on failure: it is the only way to debug an alignment. 403 try: 404 _store_log(job_id, log_path) 405 except Exception: 406 pass 407 _set(job_id, status=JobStatus.failed, error=str(exc)[:4000], 408 stage="Failed", finished_at=utcnow()) 409 # Our failure, not theirs: a credit spent on this run goes back. 410 try: 411 billing.refund_job(job_id) 412 except Exception: # noqa: BLE001 413 log.exception("job %s: could not refund after failure", job_id) 414 415 416 def _stream_aligner(job_id: str, request: AlignRequest, log_path: Path) -> int: 417 """Run the aligner, mirroring its output to a log file and to job progress. 418 419 The backend decides what its output means; this only moves bytes and 420 watches for cancellation and the time limit. 421 """ 422 cmd = aligner.build_command(request) 423 reader = aligner.progress_reader(request) 424 425 log_path.parent.mkdir(parents=True, exist_ok=True) 426 deadline = time.monotonic() + settings.job_timeout_seconds 427 428 with log_path.open("w", encoding="utf-8", errors="replace") as log: 429 log.write("$ " + " ".join(cmd) + "\n\n") 430 log.flush() 431 432 proc = subprocess.Popen( 433 cmd, 434 stdout=subprocess.PIPE, 435 stderr=subprocess.STDOUT, 436 text=True, 437 encoding="utf-8", 438 errors="replace", 439 bufsize=1, 440 env=aligner.environment(), 441 cwd=str(log_path.parent), 442 ) 443 444 try: 445 assert proc.stdout is not None 446 for raw in proc.stdout: 447 log.write(raw) 448 log.flush() 449 450 if time.monotonic() > deadline: 451 proc.kill() 452 raise JobFailed( 453 f"job exceeded the {settings.job_timeout_seconds}s time limit" 454 ) 455 456 if _is_canceled(job_id): 457 proc.kill() 458 return proc.wait() 459 460 line = raw.strip() 461 if not line: 462 continue 463 464 update = reader.feed(line) 465 if update is None: 466 continue 467 fields: dict = {"stage": update.stage} 468 if update.fraction is not None: 469 fields["progress"] = update.fraction 470 _set(job_id, **fields) 471 finally: 472 if proc.poll() is None: 473 proc.kill() 474 proc.wait() 475 476 return proc.returncode 477 478 479 def _collect_artifacts(job_id: str, paths: Paths, log_path: Path, 480 duration: float | None, request: AlignRequest, 481 video_audio: Path) -> None: 482 with SessionLocal() as s: 483 job = s.get(Job, job_id) 484 if job is None: 485 raise JobFailed("job vanished while collecting output") 486 language, model = job.language, job.model 487 audio_name, text_name = job.audio_filename, job.text_filename 488 splitter = job.splitter 489 parts_count = job.audio_parts or 1 490 491 produced = aligner.locate_output(request) 492 if produced is None or not produced.exists(): 493 # The backend may exit 0 and still have failed, so ask it why before 494 # falling back to guessing at the content. 495 reason = aligner.failure_reason(request) 496 if reason: 497 raise JobFailed(f"{aligner.name} failed: {reason}") 498 raise JobFailed( 499 f"{aligner.name} finished but produced no subtitle file. The audio " 500 "and text may not match, or the language may be wrong for this book." 501 ) 502 503 # Give the download a filename the user will recognise. A per-chapter 504 # audiobook has no single audio name worth using, so the book names it. 505 stem = Path(text_name if parts_count > 1 else audio_name).stem 506 download_name = f"{stem}.{language}{aligner.output_suffix}" 507 508 cues, first_cue, last_cue = _summarize_srt(produced) 509 510 srt_key = f"{job_id}/{download_name}" 511 size = storage.put_file(srt_key, produced) 512 513 # Three deliverables, because they serve three different jobs: 514 # .srt - the timing file on its own (HoshiReader whispersync) 515 # .mp4 - clean video, no subtitle track, for YouTube (captions are 516 # uploaded separately there) 517 # .mkv - the same video with the subtitles embedded, for local players 518 # The mkv is a stream copy of the mp4, so the second file is nearly free. 519 video_name = video_key = None 520 embed_name = embed_key = None 521 video_size = embed_size = 0 522 523 if settings.render_video: 524 try: 525 _set(job_id, stage="Rendering video", progress=0.94) 526 scratch = paths.root / "video" 527 # A cover the user supplied wins over the one inside the epub. 528 cover = find_cover(job_id) or render.extract_cover(request.text, scratch) 529 canvas = render.build_canvas(cover, scratch / "canvas.png") 530 531 video_name = f"{stem}.{language}.mp4" 532 out_video = scratch / video_name 533 render.render_video( 534 audio=video_audio, canvas=canvas, 535 dest=out_video, duration=duration, 536 ) 537 video_key = f"{job_id}/{video_name}" 538 video_size = storage.put_file(video_key, out_video) 539 540 try: 541 _set(job_id, stage="Embedding subtitles", progress=0.97) 542 embed_name = f"{stem}.{language}.mkv" 543 out_embed = scratch / embed_name 544 render.mux_subtitles(out_video, produced, out_embed) 545 embed_key = f"{job_id}/{embed_name}" 546 embed_size = storage.put_file(embed_key, out_embed) 547 except Exception as exc: # noqa: BLE001 548 embed_name = embed_key = None 549 log.warning("job %s: subtitle embed failed: %s", job_id, exc) 550 551 except Exception as exc: # noqa: BLE001 - never fail a job over the video 552 # The subtitles are the product, so a failed render is not fatal - 553 # but it must not be silent either, or it looks like it never ran. 554 video_name = video_key = None 555 log.warning("job %s: video render failed: %s", job_id, exc, 556 exc_info=True) 557 558 metadata = { 559 "job_id": job_id, 560 "created_at": utcnow().astimezone(timezone.utc).isoformat(), 561 "source": { 562 "audio_filename": audio_name, 563 "audio_parts": parts_count, 564 "text_filename": text_name, 565 "audio_duration_seconds": duration, 566 }, 567 "alignment": { 568 "backend": aligner.name, 569 "mode": "forced alignment against supplied text (nothing transcribed from scratch)", 570 "model": model, 571 "device": settings.device, 572 "language": language, 573 "language_name": (languages.get(language).name if languages.get(language) else language), 574 "sentence_splitter": splitter, 575 }, 576 "output": { 577 "filename": download_name, 578 "format": "srt", 579 "cue_count": cues, 580 "first_cue_start": first_cue, 581 "last_cue_end": last_cue, 582 "size_bytes": size, 583 }, 584 "video": ( 585 { 586 "filename": video_name, 587 "container": "mp4", 588 "subtitles": "none - upload the .srt to YouTube separately", 589 "for": "youtube", 590 "size_bytes": video_size, 591 } 592 if video_name 593 else None 594 ), 595 "video_embedded": ( 596 { 597 "filename": embed_name, 598 "container": "mkv", 599 "subtitles": "embedded SRT track, enabled by default", 600 "for": "local playback (MPV, VLC, Jellyfin)", 601 "size_bytes": embed_size, 602 } 603 if embed_name 604 else None 605 ), 606 } 607 meta_path = paths.root / "metadata.json" 608 meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), 609 encoding="utf-8") 610 meta_key = f"{job_id}/metadata.json" 611 meta_size = storage.put_file(meta_key, meta_path) 612 613 rows = [ 614 Artifact(job_id=job_id, kind="srt", filename=download_name, 615 storage_key=srt_key, size_bytes=size), 616 Artifact(job_id=job_id, kind="metadata", filename="metadata.json", 617 storage_key=meta_key, size_bytes=meta_size), 618 ] 619 if video_name and video_key: 620 rows.append( 621 Artifact(job_id=job_id, kind="video", filename=video_name, 622 storage_key=video_key, size_bytes=video_size) 623 ) 624 if embed_name and embed_key: 625 rows.append( 626 Artifact(job_id=job_id, kind="video_embedded", filename=embed_name, 627 storage_key=embed_key, size_bytes=embed_size) 628 ) 629 with SessionLocal() as s: 630 for r in rows: 631 s.add(r) 632 s.commit() 633 634 _store_log(job_id, log_path) 635 636 637 def _store_log(job_id: str, log_path: Path) -> None: 638 if not log_path.exists(): 639 return 640 with SessionLocal() as s: 641 existing = ( 642 s.query(Artifact) 643 .filter(Artifact.job_id == job_id, Artifact.kind == "log") 644 .first() 645 ) 646 if existing is not None: 647 return 648 key = f"{job_id}/subplz.log" 649 size = storage.put_file(key, log_path) 650 with SessionLocal() as s: 651 s.add(Artifact(job_id=job_id, kind="log", filename="subplz.log", 652 storage_key=key, size_bytes=size)) 653 s.commit() 654 655 656 _TIME = re.compile(r"(\d{2}):(\d{2}):(\d{2}),(\d{3})") 657 658 659 def _summarize_srt(path: Path) -> tuple[int, float | None, float | None]: 660 """Cue count and span - cheap sanity signal that the alignment covered the book.""" 661 def to_seconds(m: re.Match) -> float: 662 h, mi, s, ms = (int(g) for g in m.groups()) 663 return h * 3600 + mi * 60 + s + ms / 1000 664 665 text = path.read_text(encoding="utf-8", errors="replace") 666 stamps = list(_TIME.finditer(text)) 667 count = text.count(" --> ") 668 if not stamps: 669 return count, None, None 670 return count, to_seconds(stamps[0]), to_seconds(stamps[-1])