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,146 @@
1
+ """Opt-in production profile; localhost owner mode stays unconfined."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ ENV_PROFILE = "TEXTFLOWKIT_PROFILE"
11
+ ENV_API_TOKEN = "TEXTFLOWKIT_API_TOKEN"
12
+ ENV_WORK_ROOT = "TEXTFLOWKIT_WORK_ROOT"
13
+ ENV_MAX_REQUEST_BYTES = "TEXTFLOWKIT_MAX_REQUEST_BYTES"
14
+ ENV_RATE_PER_MINUTE = "TEXTFLOWKIT_RATE_PER_MINUTE"
15
+ ENV_MAX_DURATION_SECONDS = "TEXTFLOWKIT_MAX_DURATION_SECONDS"
16
+ ENV_MAX_OUTPUT_BYTES = "TEXTFLOWKIT_MAX_OUTPUT_BYTES"
17
+ ENV_MAX_MEDIA_BYTES = "TEXTFLOWKIT_MAX_MEDIA_BYTES"
18
+ ENV_EGRESS_PROXY = "TEXTFLOWKIT_EGRESS_PROXY"
19
+ ENV_FFMPEG_TIMEOUT_SECONDS = "TEXTFLOWKIT_FFMPEG_TIMEOUT_SECONDS"
20
+
21
+
22
+ class ServiceConfigurationError(ValueError):
23
+ """The production profile cannot safely start with this configuration."""
24
+
25
+
26
+ def production_enabled() -> bool:
27
+ value = os.environ.get(ENV_PROFILE, "developer").strip().lower()
28
+ if value not in {"developer", "production"}:
29
+ raise ServiceConfigurationError(
30
+ f"{ENV_PROFILE} must be 'developer' or 'production', not '{value}'"
31
+ )
32
+ return value == "production"
33
+
34
+
35
+ def positive_limit(name: str, default: int) -> int:
36
+ raw = os.environ.get(name)
37
+ try:
38
+ value = int(raw) if raw else default
39
+ except ValueError as exc:
40
+ raise ServiceConfigurationError(f"{name} must be a positive integer") from exc
41
+ if value <= 0:
42
+ raise ServiceConfigurationError(f"{name} must be a positive integer")
43
+ return value
44
+
45
+
46
+ def validate_production_config() -> None:
47
+ """Fail closed before handling any production HTTP request."""
48
+ if not production_enabled():
49
+ return
50
+ from textflowkit.core.paths import ENV_INPUT_ROOT, ENV_OUTPUT_ROOT
51
+
52
+ required = (ENV_API_TOKEN, ENV_INPUT_ROOT, ENV_OUTPUT_ROOT, "TEXTFLOWKIT_DB", ENV_WORK_ROOT)
53
+ missing = [name for name in required if not os.environ.get(name)]
54
+ if missing:
55
+ raise ServiceConfigurationError(
56
+ f"production profile requires: {', '.join(missing)}"
57
+ )
58
+ if len(os.environ[ENV_API_TOKEN]) < 16:
59
+ raise ServiceConfigurationError(f"{ENV_API_TOKEN} must have at least 16 characters")
60
+ if os.environ["TEXTFLOWKIT_DB"] == ":memory:":
61
+ raise ServiceConfigurationError("production requires an on-disk TEXTFLOWKIT_DB")
62
+ from textflowkit.core.executor import get_default_executor
63
+ from textflowkit.core.jobs import get_default_store
64
+ from textflowkit.core.sqlite_store import SqliteJobStore
65
+
66
+ store = get_default_store()
67
+ db_path = Path(os.environ["TEXTFLOWKIT_DB"]).expanduser().resolve()
68
+ if not isinstance(store, SqliteJobStore) or Path(store.path).resolve() != db_path:
69
+ raise ServiceConfigurationError("production requires the active store to use TEXTFLOWKIT_DB")
70
+ if get_default_executor().store is not store:
71
+ raise ServiceConfigurationError("production executor and durable store must match")
72
+ input_root = Path(os.environ[ENV_INPUT_ROOT]).expanduser().resolve()
73
+ if not input_root.is_dir():
74
+ raise ServiceConfigurationError(f"{ENV_INPUT_ROOT} must name an existing directory")
75
+ for name in (ENV_OUTPUT_ROOT, ENV_WORK_ROOT):
76
+ path = Path(os.environ[name]).expanduser().resolve()
77
+ path.mkdir(parents=True, exist_ok=True)
78
+ if not path.is_dir():
79
+ raise ServiceConfigurationError(f"{name} must name a directory")
80
+ positive_limit(ENV_MAX_REQUEST_BYTES, 64 * 1024)
81
+ positive_limit(ENV_RATE_PER_MINUTE, 60)
82
+ positive_limit(ENV_MAX_DURATION_SECONDS, 4 * 3600)
83
+ positive_limit(ENV_MAX_OUTPUT_BYTES, 50 * 1024 * 1024)
84
+ positive_limit(ENV_MAX_MEDIA_BYTES, 1024 * 1024 * 1024)
85
+ positive_limit(ENV_FFMPEG_TIMEOUT_SECONDS, 600)
86
+ positive_limit("TEXTFLOWKIT_MAX_PENDING_JOBS", 100)
87
+
88
+
89
+ def service_work_root() -> str | None:
90
+ if not production_enabled():
91
+ return None
92
+ validate_production_config()
93
+ return str(Path(os.environ[ENV_WORK_ROOT]).expanduser().resolve())
94
+
95
+
96
+ def _probe_duration(media: Path) -> float | None:
97
+ """Return known duration using a bounded probe; unknown is handled at decode."""
98
+ from textflowkit.sources.acquire import require_tool
99
+
100
+ ffprobe = require_tool("ffprobe")
101
+ try:
102
+ proc = subprocess.run(
103
+ [ffprobe, "-v", "error", "-show_entries", "format=duration",
104
+ "-of", "default=noprint_wrappers=1:nokey=1", str(media)],
105
+ capture_output=True, text=True, timeout=30, check=False,
106
+ )
107
+ except subprocess.TimeoutExpired as exc:
108
+ raise ServiceConfigurationError("ffprobe timed out before decode") from exc
109
+ if proc.returncode != 0:
110
+ return None
111
+ try:
112
+ duration = float(proc.stdout.strip())
113
+ except ValueError:
114
+ return None
115
+ return duration if math.isfinite(duration) and duration >= 0 else None
116
+
117
+
118
+ def enforce_predecode_limits(media: Path) -> None:
119
+ """Reject known oversize/overlong media before launching full extraction."""
120
+ if not production_enabled():
121
+ return
122
+ maximum = positive_limit(ENV_MAX_MEDIA_BYTES, 1024 * 1024 * 1024)
123
+ if media.stat().st_size > maximum:
124
+ raise ServiceConfigurationError("media exceeds the configured size limit")
125
+ duration = _probe_duration(media)
126
+ if duration is not None and duration > positive_limit(ENV_MAX_DURATION_SECONDS, 4 * 3600):
127
+ raise ServiceConfigurationError("source duration exceeds the configured limit")
128
+
129
+
130
+ def enforce_media_limits(media: Path, audio: Path) -> None:
131
+ """Defense in depth after bounded acquisition and decode."""
132
+ if not production_enabled():
133
+ return
134
+ maximum = positive_limit(ENV_MAX_MEDIA_BYTES, 1024 * 1024 * 1024)
135
+ if media.stat().st_size > maximum or audio.stat().st_size > maximum:
136
+ raise ServiceConfigurationError("media exceeds the configured size limit")
137
+ duration = _probe_duration(audio)
138
+ if duration is None:
139
+ raise ServiceConfigurationError("cannot verify source duration with ffprobe")
140
+ if duration > positive_limit(ENV_MAX_DURATION_SECONDS, 4 * 3600):
141
+ raise ServiceConfigurationError("source duration exceeds the configured limit")
142
+
143
+
144
+ def enforce_output_limit(size: int) -> None:
145
+ if production_enabled() and size > positive_limit(ENV_MAX_OUTPUT_BYTES, 50 * 1024 * 1024):
146
+ raise ServiceConfigurationError("rendered output exceeds the configured size limit")
@@ -0,0 +1,233 @@
1
+ """Durable job store backed by SQLite.
2
+
3
+ Chosen over a hand-rolled file format because SQLite is stdlib, transactional,
4
+ and already safe for concurrent access. One connection guarded by a lock is
5
+ sufficient at this scale and keeps the implementation obvious.
6
+
7
+ Ordering uses the table's AUTOINCREMENT `seq`, not `created_at` - same reasoning
8
+ as the in-memory store: wall-clock ordering is not portable across platforms.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sqlite3
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from textflowkit.core.jobs import Job, JobState, JobStore
22
+
23
+ _SCHEMA = """
24
+ CREATE TABLE IF NOT EXISTS jobs (
25
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ id TEXT NOT NULL UNIQUE,
27
+ source TEXT NOT NULL,
28
+ state TEXT NOT NULL,
29
+ created_at REAL NOT NULL,
30
+ updated_at REAL NOT NULL,
31
+ progress TEXT NOT NULL DEFAULT '',
32
+ error TEXT,
33
+ transcript TEXT,
34
+ outputs TEXT NOT NULL DEFAULT '[]',
35
+ cancel_requested INTEGER NOT NULL DEFAULT 0,
36
+ checkpoint TEXT,
37
+ request TEXT
38
+ );
39
+ CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state);
40
+ """
41
+
42
+ # Columns a caller may patch through update().
43
+ _MUTABLE = frozenset(
44
+ {
45
+ "state",
46
+ "progress",
47
+ "error",
48
+ "transcript",
49
+ "outputs",
50
+ "cancel_requested",
51
+ "checkpoint",
52
+ "request",
53
+ }
54
+ )
55
+
56
+ _CHECKPOINT_COLUMN = "checkpoint"
57
+
58
+
59
+ def _row_to_job(row: sqlite3.Row) -> Job:
60
+ transcript = json.loads(row["transcript"]) if row["transcript"] else None
61
+ outputs = json.loads(row["outputs"]) if row["outputs"] else []
62
+ keys = set(row.keys())
63
+ raw_checkpoint = row["checkpoint"] if "checkpoint" in keys else None
64
+ checkpoint = json.loads(raw_checkpoint) if raw_checkpoint else None
65
+ raw_request = row["request"] if "request" in keys else None
66
+ request = json.loads(raw_request) if raw_request else None
67
+ return Job(
68
+ id=row["id"],
69
+ source=row["source"],
70
+ state=JobState(row["state"]),
71
+ created_at=row["created_at"],
72
+ updated_at=row["updated_at"],
73
+ progress=row["progress"] or "",
74
+ error=row["error"],
75
+ transcript=transcript,
76
+ outputs=list(outputs),
77
+ cancel_requested=bool(row["cancel_requested"]),
78
+ checkpoint=checkpoint,
79
+ request=request,
80
+ )
81
+
82
+
83
+ class SqliteJobStore(JobStore):
84
+ """Durable job store. Jobs survive process restart."""
85
+
86
+ def __init__(self, path: str | Path, *, max_jobs: int = 200) -> None:
87
+ self.path = str(path)
88
+ # ":memory:" is allowed and useful for tests.
89
+ if self.path != ":memory:":
90
+ Path(self.path).expanduser().parent.mkdir(parents=True, exist_ok=True)
91
+ self._lock = threading.RLock()
92
+ self._conn = sqlite3.connect(self.path, check_same_thread=False)
93
+ self._conn.row_factory = sqlite3.Row
94
+ self._conn.execute("PRAGMA journal_mode=WAL")
95
+ self._conn.execute("PRAGMA foreign_keys=ON")
96
+ with self._lock:
97
+ self._conn.executescript(_SCHEMA)
98
+ self._migrate_locked()
99
+ self._conn.commit()
100
+ self._max_jobs = max_jobs
101
+
102
+ # -- interface ---------------------------------------------------------
103
+
104
+ def create(self, source: str, *, request: dict[str, Any] | None = None) -> Job:
105
+ now = time.time()
106
+ job = Job(
107
+ id=uuid.uuid4().hex[:12],
108
+ source=source,
109
+ state=JobState.PENDING,
110
+ created_at=now,
111
+ updated_at=now,
112
+ request=request,
113
+ )
114
+ with self._lock:
115
+ self._conn.execute(
116
+ "INSERT INTO jobs (id, source, state, created_at, updated_at,"
117
+ " progress, error, transcript, outputs, cancel_requested, checkpoint, request)"
118
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
119
+ (
120
+ job.id,
121
+ job.source,
122
+ job.state.value,
123
+ job.created_at,
124
+ job.updated_at,
125
+ job.progress,
126
+ job.error,
127
+ None,
128
+ json.dumps([]),
129
+ int(job.cancel_requested),
130
+ None,
131
+ json.dumps(request) if request is not None else None,
132
+ ),
133
+ )
134
+ self._conn.commit()
135
+ self._prune_locked()
136
+ return job
137
+
138
+ def get(self, job_id: str) -> Job | None:
139
+ with self._lock:
140
+ row = self._conn.execute(
141
+ "SELECT * FROM jobs WHERE id = ?", (job_id,)
142
+ ).fetchone()
143
+ return _row_to_job(row) if row else None
144
+
145
+ def update(self, job_id: str, **fields: Any) -> Job | None:
146
+ patch = {k: v for k, v in fields.items() if k in _MUTABLE}
147
+ if not patch:
148
+ return self.get(job_id)
149
+
150
+ sets: list[str] = []
151
+ values: list[Any] = []
152
+ for key, value in patch.items():
153
+ sets.append(f"{key} = ?")
154
+ if key == "state" and isinstance(value, JobState):
155
+ values.append(value.value)
156
+ elif key == "transcript":
157
+ values.append(json.dumps(value) if value is not None else None)
158
+ elif key == "outputs":
159
+ values.append(json.dumps(list(value or [])))
160
+ elif key in {"checkpoint", "request"}:
161
+ values.append(json.dumps(value) if value is not None else None)
162
+ elif key == "cancel_requested":
163
+ values.append(int(bool(value)))
164
+ else:
165
+ values.append(value)
166
+
167
+ sets.append("updated_at = ?")
168
+ values.append(time.time())
169
+ values.append(job_id)
170
+
171
+ with self._lock:
172
+ cur = self._conn.execute(
173
+ f"UPDATE jobs SET {', '.join(sets)} WHERE id = ?",
174
+ values,
175
+ )
176
+ self._conn.commit()
177
+ if cur.rowcount == 0:
178
+ return None
179
+ return self.get(job_id)
180
+
181
+ def list(self, *, limit: int = 50, state: JobState | None = None) -> list[Job]:
182
+ if limit < 0:
183
+ raise ValueError("limit must be >= 0")
184
+ with self._lock:
185
+ if state is None:
186
+ rows = self._conn.execute(
187
+ "SELECT * FROM jobs ORDER BY seq DESC LIMIT ?", (limit,)
188
+ ).fetchall()
189
+ else:
190
+ rows = self._conn.execute(
191
+ "SELECT * FROM jobs WHERE state = ? ORDER BY seq DESC LIMIT ?",
192
+ (state.value, limit),
193
+ ).fetchall()
194
+ return [_row_to_job(r) for r in rows]
195
+
196
+ def clear(self) -> None:
197
+ with self._lock:
198
+ self._conn.execute("DELETE FROM jobs")
199
+ self._conn.commit()
200
+
201
+ # -- internals ---------------------------------------------------------
202
+
203
+ def _migrate_locked(self) -> None:
204
+ """Add columns introduced after the original schema."""
205
+ existing = {
206
+ row["name"] for row in self._conn.execute("PRAGMA table_info(jobs)").fetchall()
207
+ }
208
+ if _CHECKPOINT_COLUMN not in existing:
209
+ self._conn.execute("ALTER TABLE jobs ADD COLUMN checkpoint TEXT")
210
+ if "request" not in existing:
211
+ self._conn.execute("ALTER TABLE jobs ADD COLUMN request TEXT")
212
+
213
+ def _prune_locked(self) -> None:
214
+ """Drop the oldest terminal jobs once over capacity."""
215
+ rows = self._conn.execute(
216
+ "SELECT seq FROM jobs WHERE state IN (?, ?, ?) ORDER BY seq ASC",
217
+ (JobState.DONE.value, JobState.ERROR.value, JobState.CANCELLED.value),
218
+ ).fetchall()
219
+ excess = self._count_locked() - self._max_jobs
220
+ if excess <= 0 or not rows:
221
+ return
222
+ doomed = [r["seq"] for r in rows[:excess]]
223
+ if doomed:
224
+ self._conn.executemany("DELETE FROM jobs WHERE seq = ?", [(s,) for s in doomed])
225
+ self._conn.commit()
226
+
227
+ def _count_locked(self) -> int:
228
+ return int(self._conn.execute("SELECT COUNT(*) AS n FROM jobs").fetchone()["n"])
229
+
230
+ def close(self) -> None:
231
+ with self._lock:
232
+ self._conn.close()
233
+
@@ -0,0 +1,220 @@
1
+ """One submission contract for CLI, MCP, HTTP, and batch callers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from textflowkit.core.checkpoint import (
11
+ find_resumable_checkpoint,
12
+ is_local_source,
13
+ load_checkpoint,
14
+ matches,
15
+ prepare_resume,
16
+ reusable_done_result,
17
+ validate_local_resume,
18
+ )
19
+ from textflowkit.core.executor import QueueFullError, get_default_executor
20
+ from textflowkit.core.jobs import Job, JobState, JobStore
21
+ from textflowkit.core.paths import default_input_root
22
+ from textflowkit.core.runner import run_job
23
+ from textflowkit.render import SUPPORTED_FORMATS
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class SubmissionRequest:
28
+ source: str
29
+ language: str | None = None
30
+ formats: list[str] = field(default_factory=lambda: ["json", "srt", "txt"])
31
+ output_dir: str | None = None
32
+ model: str = "small"
33
+ engine: str = "whisper"
34
+ device: str | None = None
35
+ cookies_from_browser: str | None = None
36
+ input_root: str | None = None
37
+ work_dir: str | None = None
38
+ diarize: bool = False
39
+ diarizer_backend: str = "pyannote"
40
+ translate_to: str | None = None
41
+ translator_backend: str = "ollama"
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.source:
45
+ raise ValueError("source is required")
46
+ # Adapter path helpers return Path objects, but a durable request must
47
+ # be JSON-serializable before it is inserted into SQLite.
48
+ if isinstance(self.input_root, Path):
49
+ self.input_root = str(self.input_root)
50
+ if isinstance(self.work_dir, Path):
51
+ self.work_dir = str(self.work_dir)
52
+ self.formats = [str(fmt).lower().lstrip(".") for fmt in self.formats]
53
+ bad = [fmt for fmt in self.formats if fmt not in SUPPORTED_FORMATS]
54
+ if bad:
55
+ raise ValueError(f"unsupported format(s): {', '.join(bad)}")
56
+ if len(self.formats) != len(set(self.formats)):
57
+ raise ValueError("duplicate output format")
58
+
59
+ def to_dict(self) -> dict[str, Any]:
60
+ return asdict(self)
61
+
62
+ @classmethod
63
+ def from_dict(cls, data: dict[str, Any]) -> SubmissionRequest:
64
+ return cls(**data)
65
+
66
+ def options(self) -> dict[str, Any]:
67
+ return {
68
+ "formats": list(self.formats),
69
+ "diarize": self.diarize,
70
+ "diarizer_backend": self.diarizer_backend,
71
+ "translate_to": self.translate_to,
72
+ "translator_backend": self.translator_backend,
73
+ }
74
+
75
+ def run_kwargs(self) -> dict[str, Any]:
76
+ return self.to_dict()
77
+
78
+
79
+ def _matching_checkpoint(store: JobStore, request: SubmissionRequest, job_id: str | None):
80
+ if job_id is not None:
81
+ job = store.get(job_id)
82
+ if job is None:
83
+ raise ValueError(f"no job with id '{job_id}'")
84
+ if job.request is not None:
85
+ saved = dict(job.request)
86
+ incoming = request.to_dict()
87
+ for key in ("input_root", "work_dir"):
88
+ saved.pop(key, None)
89
+ incoming.pop(key, None)
90
+ if saved != incoming:
91
+ raise ValueError("resume request does not match the saved request")
92
+ checkpoint = load_checkpoint(job)
93
+ if checkpoint is None:
94
+ return job, None
95
+ if not matches(
96
+ checkpoint, source=request.source, model=request.model,
97
+ language=request.language, engine=request.engine, device=request.device,
98
+ options=request.options(),
99
+ ):
100
+ raise ValueError("resume request does not match the saved checkpoint")
101
+ return job, checkpoint
102
+ return find_resumable_checkpoint(
103
+ store, source=request.source, model=request.model,
104
+ language=request.language, engine=request.engine, device=request.device,
105
+ options=request.options(),
106
+ )
107
+
108
+
109
+ def submit_request(
110
+ store: JobStore,
111
+ request: SubmissionRequest,
112
+ *,
113
+ background: bool = True,
114
+ resume: bool = False,
115
+ resume_job_id: str | None = None,
116
+ ) -> Job:
117
+ """Submit or resume via the same durable job lifecycle on every surface."""
118
+ executor = get_default_executor() if background else None
119
+ if executor is not None and executor.store is store:
120
+ # Reap old PENDING/RUNNING records before selecting one to resume.
121
+ executor.start()
122
+ found = _matching_checkpoint(store, request, resume_job_id) if resume or resume_job_id else None
123
+ if found is not None:
124
+ prior, checkpoint = found
125
+ if is_local_source(request.source):
126
+ if checkpoint is None:
127
+ if prior.state is JobState.DONE:
128
+ raise ValueError("local job has no reusable checkpoint; resubmit without resume")
129
+ else:
130
+ validate_local_resume(
131
+ checkpoint, request.source,
132
+ input_root=request.input_root if request.input_root is not None
133
+ else default_input_root(),
134
+ )
135
+ if prior.state is JobState.DONE:
136
+ if request.output_dir is None:
137
+ return prior
138
+ result = reusable_done_result(store, prior, formats=request.formats,
139
+ output_dir=request.output_dir,
140
+ stem=_output_stem(prior))
141
+ if result is not None:
142
+ _transcript, outputs = result
143
+ updated = store.update(prior.id, outputs=[str(p) for p in outputs])
144
+ return updated or prior
145
+ if prior.state in {JobState.PENDING, JobState.RUNNING}:
146
+ raise ValueError(f"job '{prior.id}' is already active")
147
+ prepared = prepare_resume(store, prior, checkpoint) if checkpoint else None
148
+ if checkpoint is None:
149
+ reopened = store.update(
150
+ prior.id, state=JobState.PENDING, progress="resuming from start",
151
+ error=None, cancel_requested=False,
152
+ )
153
+ if reopened is None:
154
+ raise ValueError(f"job '{prior.id}' disappeared before resume")
155
+ prepared = reopened, None
156
+ if prepared is not None:
157
+ job, checkpoint_payload = prepared
158
+ kwargs = request.run_kwargs()
159
+ store.update(job.id, request=request.to_dict())
160
+ if checkpoint_payload is not None:
161
+ kwargs["resume_checkpoint"] = checkpoint_payload
162
+ if executor is not None and executor.store is store:
163
+ try:
164
+ return executor.enqueue(job, **kwargs)
165
+ except QueueFullError:
166
+ store.update(job.id, state=JobState.ERROR, progress="queue full",
167
+ error="resume not queued; job queue is full")
168
+ raise
169
+ run_job(job, store, **kwargs)
170
+ return store.get(job.id) or job
171
+
172
+ kwargs = request.run_kwargs()
173
+ if executor is not None and executor.store is store:
174
+ return executor.submit(request=request.to_dict(), **kwargs)
175
+ job = store.create(request.source, request=request.to_dict())
176
+ run_job(job, store, **kwargs)
177
+ return store.get(job.id) or job
178
+
179
+
180
+ def resume_job(
181
+ store: JobStore, job_id: str, *, background: bool = True,
182
+ input_root: str | None = None, work_dir: str | None = None,
183
+ ) -> Job:
184
+ """Resume an interrupted durable job using its original saved request."""
185
+ job = store.get(job_id)
186
+ if job is None:
187
+ raise ValueError(f"no job with id '{job_id}'")
188
+ if job.request is None:
189
+ raise ValueError("job predates saved requests; resubmit its original options")
190
+ request_data = dict(job.request)
191
+ if input_root is not None:
192
+ request_data["input_root"] = input_root
193
+ if work_dir is not None:
194
+ request_data["work_dir"] = work_dir
195
+ return submit_request(
196
+ store, SubmissionRequest.from_dict(request_data),
197
+ background=background, resume=True, resume_job_id=job_id,
198
+ )
199
+
200
+
201
+ def submit_batch(
202
+ store: JobStore, requests: list[SubmissionRequest], *, resume: bool = False
203
+ ) -> list[dict[str, Any]]:
204
+ """Accept each source independently; a bad item never hides later items."""
205
+ results: list[dict[str, Any]] = []
206
+ for request in requests:
207
+ try:
208
+ job = submit_request(store, request, resume=resume)
209
+ results.append({"source": request.source, "job_id": job.id, "state": job.state.value})
210
+ except Exception as exc: # noqa: BLE001 - one bad item must not stop the batch
211
+ results.append({"source": request.source, "error": f"{type(exc).__name__}: {exc}"})
212
+ return results
213
+
214
+
215
+ def _output_stem(job: Job) -> str:
216
+ if job.outputs:
217
+ return Path(job.outputs[0]).stem
218
+ parsed = urlparse(job.source)
219
+ name = Path(parsed.path).stem if parsed.scheme in {"http", "https"} else Path(job.source).stem
220
+ return f"{name or 'transcript'}-{job.id}"
@@ -0,0 +1,20 @@
1
+ """Timestamp formatting for subtitle formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def srt_timestamp(seconds: float) -> str:
7
+ """HH:MM:SS,mmm (SRT uses a comma)."""
8
+ if seconds < 0:
9
+ seconds = 0.0
10
+ ms_total = round(seconds * 1000)
11
+ h, rem = divmod(ms_total, 3_600_000)
12
+ m, rem = divmod(rem, 60_000)
13
+ s, ms = divmod(rem, 1000)
14
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
15
+
16
+
17
+ def vtt_timestamp(seconds: float) -> str:
18
+ """HH:MM:SS.mmm (WebVTT uses a period)."""
19
+ return srt_timestamp(seconds).replace(",", ".")
20
+