mattrguerra/transcription-pipelinemainPrivate on GitHub, published here in full

inbox.py

Language Python4.8 KB143 lines

transcription_pipeline/inbox.py

  1. """The watch-folder run: everything waiting in the inbox, one file at a time.
  2. launchd starts `transcribe inbox` whenever the inbox changes (WatchPaths). A
  3. run takes a lock, so a second trigger while one is working exits at once; the
  4. next change after it finishes starts the next run. A file is only taken when
  5. its size holds still across a short settle window, so a recording that is
  6. still being copied or downloaded is left for the next run.
  7. <root>/inbox/ drop audio or video here
  8. <root>/transcripts/ .txt / .md / .json (+ .srt / .vtt)
  9. <root>/done/ the source, after success
  10. <root>/failed/ the source, plus <name>.error.txt, after failure
  11. """
  12. from __future__ import annotations
  13. import os
  14. import time
  15. import traceback
  16. from dataclasses import dataclass
  17. from pathlib import Path
  18. from .audio import AUDIO_EXTS
  19. from .filing import move_unique, write_unique
  20. @dataclass
  21. class Layout:
  22. root: Path
  23. @property
  24. def inbox(self) -> Path:
  25. return self.root / "inbox"
  26. @property
  27. def transcripts(self) -> Path:
  28. return self.root / "transcripts"
  29. @property
  30. def done(self) -> Path:
  31. return self.root / "done"
  32. @property
  33. def failed(self) -> Path:
  34. return self.root / "failed"
  35. @property
  36. def lock(self) -> Path:
  37. return self.root / ".transcribe.lock"
  38. def ensure(self):
  39. for d in (self.inbox, self.transcripts, self.done, self.failed):
  40. d.mkdir(parents=True, exist_ok=True)
  41. class Lock:
  42. """A pid lock file. A lock left by a process that no longer exists is stale."""
  43. def __init__(self, path: Path):
  44. self.path = path
  45. self.held = False
  46. def acquire(self) -> bool:
  47. try:
  48. fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
  49. except FileExistsError:
  50. try:
  51. pid = int(self.path.read_text().strip() or 0)
  52. os.kill(pid, 0)
  53. return False # a live run holds it
  54. except (ValueError, ProcessLookupError):
  55. self.path.unlink(missing_ok=True) # stale: take it over
  56. return self.acquire()
  57. except PermissionError:
  58. return False
  59. with os.fdopen(fd, "w") as f:
  60. f.write(str(os.getpid()))
  61. self.held = True
  62. return True
  63. def release(self):
  64. if self.held:
  65. self.path.unlink(missing_ok=True)
  66. self.held = False
  67. def candidates(inbox: Path) -> list[Path]:
  68. """Audio and video files by extension. Partial downloads (.part, .crdownload,
  69. .download) and hidden files never match, so they wait for the next run."""
  70. out = []
  71. for p in sorted(inbox.iterdir()):
  72. if not p.is_file() or p.name.startswith("."):
  73. continue
  74. if p.suffix.lower() in AUDIO_EXTS:
  75. out.append(p)
  76. return out
  77. def stable(paths: list[Path], settle: float) -> list[Path]:
  78. """Keep only files whose size did not change across `settle` seconds."""
  79. before = {p: p.stat().st_size for p in paths if p.exists()}
  80. if settle > 0:
  81. time.sleep(settle)
  82. return [p for p, size in before.items() if p.exists() and p.stat().st_size == size and size > 0]
  83. def run(layout: Layout, handle, settle: float = 5.0, log=print) -> dict:
  84. """handle(path) -> pipeline.Result.
  85. Returns {"done": [...], "failed": [...], "skipped": reason}.
  86. """
  87. layout.ensure()
  88. lock = Lock(layout.lock)
  89. if not lock.acquire():
  90. log("inbox: another run is in progress; exiting.")
  91. return {"done": [], "failed": [], "skipped": "locked"}
  92. report = {"done": [], "failed": [], "skipped": None}
  93. try:
  94. while True:
  95. ready = stable(candidates(layout.inbox), settle)
  96. ready = [p for p in ready if p.name not in {*report["done"], *report["failed"]}]
  97. if not ready:
  98. break
  99. for src in ready:
  100. log(f"inbox: {src.name}")
  101. try:
  102. res = handle(src)
  103. move_unique(src, layout.done)
  104. report["done"].append(src.name)
  105. names = ", ".join(str(p.name) for p in res.outputs.values())
  106. log(f" ok: {res.sentences} sentences, {len(res.speakers)} speakers, "
  107. f"{res.duration:.0f}s -> {names}")
  108. except Exception as e: # one bad file must not stop the rest
  109. moved = move_unique(src, layout.failed)
  110. write_unique(layout.failed, f"{moved.stem}.error.txt",
  111. f"{type(e).__name__}: {e}\n\n{traceback.format_exc()}")
  112. report["failed"].append(src.name)
  113. log(f" FAILED: {e}")
  114. finally:
  115. lock.release()
  116. if not report["done"] and not report["failed"]:
  117. log("inbox: nothing ready.")
  118. return report