textflowkit 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. textflowkit/__init__.py +8 -0
  2. textflowkit/adapters/__init__.py +11 -0
  3. textflowkit/adapters/http_server.py +465 -0
  4. textflowkit/adapters/mcp_server.py +554 -0
  5. textflowkit/cli.py +473 -0
  6. textflowkit/core/__init__.py +5 -0
  7. textflowkit/core/batch.py +186 -0
  8. textflowkit/core/bind.py +66 -0
  9. textflowkit/core/cancel.py +19 -0
  10. textflowkit/core/checkpoint.py +389 -0
  11. textflowkit/core/diarize.py +229 -0
  12. textflowkit/core/engine.py +127 -0
  13. textflowkit/core/executor.py +301 -0
  14. textflowkit/core/jobs.py +241 -0
  15. textflowkit/core/model.py +98 -0
  16. textflowkit/core/paths.py +226 -0
  17. textflowkit/core/pipeline.py +368 -0
  18. textflowkit/core/retrieval.py +148 -0
  19. textflowkit/core/runner.py +146 -0
  20. textflowkit/core/service.py +146 -0
  21. textflowkit/core/sqlite_store.py +233 -0
  22. textflowkit/core/submission.py +220 -0
  23. textflowkit/core/timeutil.py +20 -0
  24. textflowkit/core/translate.py +244 -0
  25. textflowkit/render/__init__.py +191 -0
  26. textflowkit/render/docx.py +71 -0
  27. textflowkit/render/fonts/NotoSans.ttf +0 -0
  28. textflowkit/render/fonts/NotoSansArabic.ttf +0 -0
  29. textflowkit/render/fonts/NotoSansSC.ttf +0 -0
  30. textflowkit/render/fonts/OFL-NotoSans.txt +94 -0
  31. textflowkit/render/fonts/OFL-NotoSansSC.txt +93 -0
  32. textflowkit/render/fonts/README.md +19 -0
  33. textflowkit/render/markdown.py +33 -0
  34. textflowkit/render/pdf.py +136 -0
  35. textflowkit/render/srt.py +22 -0
  36. textflowkit/render/txt.py +19 -0
  37. textflowkit/render/vtt.py +20 -0
  38. textflowkit/sources/__init__.py +16 -0
  39. textflowkit/sources/acquire.py +437 -0
  40. textflowkit/sources/detect.py +200 -0
  41. textflowkit/sources/scratch.py +32 -0
  42. textflowkit-0.1.3.dist-info/METADATA +266 -0
  43. textflowkit-0.1.3.dist-info/RECORD +46 -0
  44. textflowkit-0.1.3.dist-info/WHEEL +4 -0
  45. textflowkit-0.1.3.dist-info/entry_points.txt +4 -0
  46. textflowkit-0.1.3.dist-info/licenses/LICENSE +203 -0
