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
@@ -0,0 +1,368 @@
1
+ """The shared pipeline.
2
+
3
+ Every platform, every caller, every adapter runs this same path:
4
+
5
+ resolve source -> acquire media -> extract audio -> transcribe -> render
6
+
7
+ Platform differences live entirely in the source layer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import tempfile
14
+ import time
15
+ import uuid
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from textflowkit.core.checkpoint import (
22
+ CheckpointRecord,
23
+ local_source_identity,
24
+ parse_checkpoint,
25
+ validate_local_resume,
26
+ )
27
+ from textflowkit.core.diarize import DiarizationError, assign_speakers, get_diarizer
28
+ from textflowkit.core.engine import get_engine
29
+ from textflowkit.core.model import Transcript
30
+ from textflowkit.core.paths import (
31
+ UnsafeInputPathError,
32
+ default_input_root,
33
+ resolve_input_path,
34
+ )
35
+ from textflowkit.core.service import enforce_media_limits, enforce_predecode_limits
36
+ from textflowkit.core.translate import (
37
+ TranslationError,
38
+ get_translator,
39
+ translate_segments,
40
+ )
41
+ from textflowkit.render import SUPPORTED_FORMATS, write_all
42
+ from textflowkit.sources.acquire import (
43
+ AcquisitionError,
44
+ extract_audio,
45
+ fetch_media,
46
+ require_tool,
47
+ stage_confined_local_media,
48
+ )
49
+ from textflowkit.sources.detect import resolve_source
50
+
51
+
52
+ class PipelineError(RuntimeError):
53
+ """Raised when any stage of the pipeline fails."""
54
+
55
+
56
+ def _cleanup_scratch(path: Path) -> None:
57
+ """Remove a completed attempt, tolerating brief Windows decoder file locks.
58
+
59
+ A failed cleanup must not be silently ignored: the scratch tree may contain
60
+ downloaded media, so report a persistent failure to the caller.
61
+ """
62
+ for attempt in range(10):
63
+ try:
64
+ shutil.rmtree(path)
65
+ return
66
+ except FileNotFoundError:
67
+ return
68
+ except OSError as exc:
69
+ if attempt == 9:
70
+ raise PipelineError(f"scratch cleanup failed: {exc}") from exc
71
+ time.sleep(0.1)
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class TranscribeResult:
76
+ transcript: Transcript
77
+ outputs: list[Path]
78
+
79
+
80
+ def _existing_path(raw: str | None) -> Path | None:
81
+ if not raw:
82
+ return None
83
+ path = Path(raw)
84
+ return path if path.exists() else None
85
+
86
+
87
+ def _resume_transcript(
88
+ resumed: CheckpointRecord | None,
89
+ *,
90
+ source: str,
91
+ ref,
92
+ ) -> Transcript | None:
93
+ if resumed is None or resumed.transcript is None:
94
+ return None
95
+ try:
96
+ transcript = Transcript.from_dict(resumed.transcript)
97
+ except (KeyError, TypeError, ValueError):
98
+ return None
99
+ transcript.source = source
100
+ transcript.platform = ref.platform
101
+ return transcript
102
+
103
+
104
+ def _checkpoint_paths_usable(resumed: CheckpointRecord | None) -> bool:
105
+ if resumed is None or resumed.transcript is None:
106
+ return False
107
+ return "transcribe" in resumed.finished_stages
108
+
109
+
110
+ def transcribe(
111
+ source: str,
112
+ *,
113
+ language: str | None = None,
114
+ formats: list[str] | None = None,
115
+ output_dir: str | Path | None = None,
116
+ model: str = "small",
117
+ engine: str = "whisper",
118
+ device: str | None = None,
119
+ cookies_from_browser: str | None = None,
120
+ keep_media: bool = False,
121
+ work_dir: str | Path | None = None,
122
+ check_cancel: Callable[[], None] | None = None,
123
+ input_root: str | Path | None = None,
124
+ diarize: bool = False,
125
+ diarizer_backend: str = "pyannote",
126
+ translate_to: str | None = None,
127
+ translator_backend: str = "ollama",
128
+ resume_checkpoint: dict[str, Any] | None = None,
129
+ on_checkpoint: Callable[[dict[str, Any]], None] | None = None,
130
+ output_id: str | None = None,
131
+ ) -> TranscribeResult:
132
+ """Run the full pipeline for a URL or local file.
133
+
134
+ `check_cancel` is an optional callback invoked at stage boundaries and
135
+ during yt-dlp progress and ffmpeg decode. It is expected to raise to abort
136
+ the run. Model inference and optional postprocessors still only stop at
137
+ their next stage boundary.
138
+
139
+ `resume_checkpoint` carries a validated snapshot from an earlier run.
140
+ Completed transcript work is reused; acquisition and engine work are skipped.
141
+ `on_checkpoint` receives an atomic snapshot after each completed stage.
142
+ """
143
+ resumed = parse_checkpoint(resume_checkpoint)
144
+ finished_stages = list(resumed.finished_stages) if resumed else []
145
+ media: Path | None = None
146
+ audio: Path | None = None
147
+ transcript: Transcript | None = None
148
+ local_identity: dict[str, Any] | None = resumed.local_identity if resumed else None
149
+
150
+ def _checkpoint(stage: str | None = None) -> CheckpointRecord | None:
151
+ if check_cancel is not None:
152
+ check_cancel()
153
+ if stage is None or on_checkpoint is None:
154
+ return None
155
+ if stage not in finished_stages:
156
+ finished_stages.append(stage)
157
+ snapshot = CheckpointRecord(
158
+ source=source,
159
+ model=model,
160
+ language=language,
161
+ engine=engine,
162
+ device=resumed.device if resumed else device,
163
+ options={
164
+ "formats": list(formats or []),
165
+ "diarize": diarize,
166
+ "diarizer_backend": diarizer_backend,
167
+ "translate_to": translate_to,
168
+ "translator_backend": translator_backend,
169
+ },
170
+ finished_stages=list(finished_stages),
171
+ transcript=transcript.to_dict() if isinstance(transcript, Transcript) else None,
172
+ media_path=_recordable(media),
173
+ audio_path=_recordable(audio),
174
+ local_identity=local_identity,
175
+ )
176
+ on_checkpoint(snapshot.to_dict())
177
+ return snapshot
178
+
179
+ def _recordable(path: Path | None) -> str | None:
180
+ """A path we can honestly promise to a later run.
181
+
182
+ The scratch directory is deleted at the end of a run, so recording a
183
+ path inside it produces a checkpoint that looks valid but can never be
184
+ used - resume finds the file gone and silently redoes the work. Only
185
+ paths outside scratch are worth storing.
186
+ """
187
+ if path is None:
188
+ return None
189
+ try:
190
+ resolved = Path(path).resolve()
191
+ except OSError:
192
+ return None
193
+ if scratch is not None and scratch.resolve() in resolved.parents:
194
+ return None
195
+ return str(path)
196
+
197
+ _checkpoint()
198
+
199
+ formats = formats or ["json", "srt", "txt"]
200
+ for fmt in formats:
201
+ if fmt.lower().lstrip(".") not in SUPPORTED_FORMATS:
202
+ raise PipelineError(
203
+ f"unsupported format '{fmt}'; choose from {', '.join(SUPPORTED_FORMATS)}"
204
+ )
205
+
206
+ # A local path may be confined; a URL is guarded separately by the SSRF
207
+ # check inside resolve_source. `input_root=None` means "use the configured
208
+ # root if one is set", which keeps the CLI unconfined by default.
209
+ root = input_root if input_root is not None else default_input_root()
210
+ resolved_source = source
211
+ if not source.startswith(("http://", "https://")):
212
+ try:
213
+ resolved_source = str(resolve_input_path(source, root=root))
214
+ except (FileNotFoundError, ValueError, UnsafeInputPathError) as exc:
215
+ raise PipelineError(str(exc)) from exc
216
+
217
+ try:
218
+ ref = resolve_source(resolved_source)
219
+ except (FileNotFoundError, ValueError) as exc:
220
+ raise PipelineError(str(exc)) from exc
221
+
222
+ _checkpoint("source")
223
+
224
+ if resumed is not None and ref.kind == "file":
225
+ try:
226
+ validate_local_resume(resumed, resolved_source, input_root=root)
227
+ except ValueError as exc:
228
+ raise PipelineError(str(exc)) from exc
229
+
230
+ if work_dir is not None:
231
+ Path(work_dir).mkdir(parents=True, exist_ok=True)
232
+ scratch = Path(tempfile.mkdtemp(prefix="textflowkit-", dir=work_dir))
233
+
234
+ try:
235
+ transcript = _resume_transcript(resumed, source=source, ref=ref)
236
+ if resumed is not None:
237
+ media = _existing_path(resumed.media_path)
238
+ audio = _existing_path(resumed.audio_path)
239
+
240
+ # Resuming means "the transcript already exists, do not transcribe again".
241
+ # The media files are a separate question: they live in scratch and are
242
+ # deleted after every run, so requiring them would make resume impossible.
243
+ # If a later stage (diarization) genuinely needs the audio and it is gone,
244
+ # re-acquire it - that is far cheaper than re-running Whisper.
245
+ can_resume = transcript is not None and _checkpoint_paths_usable(resumed)
246
+ if not can_resume:
247
+ transcript = None
248
+
249
+ try:
250
+ if not can_resume:
251
+ require_tool("ffmpeg")
252
+ if ref.kind == "file" and root is not None:
253
+ media = stage_confined_local_media(
254
+ ref.location, work_dir=scratch, input_root=root
255
+ )
256
+ else:
257
+ media = fetch_media(
258
+ ref,
259
+ work_dir=scratch,
260
+ cookies_from_browser=cookies_from_browser,
261
+ check_cancel=check_cancel,
262
+ )
263
+ if ref.kind == "file":
264
+ local_identity = local_source_identity(
265
+ resolved_source, input_root=root, content_path=media,
266
+ )
267
+ _checkpoint("fetch")
268
+ enforce_predecode_limits(media)
269
+ audio = extract_audio(media, work_dir=scratch, check_cancel=check_cancel)
270
+ enforce_media_limits(media, audio)
271
+ _checkpoint("extract")
272
+
273
+ eng = get_engine(engine, model=model, device=device)
274
+ try:
275
+ transcript = eng.transcribe(audio, language=language)
276
+ except Exception as exc: # engine failures are user-facing
277
+ raise PipelineError(f"transcription failed: {exc}") from exc
278
+ if ref.kind == "file":
279
+ try:
280
+ current = local_source_identity(resolved_source, input_root=root)
281
+ except (FileNotFoundError, OSError, ValueError) as exc:
282
+ raise PipelineError("local source changed during transcription") from exc
283
+ if current != local_identity:
284
+ raise PipelineError("local source changed during transcription")
285
+ _checkpoint("transcribe")
286
+ elif diarize and audio is None:
287
+ # A finished transcript is the expensive checkpoint. Reacquire
288
+ # only the audio required by pyannote; never rerun Whisper.
289
+ require_tool("ffmpeg")
290
+ if media is None:
291
+ if ref.kind == "file" and root is not None:
292
+ media = stage_confined_local_media(
293
+ ref.location, work_dir=scratch, input_root=root
294
+ )
295
+ else:
296
+ media = fetch_media(
297
+ ref, work_dir=scratch,
298
+ cookies_from_browser=cookies_from_browser,
299
+ check_cancel=check_cancel,
300
+ )
301
+ _checkpoint("fetch")
302
+ enforce_predecode_limits(media)
303
+ audio = extract_audio(media, work_dir=scratch, check_cancel=check_cancel)
304
+ enforce_media_limits(media, audio)
305
+ _checkpoint("extract")
306
+ except (AcquisitionError, UnsafeInputPathError) as exc:
307
+ raise PipelineError(str(exc)) from exc
308
+
309
+ if diarize:
310
+ # Refuse loudly rather than returning a transcript with empty speakers.
311
+ # A silent no-op here is exactly the defect that was removed from
312
+ # --speaker-labels, and it must not come back through this door.
313
+ if audio is None:
314
+ raise PipelineError("diarization requested but no audio is available for resume")
315
+ try:
316
+ diarizer = get_diarizer(diarizer_backend)
317
+ turns = diarizer.diarize(audio)
318
+ except DiarizationError as exc:
319
+ raise PipelineError(f"diarization requested but unavailable: {exc}") from exc
320
+ except Exception as exc:
321
+ raise PipelineError(f"diarization failed: {exc}") from exc
322
+ labelled = assign_speakers(transcript.segments, turns)
323
+ transcript.metadata["diarization"] = {
324
+ "backend": getattr(diarizer, "name", diarizer_backend),
325
+ "speakers": sorted({t.speaker for t in turns}),
326
+ "turns": len(turns),
327
+ "segments_labelled": labelled,
328
+ }
329
+
330
+ if translate_to:
331
+ # Refuse loudly: never present source text as though it were translated.
332
+ try:
333
+ translator = get_translator(translator_backend)
334
+ translated = translate_segments(
335
+ transcript.segments, translate_to, translator=translator
336
+ )
337
+ except TranslationError as exc:
338
+ raise PipelineError(f"translation requested but unavailable: {exc}") from exc
339
+ except ValueError as exc:
340
+ raise PipelineError(f"translation failed: {exc}") from exc
341
+ transcript.metadata["translation"] = {
342
+ "backend": getattr(translator, "name", translator_backend),
343
+ "route": getattr(translator, "route", "unknown"),
344
+ "target": translate_to,
345
+ "segments_translated": translated,
346
+ }
347
+
348
+ _checkpoint("postprocess")
349
+
350
+ transcript.source = source
351
+ transcript.platform = ref.platform
352
+
353
+ outputs: list[Path] = []
354
+ if output_dir is not None:
355
+ stem = Path(ref.location).stem if ref.kind != "url" else "transcript"
356
+ stem = stem.replace("textflowkit-", "") or "transcript"
357
+ stem = f"{stem}-{output_id or uuid.uuid4().hex[:16]}"
358
+ outputs = write_all(transcript, formats=formats, output_dir=output_dir, stem=stem)
359
+ _checkpoint("render")
360
+
361
+ assert transcript is not None
362
+ result = TranscribeResult(transcript=transcript, outputs=outputs)
363
+ except BaseException:
364
+ _cleanup_scratch(scratch)
365
+ raise
366
+ if not keep_media:
367
+ _cleanup_scratch(scratch)
368
+ return result
@@ -0,0 +1,148 @@
1
+ """Transcript retrieval: paging, time ranges, and search.
2
+
3
+ A long transcript returned whole is a context problem. A 19-minute video is
4
+ already ~51 KB of JSON (~214 segments); a 3-hour podcast is several hundred KB
5
+ dropped into a model's context in a single tool result, most of it irrelevant to
6
+ the question being asked.
7
+
8
+ This module does the slicing in one place so the MCP and HTTP adapters cannot
9
+ disagree about what `offset` or `start` mean - the same reasoning that put the
10
+ pipeline in core rather than in each adapter.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+
17
+ from textflowkit.core.model import Segment, Transcript
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class Page:
22
+ """A window onto a transcript's segments."""
23
+
24
+ segments: list[Segment]
25
+ total: int
26
+ offset: int
27
+ returned: int
28
+ has_more: bool
29
+ start: float
30
+ end: float
31
+
32
+ def as_dict(self) -> dict:
33
+ return {
34
+ "total_segments": self.total,
35
+ "offset": self.offset,
36
+ "returned": self.returned,
37
+ "has_more": self.has_more,
38
+ "start": self.start,
39
+ "end": self.end,
40
+ }
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class Match:
45
+ """A search hit, plus optional surrounding context."""
46
+
47
+ index: int
48
+ segment: Segment
49
+ context_before: list[Segment] = field(default_factory=list)
50
+ context_after: list[Segment] = field(default_factory=list)
51
+
52
+
53
+ def page_segments(
54
+ transcript: Transcript,
55
+ *,
56
+ offset: int = 0,
57
+ limit: int | None = None,
58
+ start: float | None = None,
59
+ end: float | None = None,
60
+ ) -> Page:
61
+ """Slice a transcript by time range and/or page offset.
62
+
63
+ Filtering order is time first, then offset/limit within the filtered set, so
64
+ `offset` counts from the start of the requested window rather than from the
65
+ beginning of the transcript.
66
+
67
+ Raises `ValueError` for a negative offset or limit.
68
+ """
69
+ if offset < 0:
70
+ raise ValueError("offset must be >= 0")
71
+ if limit is not None and limit < 0:
72
+ raise ValueError("limit must be >= 0")
73
+
74
+ segments = [s for s in transcript.segments if not s.hidden]
75
+
76
+ if start is not None:
77
+ segments = [s for s in segments if s.end >= start]
78
+ if end is not None:
79
+ segments = [s for s in segments if s.start <= end]
80
+
81
+ total = len(segments)
82
+ window = segments[offset:] if offset else segments
83
+ if limit is not None:
84
+ window = window[:limit]
85
+
86
+ first = window[0].start if window else 0.0
87
+ last = window[-1].end if window else 0.0
88
+ consumed = offset + len(window)
89
+
90
+ return Page(
91
+ segments=window,
92
+ total=total,
93
+ offset=offset,
94
+ returned=len(window),
95
+ has_more=consumed < total,
96
+ start=first,
97
+ end=last,
98
+ )
99
+
100
+
101
+ def search_segments(
102
+ transcript: Transcript,
103
+ query: str,
104
+ *,
105
+ limit: int = 20,
106
+ context: int = 0,
107
+ case_sensitive: bool = False,
108
+ ) -> list[Match]:
109
+ """Find segments whose text contains `query`.
110
+
111
+ `context` includes that many neighbouring segments either side, which is
112
+ usually what makes a hit readable. Matching is substring, not fuzzy - the
113
+ caller can ask again with a different phrase.
114
+
115
+ Raises `ValueError` for an empty query or negative limit/context.
116
+ """
117
+ if not query:
118
+ raise ValueError("query must not be empty")
119
+ if limit < 0:
120
+ raise ValueError("limit must be >= 0")
121
+ if context < 0:
122
+ raise ValueError("context must be >= 0")
123
+ if limit == 0:
124
+ return []
125
+
126
+ needle = query if case_sensitive else query.lower()
127
+ visible = [s for s in transcript.segments if not s.hidden]
128
+
129
+ matches: list[Match] = []
130
+ for idx, segment in enumerate(visible):
131
+ hay = segment.text if case_sensitive else segment.text.lower()
132
+ translated = segment.translated_text
133
+ if translated:
134
+ hay_translated = translated if case_sensitive else translated.lower()
135
+ else:
136
+ hay_translated = ""
137
+ if needle in hay or (hay_translated and needle in hay_translated):
138
+ matches.append(
139
+ Match(
140
+ index=idx,
141
+ segment=segment,
142
+ context_before=visible[max(0, idx - context): idx] if context else [],
143
+ context_after=visible[idx + 1: idx + 1 + context] if context else [],
144
+ )
145
+ )
146
+ if len(matches) >= limit:
147
+ break
148
+ return matches
@@ -0,0 +1,146 @@
1
+ """Job execution.
2
+
3
+ `run_job` executes one job synchronously and owns its state transitions.
4
+ `submit` is the entry point callers use; it delegates to the process-wide
5
+ `JobExecutor`, so jobs get bounded concurrency and can be cancelled.
6
+
7
+ Cancellation contract:
8
+
9
+ - `JobCancelled` is an orderly stop, not a failure. The job ends CANCELLED.
10
+ - A job that finishes while a cancellation is in flight must not overwrite the
11
+ cancelled state with DONE, so the final transition re-reads the job first.
12
+ - Explicit resume of an interrupted job is the one path allowed past the
13
+ terminal-state guard. Callers use `prepare_resume` first, which resets the job
14
+ to PENDING while preserving its checkpoint.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from textflowkit.core.checkpoint import write_checkpoint
24
+ from textflowkit.core.executor import JobCancelled, get_default_executor
25
+ from textflowkit.core.jobs import Job, JobState, JobStore
26
+ from textflowkit.core.model import Transcript
27
+ from textflowkit.core.pipeline import PipelineError, transcribe
28
+
29
+
30
+ def run_job(
31
+ job: Job,
32
+ store: JobStore,
33
+ *,
34
+ source: str,
35
+ language: str | None = None,
36
+ formats: list[str] | None = None,
37
+ output_dir: str | Path | None = None,
38
+ model: str = "small",
39
+ engine: str = "whisper",
40
+ device: str | None = None,
41
+ cookies_from_browser: str | None = None,
42
+ keep_media: bool = False,
43
+ work_dir: str | Path | None = None,
44
+ check_cancel: Callable[[], None] | None = None,
45
+ input_root: str | Path | None = None,
46
+ diarize: bool = False,
47
+ diarizer_backend: str = "pyannote",
48
+ translate_to: str | None = None,
49
+ translator_backend: str = "ollama",
50
+ resume_checkpoint: dict[str, Any] | None = None,
51
+ ) -> None:
52
+ """Execute a job, recording its terminal state. Callers decide the thread."""
53
+ # Do not start work that has already been cancelled or otherwise finished.
54
+ # `submit` only hands us fresh jobs; explicit resume prepares the row first.
55
+ current = store.get(job.id)
56
+ if current is not None and current.is_terminal:
57
+ return
58
+
59
+ store.update(job.id, state=JobState.RUNNING, progress="starting")
60
+
61
+ try:
62
+ result = transcribe(
63
+ source,
64
+ language=language,
65
+ formats=formats,
66
+ output_dir=output_dir,
67
+ model=model,
68
+ engine=engine,
69
+ device=device,
70
+ cookies_from_browser=cookies_from_browser,
71
+ keep_media=keep_media,
72
+ work_dir=work_dir,
73
+ check_cancel=check_cancel,
74
+ input_root=input_root,
75
+ diarize=diarize,
76
+ diarizer_backend=diarizer_backend,
77
+ translate_to=translate_to,
78
+ translator_backend=translator_backend,
79
+ resume_checkpoint=resume_checkpoint,
80
+ on_checkpoint=lambda record: write_checkpoint(store, job.id, record),
81
+ output_id=job.id,
82
+ )
83
+ except JobCancelled:
84
+ store.update(job.id, state=JobState.CANCELLED, progress="cancelled")
85
+ return
86
+ except PipelineError as exc:
87
+ store.update(job.id, state=JobState.ERROR, error=str(exc), progress="failed")
88
+ return
89
+ # Last-resort guard: a job must never be left stuck in RUNNING because of an
90
+ # unexpected exception type. The error is recorded on the job, not swallowed.
91
+ # Narrower catches above handle the expected failure modes.
92
+ except Exception as exc: # noqa: BLE001
93
+ store.update(
94
+ job.id,
95
+ state=JobState.ERROR,
96
+ error=f"{type(exc).__name__}: {exc}",
97
+ progress="failed",
98
+ )
99
+ return
100
+
101
+ # A cancellation that arrived while the last stage ran must win over DONE.
102
+ latest = store.get(job.id)
103
+ if latest is not None and latest.state is JobState.CANCELLED:
104
+ return
105
+
106
+ store.update(
107
+ job.id,
108
+ state=JobState.DONE,
109
+ progress="complete",
110
+ transcript=result.transcript.to_dict(),
111
+ outputs=[str(p) for p in result.outputs],
112
+ )
113
+
114
+
115
+ def submit(
116
+ store: JobStore,
117
+ *,
118
+ source: str,
119
+ background: bool = True,
120
+ **kwargs,
121
+ ) -> Job:
122
+ """Create a job and schedule it.
123
+
124
+ `background=False` runs inline (used by tests and by callers that want a
125
+ blocking call). Otherwise the job goes to the process-wide executor, which
126
+ bounds concurrency and can cancel it.
127
+ """
128
+ if not background:
129
+ job = store.create(source)
130
+ run_job(job, store, source=source, **kwargs)
131
+ return job
132
+
133
+ executor = get_default_executor()
134
+ if executor.store is not store:
135
+ # A caller passed a specific store; run it directly rather than silently
136
+ # routing the job to a different store than the one they hold.
137
+ job = store.create(source)
138
+ run_job(job, store, source=source, **kwargs)
139
+ return job
140
+ return executor.submit(source=source, **kwargs)
141
+
142
+
143
+ def transcript_for(job: Job) -> Transcript | None:
144
+ if job.transcript is None:
145
+ return None
146
+ return Transcript.from_dict(job.transcript)