"""Batch processing — run any sermon-clean subcommand across many files. Real workflow: a preacher records 4-5 sermons on Sunday, all need the same post-processing. Running each one by hand is tedious and error-prone. `batch` takes a glob pattern and runs the requested subcommand on each file. Output goes into a sibling directory tree with `-` filenames. Errors are collected, not raised, so one bad file doesn't kill the whole batch. """ from __future__ import annotations import glob import re import shutil import subprocess from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional @dataclass class BatchItem: src: Path output: Path returncode: int = 0 stderr: str = "" stdout: str = "" @property def ok(self) -> bool: return self.returncode == 0 @dataclass class BatchResult: items: List[BatchItem] = field(default_factory=list) n_ok: int = 0 n_failed: int = 0 def summary(self) -> str: lines = [f"batch: {self.n_ok} ok, {self.n_failed} failed (of {len(self.items)} total)"] for item in self.items: marker = "✓" if item.ok else "✗" lines.append(f" {marker} {item.src.name} -> {item.output}") if not item.ok and item.stderr: # First non-empty stderr line for diagnosis for line in item.stderr.splitlines(): if line.strip(): lines.append(f" stderr: {line.strip()}") break return "\n".join(lines) def _expand_globs(patterns: List[str]) -> List[Path]: """Expand a list of glob patterns into a sorted, deduped list of files.""" seen = set() out = [] for pattern in patterns: for path in sorted(glob.glob(pattern, recursive=True)): p = Path(path) if p.is_file() and p.resolve() not in seen: seen.add(p.resolve()) out.append(p) return out def _make_output_path(src: Path, output_dir: Path, suffix: str, ext: Optional[str]) -> Path: """Compute the output path for a given input. Filename pattern: , where ext defaults to src.suffix. """ out_ext = ext if ext is not None else src.suffix out_name = f"{src.stem}{suffix}{out_ext}" return output_dir / out_name # Valid subcommands for batch. Keep this in sync with cli.py. BATCH_SUBCOMMANDS = {"find", "scan", "si", "silence-index", "mb", "multiband", "slices", "cut", "paste", "auto", "pipe", "normalize", "silence-stats", "threshold-tune", "denoise"} def run_batch( subcommand: str, sources: List[Path], output_dir: Path, *, output_suffix: str = "-fixed", output_extension: Optional[str] = None, extra_args: Optional[List[str]] = None, ) -> BatchResult: """Run `sermon-clean ` over each source file. Args: subcommand: which sermon-clean subcommand to invoke. sources: list of input audio files. output_dir: directory to write outputs to. Created if missing. output_suffix: filename suffix for outputs (e.g. '-normalized'). Ignored for subcommands that don't produce an output file (find/scan/si/mb/silence-stats/threshold-tune/slices). output_extension: extension override (e.g. '.wav'). Defaults to src.suffix. extra_args: additional args to pass to the subcommand. Note that `--suffix` is reserved by `batch` itself — strip it if you forward it via this param. (We don't currently forward anything from the batch CLI to subcommands other than the implicit `-o `.) Returns: BatchResult with per-file success/failure. Raises: ValueError: if subcommand is not a recognized sermon-clean command. """ if subcommand not in BATCH_SUBCOMMANDS: raise ValueError( f"unknown subcommand for batch: {subcommand!r}. " f"valid: {sorted(BATCH_SUBCOMMANDS)}" ) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) # Find the sermon-clean executable in PATH or fall back to python -m sc_bin = shutil.which("sermon-clean") if sc_bin: cmd_base = [sc_bin] else: import sys cmd_base = [sys.executable, "-m", "sermon_clean.cli"] result = BatchResult() extra_args = extra_args or [] for src in sources: src = Path(src) out = _make_output_path(src, output_dir, output_suffix, output_extension) # Build the per-file argv. -o / --output is added only for subcommands # that produce a single output file. output_subcommands = {"cut", "paste", "normalize", "denoise", "pipe"} argv = cmd_base + [subcommand, str(src)] + extra_args if subcommand in output_subcommands: argv += ["-o", str(out)] proc = subprocess.run(argv, capture_output=True, text=True) result.items.append(BatchItem( src=src, output=out, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr, )) if proc.returncode == 0: result.n_ok += 1 else: result.n_failed += 1 return result