inbox.py
Language Python4.8 KB143 lines
"""The watch-folder run: everything waiting in the inbox, one file at a time.launchd starts `transcribe inbox` whenever the inbox changes (WatchPaths). Arun takes a lock, so a second trigger while one is working exits at once; thenext change after it finishes starts the next run. A file is only taken whenits size holds still across a short settle window, so a recording that isstill being copied or downloaded is left for the next run.<root>/inbox/ drop audio or video here<root>/transcripts/ .txt / .md / .json (+ .srt / .vtt)<root>/done/ the source, after success<root>/failed/ the source, plus <name>.error.txt, after failure"""from __future__ import annotationsimport osimport timeimport tracebackfrom dataclasses import dataclassfrom pathlib import Pathfrom .audio import AUDIO_EXTSfrom .filing import move_unique, write_unique@dataclassclass Layout:root: Path@propertydef inbox(self) -> Path:return self.root / "inbox"@propertydef transcripts(self) -> Path:return self.root / "transcripts"@propertydef done(self) -> Path:return self.root / "done"@propertydef failed(self) -> Path:return self.root / "failed"@propertydef lock(self) -> Path:return self.root / ".transcribe.lock"def ensure(self):for d in (self.inbox, self.transcripts, self.done, self.failed):d.mkdir(parents=True, exist_ok=True)class Lock:"""A pid lock file. A lock left by a process that no longer exists is stale."""def __init__(self, path: Path):self.path = pathself.held = Falsedef acquire(self) -> bool:try:fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)except FileExistsError:try:pid = int(self.path.read_text().strip() or 0)os.kill(pid, 0)return False # a live run holds itexcept (ValueError, ProcessLookupError):self.path.unlink(missing_ok=True) # stale: take it overreturn self.acquire()except PermissionError:return Falsewith os.fdopen(fd, "w") as f:f.write(str(os.getpid()))self.held = Truereturn Truedef release(self):if self.held:self.path.unlink(missing_ok=True)self.held = Falsedef candidates(inbox: Path) -> list[Path]:"""Audio and video files by extension. Partial downloads (.part, .crdownload,.download) and hidden files never match, so they wait for the next run."""out = []for p in sorted(inbox.iterdir()):if not p.is_file() or p.name.startswith("."):continueif p.suffix.lower() in AUDIO_EXTS:out.append(p)return outdef stable(paths: list[Path], settle: float) -> list[Path]:"""Keep only files whose size did not change across `settle` seconds."""before = {p: p.stat().st_size for p in paths if p.exists()}if settle > 0:time.sleep(settle)return [p for p, size in before.items() if p.exists() and p.stat().st_size == size and size > 0]def run(layout: Layout, handle, settle: float = 5.0, log=print) -> dict:"""handle(path) -> pipeline.Result.Returns {"done": [...], "failed": [...], "skipped": reason}."""layout.ensure()lock = Lock(layout.lock)if not lock.acquire():log("inbox: another run is in progress; exiting.")return {"done": [], "failed": [], "skipped": "locked"}report = {"done": [], "failed": [], "skipped": None}try:while True:ready = stable(candidates(layout.inbox), settle)ready = [p for p in ready if p.name not in {*report["done"], *report["failed"]}]if not ready:breakfor src in ready:log(f"inbox: {src.name}")try:res = handle(src)move_unique(src, layout.done)report["done"].append(src.name)names = ", ".join(str(p.name) for p in res.outputs.values())log(f" ok: {res.sentences} sentences, {len(res.speakers)} speakers, "f"{res.duration:.0f}s -> {names}")except Exception as e: # one bad file must not stop the restmoved = move_unique(src, layout.failed)write_unique(layout.failed, f"{moved.stem}.error.txt",f"{type(e).__name__}: {e}\n\n{traceback.format_exc()}")report["failed"].append(src.name)log(f" FAILED: {e}")finally:lock.release()if not report["done"] and not report["failed"]:log("inbox: nothing ready.")return report