textflowkit/cli.py ADDED
@@ -0,0 +1,473 @@
1
+ """textflowkit command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from textflowkit import __version__
12
+ from textflowkit.core.batch import run_batch
13
+ from textflowkit.core.engine import get_engine
14
+ from textflowkit.core.jobs import JobState, get_default_store
15
+ from textflowkit.core.model import Transcript
16
+ from textflowkit.core.paths import default_input_root, output_root
17
+ from textflowkit.core.pipeline import TranscribeResult
18
+ from textflowkit.core.runner import transcript_for
19
+ from textflowkit.core.submission import SubmissionRequest, submit_request
20
+ from textflowkit.render import (
21
+ BINARY_FORMATS,
22
+ SUPPORTED_FORMATS,
23
+ atomic_write_bytes,
24
+ render,
25
+ render_bytes,
26
+ )
27
+ from textflowkit.sources.acquire import AcquisitionError
28
+ from textflowkit.sources.detect import PLATFORMS
29
+
30
+
31
+ def _build_parser() -> argparse.ArgumentParser:
32
+ p = argparse.ArgumentParser(
33
+ prog="textflowkit",
34
+ description="Transcribe media from a URL or local file into timestamped text and subtitles.",
35
+ )
36
+ p.add_argument("--version", action="version", version=f"textflowkit {__version__}")
37
+ sub = p.add_subparsers(dest="command", required=True)
38
+
39
+ t = sub.add_parser("transcribe", help="transcribe a URL or local media file")
40
+ t.add_argument("source", help="media URL or path to a local file")
41
+ t.add_argument("--formats", default="json,srt,txt",
42
+ help=f"comma-separated outputs (default: json,srt,txt; available: {', '.join(SUPPORTED_FORMATS)})")
43
+ t.add_argument("--output-dir", "-o", default=None, help="directory for written outputs")
44
+ t.add_argument("--language", default=None, help="source language code (e.g. en); default auto-detect")
45
+ t.add_argument("--model", default="small", help="whisper model size (tiny/base/small/medium/large); default small")
46
+ t.add_argument("--device", default=None, help="torch device (cuda/cpu); default auto")
47
+ t.add_argument(
48
+ "--diarize",
49
+ action="store_true",
50
+ help=(
51
+ "label speakers. Requires the optional 'diarize' extra and a Hugging "
52
+ "Face token with access to the gated pyannote model; fails loudly if either is missing"
53
+ ),
54
+ )
55
+ t.add_argument(
56
+ "--translate-to",
57
+ default=None,
58
+ metavar="LANG",
59
+ help=(
60
+ "translate the transcript into LANG (e.g. es) using the configured "
61
+ "backend; the job fails loudly if the backend is unreachable"
62
+ ),
63
+ )
64
+ t.add_argument("--cookies-from-browser", default=None,
65
+ help="pass cookies to yt-dlp from a browser (e.g. firefox) for access-controlled content")
66
+ t.add_argument("--stdout", action="store_true", help="print transcript to stdout instead of writing files")
67
+ t.add_argument("--stdout-format", default="txt", help="format for --stdout (default txt)")
68
+ t.add_argument(
69
+ "--resume",
70
+ action="store_true",
71
+ help="reuse a completed checkpoint for the same source and options when one exists",
72
+ )
73
+ t.add_argument("--quiet", "-q", action="store_true", help="suppress progress messages")
74
+
75
+ b = sub.add_parser("batch", help="transcribe many sources in one invocation")
76
+ b.add_argument("sources", nargs="+", help="media URLs or paths to local files")
77
+ b.add_argument("--formats", default="json,srt,txt",
78
+ help=f"comma-separated outputs (default: json,srt,txt; available: {', '.join(SUPPORTED_FORMATS)})")
79
+ b.add_argument("--output-dir", "-o", default=None, help="directory for written outputs")
80
+ b.add_argument("--language", default=None, help="source language code (e.g. en); default auto-detect")
81
+ b.add_argument("--model", default="small", help="whisper model size (tiny/base/small/medium/large); default small")
82
+ b.add_argument("--device", default=None, help="torch device (cuda/cpu); default auto")
83
+ b.add_argument("--diarize", action="store_true", help="label speakers (same requirements as transcribe)")
84
+ b.add_argument("--translate-to", default=None, metavar="LANG", help="translate transcript into LANG")
85
+ b.add_argument("--cookies-from-browser", default=None,
86
+ help="pass cookies to yt-dlp from a browser (e.g. firefox)")
87
+ b.add_argument(
88
+ "--resume",
89
+ action="store_true",
90
+ help="reuse a completed checkpoint for each matching source and options",
91
+ )
92
+ b.add_argument("--quiet", "-q", action="store_true", help="suppress per-item progress messages")
93
+
94
+ l = sub.add_parser("export", help="re-render an existing transcript JSON")
95
+ l.add_argument("transcript", help="path to a transcript .json file")
96
+ l.add_argument("--format", "-f", default="srt", help=f"output format ({', '.join(SUPPORTED_FORMATS)})")
97
+ l.add_argument("--output", "-o", default=None, help="output file (default stdout)")
98
+
99
+ sub.add_parser("sources", help="list recognised platforms")
100
+ sub.add_parser("doctor", help="report the versions and tools this install will use")
101
+ st = sub.add_parser(
102
+ "selftest",
103
+ help="run a real end-to-end check on this machine (compute device + a tiny transcription)",
104
+ )
105
+ st.add_argument("--model", default="tiny", help="whisper model for the check (default tiny)")
106
+ st.add_argument(
107
+ "--skip-transcribe",
108
+ action="store_true",
109
+ help="only check the compute device, do not load a model",
110
+ )
111
+ return p
112
+
113
+
114
+ def _formats(raw: str) -> list[str]:
115
+ return [f.strip().lower().lstrip(".") for f in raw.split(",") if f.strip()]
116
+
117
+
118
+ def _store_is_durable() -> bool:
119
+ """Whether the default store survives this process.
120
+
121
+ ``_make_store`` falls back to ``MemoryJobStore`` when TEXTFLOWKIT_DB is
122
+ unset, and an in-memory store cannot carry a checkpoint into a later
123
+ invocation. Resume depends on that carrying, so callers warn instead of
124
+ silently redoing the work.
125
+ """
126
+ return bool(os.environ.get("TEXTFLOWKIT_DB"))
127
+
128
+
129
+ def _cmd_transcribe(args: argparse.Namespace) -> int:
130
+ formats = _formats(args.formats)
131
+ output_dir = args.output_dir
132
+ if output_dir is None and not args.stdout:
133
+ output_dir = "."
134
+ if args.resume and not _store_is_durable():
135
+ print(
136
+ "warning: --resume needs a durable store; TEXTFLOWKIT_DB is not set, "
137
+ "so no checkpoint from an earlier run can be found. Re-running from "
138
+ "scratch. Set TEXTFLOWKIT_DB to persist jobs and checkpoints.",
139
+ file=sys.stderr,
140
+ )
141
+
142
+ try:
143
+ request = SubmissionRequest(
144
+ source=args.source, language=args.language, formats=formats,
145
+ output_dir=output_dir, model=args.model, device=args.device,
146
+ cookies_from_browser=args.cookies_from_browser,
147
+ diarize=args.diarize, translate_to=args.translate_to,
148
+ )
149
+ store = get_default_store()
150
+ job = submit_request(store, request, background=False, resume=args.resume)
151
+ except ValueError as exc:
152
+ print(f"error: {exc}", file=sys.stderr)
153
+ return 1
154
+ if job.state is not JobState.DONE:
155
+ print(f"error: {job.error or job.state.value}", file=sys.stderr)
156
+ return 1
157
+ transcript = transcript_for(job)
158
+ if transcript is None:
159
+ print("error: completed job contains no transcript", file=sys.stderr)
160
+ return 1
161
+ return _finish_transcribe(
162
+ args, TranscribeResult(transcript=transcript, outputs=[Path(p) for p in job.outputs]),
163
+ job, store,
164
+ )
165
+
166
+
167
+ def _finish_transcribe(
168
+ args: argparse.Namespace,
169
+ result,
170
+ job,
171
+ store,
172
+ ) -> int:
173
+ """Record and print a result that came from the pipeline or reuse."""
174
+ from textflowkit.core.pipeline import TranscribeResult
175
+
176
+ if not isinstance(result, TranscribeResult):
177
+ transcript, outputs = result
178
+ result = TranscribeResult(transcript=transcript, outputs=list(outputs))
179
+ store.update(
180
+ job.id,
181
+ state=JobState.DONE,
182
+ progress="complete",
183
+ transcript=result.transcript.to_dict(),
184
+ outputs=[str(p) for p in result.outputs],
185
+ )
186
+
187
+ tr = result.transcript
188
+ if not args.quiet:
189
+ segs = len(tr.segments)
190
+ print(f"platform : {tr.platform}", file=sys.stderr)
191
+ print(f"language : {tr.language or 'unknown'}", file=sys.stderr)
192
+ print(f"segments : {segs}", file=sys.stderr)
193
+ if tr.metadata.get("model"):
194
+ print(f"engine : {tr.engine} ({tr.metadata.get('model')} on {tr.metadata.get('device')})", file=sys.stderr)
195
+
196
+ if args.stdout:
197
+ sys.stdout.write(render(tr, args.stdout_format))
198
+ return 0
199
+
200
+ for path in result.outputs:
201
+ print(str(path))
202
+ return 0
203
+
204
+
205
+ def _cmd_batch(args: argparse.Namespace) -> int:
206
+ if args.resume and not _store_is_durable():
207
+ print(
208
+ "warning: batch --resume cannot survive a process restart without "
209
+ "TEXTFLOWKIT_DB; this run may repeat completed transcription. "
210
+ "Set TEXTFLOWKIT_DB for durable resume.",
211
+ file=sys.stderr,
212
+ )
213
+ report = run_batch(
214
+ list(args.sources),
215
+ store=get_default_store(),
216
+ resume=args.resume,
217
+ language=args.language,
218
+ formats=_formats(args.formats),
219
+ output_dir=args.output_dir or ".",
220
+ model=args.model,
221
+ device=args.device,
222
+ cookies_from_browser=args.cookies_from_browser,
223
+ diarize=args.diarize,
224
+ translate_to=args.translate_to,
225
+ )
226
+ if not args.quiet:
227
+ for item in report.items:
228
+ detail = item.error or ", ".join(item.outputs)
229
+ suffix = f" - {detail}" if detail else ""
230
+ print(f"{item.status:<9} {item.source}{suffix}")
231
+ print(
232
+ f"batch: {report.total} total, {report.succeeded} succeeded, "
233
+ f"{report.failed} failed, {report.skipped} skipped"
234
+ )
235
+ return 0 if report.ok else 1
236
+
237
+
238
+ def _cmd_export(args: argparse.Namespace) -> int:
239
+ path = Path(args.transcript)
240
+ if not path.exists():
241
+ print(f"error: no such transcript: {path}", file=sys.stderr)
242
+ return 1
243
+ try:
244
+ tr = Transcript.load_json(path)
245
+ except (OSError, ValueError, KeyError, TypeError) as exc:
246
+ print(f"error: could not read transcript: {exc}", file=sys.stderr)
247
+ return 1
248
+ try:
249
+ fmt = args.format.lower().lstrip(".")
250
+ if fmt in BINARY_FORMATS and not args.output:
251
+ raise ValueError(f"--output is required for binary {fmt} export")
252
+ content = render_bytes(tr, fmt, title=path.stem)
253
+ except (ValueError, ImportError) as exc:
254
+ print(f"error: {exc}", file=sys.stderr)
255
+ return 1
256
+ if args.output:
257
+ try:
258
+ atomic_write_bytes(Path(args.output), content, replace=True)
259
+ except OSError as exc:
260
+ print(f"error: could not write export: {exc}", file=sys.stderr)
261
+ return 1
262
+ print(str(Path(args.output)))
263
+ else:
264
+ sys.stdout.write(content.decode("utf-8"))
265
+ return 0
266
+
267
+
268
+ def _cmd_doctor(_: argparse.Namespace) -> int:
269
+ """Print the environment this install will actually use.
270
+
271
+ Platform support depends on yt-dlp continuing to work against sites it does
272
+ not own, and that breaks from the outside. When a site stops working, the
273
+ first question is which yt-dlp and which JavaScript runtime are in play -
274
+ this answers it without guessing.
275
+ """
276
+ import shutil
277
+
278
+ def line(label: str, value: str) -> None:
279
+ print(f"{label:<18} {value}")
280
+
281
+ line("textflowkit", __version__)
282
+ line("python", sys.version.split()[0])
283
+
284
+ # ffmpeg
285
+ ffmpeg = shutil.which("ffmpeg")
286
+ if ffmpeg:
287
+ try:
288
+ out = subprocess.run(
289
+ [ffmpeg, "-version"], capture_output=True, text=True, check=False
290
+ ).stdout.splitlines()
291
+ line("ffmpeg", out[0] if out else ffmpeg)
292
+ except OSError as exc:
293
+ line("ffmpeg", f"{ffmpeg} (could not run: {exc})")
294
+ else:
295
+ line("ffmpeg", "MISSING - required")
296
+
297
+ # yt-dlp: which one, and how
298
+ from textflowkit.sources.acquire import detect_js_runtime, require_tool
299
+
300
+ try:
301
+ ytdlp_path = require_tool("yt-dlp", module="yt_dlp")
302
+ except AcquisitionError as exc:
303
+ line("yt-dlp", f"MISSING - {exc}")
304
+ else:
305
+ try:
306
+ import yt_dlp
307
+ version = getattr(getattr(yt_dlp, "version", None), "__version__", "unknown")
308
+ except ImportError:
309
+ version = "unknown"
310
+ how = "module (in-process)" if ytdlp_path is None else ytdlp_path
311
+ line("yt-dlp", f"{version} via {how}")
312
+
313
+ runtime = detect_js_runtime()
314
+ line("js runtime", runtime or "none found (YouTube formats may be limited)")
315
+
316
+ # optional extras
317
+ for label, module in (
318
+ ("mcp", "mcp"),
319
+ ("fastapi", "fastapi"),
320
+ ("pyannote", "pyannote.audio"),
321
+ ("python-docx", "docx"),
322
+ ("reportlab", "reportlab"),
323
+ ):
324
+ try:
325
+ __import__(module)
326
+ except ImportError:
327
+ line(label, "not installed")
328
+ else:
329
+ line(label, "installed")
330
+
331
+ # Compute devices are separate decisions: pyannote may be pinned to CPU
332
+ # while Whisper uses ROCm/CUDA, or vice versa.
333
+ try:
334
+ import torch
335
+
336
+ if torch.cuda.is_available():
337
+ name = torch.cuda.get_device_name(0)
338
+ auto_device = "cuda"
339
+ device_label = f"{name} (torch {torch.__version__})"
340
+ else:
341
+ auto_device = "cpu"
342
+ device_label = f"cpu (torch {torch.__version__})"
343
+ except ImportError:
344
+ auto_device = "cpu"
345
+ device_label = "torch not installed"
346
+
347
+ line("whisper device", f"{auto_device}: {device_label}")
348
+ from textflowkit.core.diarize import ENV_DIARIZE_DEVICE
349
+
350
+ diarize_device = os.environ.get(ENV_DIARIZE_DEVICE) or auto_device
351
+ line("diarize device", f"{diarize_device}: {device_label if diarize_device == auto_device else 'configured'}")
352
+
353
+ from textflowkit.core.translate import ENV_OLLAMA_MODEL, OllamaTranslator
354
+
355
+ translation_model = os.environ.get(ENV_OLLAMA_MODEL)
356
+ line("translation model", translation_model or f"not configured (set {ENV_OLLAMA_MODEL})")
357
+ if translation_model:
358
+ line("translation route", OllamaTranslator().route)
359
+
360
+ line("input root", str(default_input_root() or "unconfined (CLI default)"))
361
+ line("output root", str(output_root()))
362
+ line("jobs store", os.environ.get("TEXTFLOWKIT_DB", "in-memory (not durable)"))
363
+ return 0
364
+
365
+
366
+ def _cmd_selftest(args: argparse.Namespace) -> int:
367
+ """Prove the compute path works on THIS machine, end to end.
368
+
369
+ The GPU path cannot run in hosted CI - no runner has an AMD GPU - so the
370
+ honest way to keep it verified is to make the check reproducible and runnable
371
+ on demand rather than relying on one engineer's memory of a good run.
372
+ """
373
+ import tempfile
374
+ import wave
375
+ from pathlib import Path
376
+
377
+ failures: list[str] = []
378
+
379
+ def ok(label: str, detail: str = "") -> None:
380
+ print(f" PASS {label}" + (f" ({detail})" if detail else ""))
381
+
382
+ def bad(label: str, detail: str) -> None:
383
+ failures.append(label)
384
+ print(f" FAIL {label} ({detail})")
385
+
386
+ print("compute")
387
+ try:
388
+ import torch
389
+
390
+ print(f" torch {torch.__version__}, hip={torch.version.hip}, cuda_available={torch.cuda.is_available()}")
391
+ device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
392
+ a = torch.randn(512, 512, device=device)
393
+ b = torch.randn(512, 512, device=device)
394
+ c = a @ b
395
+ torch.cuda.synchronize() if torch.cuda.is_available() else None
396
+ assert c.shape == (512, 512)
397
+ if torch.cuda.is_available():
398
+ name = torch.cuda.get_device_name(0)
399
+ ok("matmul on device", name)
400
+ else:
401
+ ok("matmul on device", "cpu (no GPU visible)")
402
+ except Exception as exc: # noqa: BLE001 - this is a diagnostic
403
+ bad("matmul on device", f"{type(exc).__name__}: {exc}")
404
+
405
+ def summarise() -> int:
406
+ print()
407
+ if failures:
408
+ print(f"SELFTEST FAILED: {', '.join(failures)}")
409
+ return 1
410
+ print("SELFTEST PASSED")
411
+ return 0
412
+
413
+ if args.skip_transcribe:
414
+ return summarise()
415
+
416
+ print("transcribe")
417
+ with tempfile.TemporaryDirectory(prefix="tfk-selftest-") as scratch:
418
+ wav = Path(scratch) / "probe.wav"
419
+ try:
420
+ # 1 second of silence, written as real PCM wav.
421
+ with wave.open(str(wav), "wb") as w:
422
+ w.setnchannels(1)
423
+ w.setsampwidth(2)
424
+ w.setframerate(16000)
425
+ w.writeframes(b"\x00\x00" * 16000)
426
+ ok("generated probe audio", f"{wav.stat().st_size} bytes")
427
+ except Exception as exc: # noqa: BLE001 - diagnostic
428
+ bad("generated probe audio", f"{type(exc).__name__}: {exc}")
429
+ return 1
430
+
431
+ try:
432
+ engine = get_engine("whisper", model=args.model)
433
+ transcript = engine.transcribe(wav)
434
+ ok(
435
+ "whisper ran on this device",
436
+ f"model={args.model} device={transcript.metadata.get('device')} segments={len(transcript.segments)}",
437
+ )
438
+ except Exception as exc: # noqa: BLE001 - diagnostic
439
+ bad("whisper ran on this device", f"{type(exc).__name__}: {exc}")
440
+
441
+ return summarise()
442
+
443
+
444
+ def _cmd_sources(_: argparse.Namespace) -> int:
445
+ for name in sorted(PLATFORMS):
446
+ print(name)
447
+ print("local")
448
+ print("direct")
449
+ return 0
450
+
451
+
452
+ def main(argv: list[str] | None = None) -> int:
453
+ parser = _build_parser()
454
+ args = parser.parse_args(argv)
455
+ if args.command == "transcribe":
456
+ return _cmd_transcribe(args)
457
+ if args.command == "batch":
458
+ return _cmd_batch(args)
459
+ if args.command == "export":
460
+ return _cmd_export(args)
461
+ if args.command == "sources":
462
+ return _cmd_sources(args)
463
+ if args.command == "doctor":
464
+ return _cmd_doctor(args)
465
+ if args.command == "selftest":
466
+ return _cmd_selftest(args)
467
+ parser.print_help()
468
+ return 2
469
+
470
+
471
+ if __name__ == "__main__":
472
+ raise SystemExit(main())
473
+
@@ -0,0 +1,5 @@
1
+ """Core layer: canonical model, pipeline, jobs, and paths."""
2
+
3
+ from textflowkit.core.model import Segment, Transcript
4
+
5
+ __all__ = ["Segment", "Transcript"]
@@ -0,0 +1,186 @@
1
+ """Batch transcription orchestration.
2
+
3
+ Batch is deliberately a thin loop over one-item work. It does not know about
4
+ media acquisition, engines, or rendering; it only creates jobs through the
5
+ existing runner and reports what each job produced.
6
+
7
+ The contract that matters operationally is the same as the ticket's wording:
8
+ one bad source must never erase the results of the other sources. Every item is
9
+ attempted, and every item gets an explicit outcome in the returned report.
10
+
11
+ Resuming a batch item uses the same `prepare_resume` path as the CLI, so an
12
+ interrupted ERROR/CANCELLED job is reopened rather than silently skipped.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from textflowkit.core.checkpoint import (
21
+ find_resumable_checkpoint,
22
+ prepare_resume,
23
+ reusable_done_result,
24
+ )
25
+ from textflowkit.core.executor import JobCancelled
26
+ from textflowkit.core.jobs import JobState, JobStore
27
+ from textflowkit.core.runner import run_job
28
+ from textflowkit.core.submission import SubmissionRequest
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class BatchItem:
33
+ """One source's outcome in a batch run."""
34
+
35
+ source: str
36
+ status: str
37
+ job_id: str | None = None
38
+ error: str | None = None
39
+ outputs: list[str] = field(default_factory=list)
40
+ resumed: bool = False
41
+
42
+ @property
43
+ def succeeded(self) -> bool:
44
+ return self.status == "succeeded"
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "source": self.source,
49
+ "status": self.status,
50
+ "job_id": self.job_id,
51
+ "error": self.error,
52
+ "outputs": list(self.outputs),
53
+ "resumed": self.resumed,
54
+ }
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class BatchReport:
59
+ """Aggregate and per-item outcomes for a batch invocation."""
60
+
61
+ items: list[BatchItem] = field(default_factory=list)
62
+
63
+ @property
64
+ def total(self) -> int:
65
+ return len(self.items)
66
+
67
+ @property
68
+ def succeeded(self) -> int:
69
+ return sum(item.status == "succeeded" for item in self.items)
70
+
71
+ @property
72
+ def failed(self) -> int:
73
+ return sum(item.status == "failed" for item in self.items)
74
+
75
+ @property
76
+ def skipped(self) -> int:
77
+ return sum(item.status == "skipped" for item in self.items)
78
+
79
+ @property
80
+ def ok(self) -> bool:
81
+ return self.failed == 0
82
+
83
+ def to_dict(self) -> dict[str, Any]:
84
+ return {
85
+ "total": self.total,
86
+ "succeeded": self.succeeded,
87
+ "failed": self.failed,
88
+ "skipped": self.skipped,
89
+ "items": [item.to_dict() for item in self.items],
90
+ }
91
+
92
+
93
+ def run_batch(
94
+ sources: list[str],
95
+ *,
96
+ store: JobStore,
97
+ resume: bool = False,
98
+ **kwargs: Any,
99
+ ) -> BatchReport:
100
+ """Run every source through the single-item runner and return a report.
101
+
102
+ `run_job` already records terminal state on the store. This layer adds no
103
+ second pipeline path; it simply catches per-item failures so one bad source
104
+ does not stop the rest.
105
+ """
106
+ report = BatchReport()
107
+ for source in sources:
108
+ item = BatchItem(source=source, status="failed")
109
+ try:
110
+ request = SubmissionRequest(source=source, **kwargs)
111
+ except (TypeError, ValueError) as exc:
112
+ item.error = str(exc)
113
+ report.items.append(item)
114
+ continue
115
+ item_kwargs = request.run_kwargs()
116
+ item_kwargs.pop("source")
117
+ if resume:
118
+ found = find_resumable_checkpoint(
119
+ store,
120
+ source=source,
121
+ model=item_kwargs.get("model", "small"),
122
+ language=item_kwargs.get("language"),
123
+ engine=item_kwargs.get("engine", "whisper"),
124
+ device=item_kwargs.get("device"),
125
+ options=_resume_options(item_kwargs),
126
+ )
127
+ if found is not None:
128
+ prior, checkpoint = found
129
+ item.job_id = prior.id
130
+ prepared = prepare_resume(store, prior, checkpoint)
131
+ if prepared is None:
132
+ reused = reusable_done_result(store, prior)
133
+ if reused is not None:
134
+ _transcript, outputs = reused
135
+ item.status = "succeeded"
136
+ item.resumed = True
137
+ item.outputs = [str(p) for p in outputs]
138
+ item.error = None
139
+ report.items.append(item)
140
+ continue
141
+ job = store.create(source, request=request.to_dict())
142
+ item.job_id = job.id
143
+ else:
144
+ job, payload = prepared
145
+ item.resumed = True
146
+ item_kwargs["resume_checkpoint"] = payload
147
+ else:
148
+ job = store.create(source, request=request.to_dict())
149
+ item.job_id = job.id
150
+ else:
151
+ job = store.create(source, request=request.to_dict())
152
+ item.job_id = job.id
153
+
154
+ try:
155
+ run_job(job, store, source=source, **item_kwargs)
156
+ current = store.get(job.id)
157
+ if current is None:
158
+ item.status = "failed"
159
+ item.error = "job disappeared from the store"
160
+ elif current.state is JobState.DONE:
161
+ item.status = "succeeded"
162
+ item.outputs = list(current.outputs)
163
+ elif current.state is JobState.CANCELLED:
164
+ item.status = "skipped"
165
+ item.error = current.error or "cancelled"
166
+ else:
167
+ item.status = "failed"
168
+ item.error = current.error or f"job ended in state {current.state.value}"
169
+ except JobCancelled:
170
+ item.status = "skipped"
171
+ item.error = "cancelled"
172
+ except Exception as exc: # noqa: BLE001 - isolate one item from the rest
173
+ item.status = "failed"
174
+ item.error = f"{type(exc).__name__}: {exc}"
175
+ report.items.append(item)
176
+ return report
177
+
178
+
179
+ def _resume_options(kwargs: dict[str, Any]) -> dict[str, Any]:
180
+ return {
181
+ "formats": list(kwargs.get("formats") or []),
182
+ "diarize": bool(kwargs.get("diarize", False)),
183
+ "diarizer_backend": kwargs.get("diarizer_backend", "pyannote"),
184
+ "translate_to": kwargs.get("translate_to"),
185
+ "translator_backend": kwargs.get("translator_backend", "ollama"),
186
+ }