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.
- textflowkit/__init__.py +8 -0
- textflowkit/adapters/__init__.py +11 -0
- textflowkit/adapters/http_server.py +465 -0
- textflowkit/adapters/mcp_server.py +554 -0
- textflowkit/cli.py +473 -0
- textflowkit/core/__init__.py +5 -0
- textflowkit/core/batch.py +186 -0
- textflowkit/core/bind.py +66 -0
- textflowkit/core/cancel.py +19 -0
- textflowkit/core/checkpoint.py +389 -0
- textflowkit/core/diarize.py +229 -0
- textflowkit/core/engine.py +127 -0
- textflowkit/core/executor.py +301 -0
- textflowkit/core/jobs.py +241 -0
- textflowkit/core/model.py +98 -0
- textflowkit/core/paths.py +226 -0
- textflowkit/core/pipeline.py +368 -0
- textflowkit/core/retrieval.py +148 -0
- textflowkit/core/runner.py +146 -0
- textflowkit/core/service.py +146 -0
- textflowkit/core/sqlite_store.py +233 -0
- textflowkit/core/submission.py +220 -0
- textflowkit/core/timeutil.py +20 -0
- textflowkit/core/translate.py +244 -0
- textflowkit/render/__init__.py +191 -0
- textflowkit/render/docx.py +71 -0
- textflowkit/render/fonts/NotoSans.ttf +0 -0
- textflowkit/render/fonts/NotoSansArabic.ttf +0 -0
- textflowkit/render/fonts/NotoSansSC.ttf +0 -0
- textflowkit/render/fonts/OFL-NotoSans.txt +94 -0
- textflowkit/render/fonts/OFL-NotoSansSC.txt +93 -0
- textflowkit/render/fonts/README.md +19 -0
- textflowkit/render/markdown.py +33 -0
- textflowkit/render/pdf.py +136 -0
- textflowkit/render/srt.py +22 -0
- textflowkit/render/txt.py +19 -0
- textflowkit/render/vtt.py +20 -0
- textflowkit/sources/__init__.py +16 -0
- textflowkit/sources/acquire.py +437 -0
- textflowkit/sources/detect.py +200 -0
- textflowkit/sources/scratch.py +32 -0
- textflowkit-0.1.3.dist-info/METADATA +266 -0
- textflowkit-0.1.3.dist-info/RECORD +46 -0
- textflowkit-0.1.3.dist-info/WHEEL +4 -0
- textflowkit-0.1.3.dist-info/entry_points.txt +4 -0
- textflowkit-0.1.3.dist-info/licenses/LICENSE +203 -0
textflowkit/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""textflowkit - cross-platform media transcription toolkit."""
|
|
2
|
+
|
|
3
|
+
from textflowkit.core.model import Segment, Transcript
|
|
4
|
+
from textflowkit.core.pipeline import PipelineError, transcribe
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.3"
|
|
7
|
+
|
|
8
|
+
__all__ = ["PipelineError", "Segment", "Transcript", "__version__", "transcribe"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Interface adapters over the textflowkit core.
|
|
2
|
+
|
|
3
|
+
Adapters are deliberately thin: they translate a transport into a call on
|
|
4
|
+
`textflowkit.core.runner` and translate the result back. No pipeline logic lives
|
|
5
|
+
here, so the MCP server, the HTTP API, and any future frontend cannot drift.
|
|
6
|
+
|
|
7
|
+
- `mcp_server` - Model Context Protocol (stdio or Streamable HTTP)
|
|
8
|
+
- `http_server` - JSON HTTP API for software products and web frontends
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__ = ["http_server", "mcp_server"]
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""HTTP adapter.
|
|
2
|
+
|
|
3
|
+
A small JSON API over the same core and job store the MCP adapter uses. This is
|
|
4
|
+
the door for software products and for the eventual website: it is deliberately
|
|
5
|
+
job-based so a long video never blocks a request.
|
|
6
|
+
|
|
7
|
+
Not started by default. Developer mode is unauthenticated and loopback-only by
|
|
8
|
+
default. The opt-in JSON HTTP production profile requires Bearer authentication
|
|
9
|
+
and a trusted egress proxy for URL jobs; Streamable-HTTP MCP is separate.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Annotated, Any
|
|
21
|
+
|
|
22
|
+
from textflowkit import __version__
|
|
23
|
+
from textflowkit.core.bind import ENV_ALLOW_REMOTE, UnsafeBindError, check_bind_safety
|
|
24
|
+
from textflowkit.core.executor import QueueFullError, get_default_executor
|
|
25
|
+
from textflowkit.core.jobs import JobState, get_default_store, validate_list_limit
|
|
26
|
+
from textflowkit.core.model import Transcript
|
|
27
|
+
from textflowkit.core.paths import (
|
|
28
|
+
UnsafeOutputPathError,
|
|
29
|
+
ensure_output_dir,
|
|
30
|
+
server_input_root,
|
|
31
|
+
)
|
|
32
|
+
from textflowkit.core.retrieval import page_segments, search_segments
|
|
33
|
+
from textflowkit.core.runner import transcript_for
|
|
34
|
+
from textflowkit.core.service import (
|
|
35
|
+
ENV_API_TOKEN,
|
|
36
|
+
ENV_MAX_REQUEST_BYTES,
|
|
37
|
+
ENV_RATE_PER_MINUTE,
|
|
38
|
+
ServiceConfigurationError,
|
|
39
|
+
enforce_output_limit,
|
|
40
|
+
positive_limit,
|
|
41
|
+
production_enabled,
|
|
42
|
+
service_work_root,
|
|
43
|
+
validate_production_config,
|
|
44
|
+
)
|
|
45
|
+
from textflowkit.core.submission import (
|
|
46
|
+
SubmissionRequest,
|
|
47
|
+
submit_batch,
|
|
48
|
+
submit_request,
|
|
49
|
+
)
|
|
50
|
+
from textflowkit.core.submission import (
|
|
51
|
+
resume_job as core_resume_job,
|
|
52
|
+
)
|
|
53
|
+
from textflowkit.render import (
|
|
54
|
+
SUPPORTED_FORMATS,
|
|
55
|
+
TEXT_FORMATS,
|
|
56
|
+
atomic_write_bytes,
|
|
57
|
+
render,
|
|
58
|
+
render_bytes,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try: # optional extra
|
|
62
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
63
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
64
|
+
from pydantic import BaseModel, Field
|
|
65
|
+
except ImportError as exc: # pragma: no cover
|
|
66
|
+
raise ImportError(
|
|
67
|
+
"The HTTP adapter requires the 'http' extra. Install with: pip install 'textflowkit[http]'"
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
app = FastAPI(
|
|
71
|
+
title="textflowkit",
|
|
72
|
+
version=__version__,
|
|
73
|
+
description="Cross-platform media transcription API. Job-based: submit, poll, fetch.",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_RATE_LOCK = threading.Lock()
|
|
77
|
+
_RATE_BUCKETS: dict[str, tuple[float, int]] = {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.middleware("http")
|
|
81
|
+
async def production_guard(request: Request, call_next):
|
|
82
|
+
try:
|
|
83
|
+
if not production_enabled():
|
|
84
|
+
return await call_next(request)
|
|
85
|
+
validate_production_config()
|
|
86
|
+
except ServiceConfigurationError as exc:
|
|
87
|
+
return JSONResponse({"error": str(exc)}, status_code=503)
|
|
88
|
+
supplied = request.headers.get("authorization", "")
|
|
89
|
+
expected = f"Bearer {os.environ[ENV_API_TOKEN]}"
|
|
90
|
+
if not hmac.compare_digest(supplied, expected):
|
|
91
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
92
|
+
|
|
93
|
+
max_bytes = positive_limit(ENV_MAX_REQUEST_BYTES, 64 * 1024)
|
|
94
|
+
length = request.headers.get("content-length")
|
|
95
|
+
if length is not None and (not length.isdecimal() or int(length) > max_bytes):
|
|
96
|
+
return JSONResponse({"error": "request body too large"}, status_code=413)
|
|
97
|
+
# Do not call request.body() first: absent Content-Length, it buffers an
|
|
98
|
+
# arbitrarily large chunked body before the check can run. Cache only after
|
|
99
|
+
# incrementally enforcing the limit so call_next can replay it to FastAPI.
|
|
100
|
+
body = bytearray()
|
|
101
|
+
async for chunk in request.stream():
|
|
102
|
+
if len(body) + len(chunk) > max_bytes:
|
|
103
|
+
return JSONResponse({"error": "request body too large"}, status_code=413)
|
|
104
|
+
body.extend(chunk)
|
|
105
|
+
request._body = bytes(body)
|
|
106
|
+
|
|
107
|
+
rate = positive_limit(ENV_RATE_PER_MINUTE, 60)
|
|
108
|
+
peer = request.client.host if request.client else "unknown"
|
|
109
|
+
now = time.monotonic()
|
|
110
|
+
with _RATE_LOCK:
|
|
111
|
+
started, count = _RATE_BUCKETS.get(peer, (now, 0))
|
|
112
|
+
if now - started >= 60:
|
|
113
|
+
started, count = now, 0
|
|
114
|
+
if count >= rate:
|
|
115
|
+
return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
|
|
116
|
+
_RATE_BUCKETS[peer] = started, count + 1
|
|
117
|
+
if len(_RATE_BUCKETS) > 10000:
|
|
118
|
+
_RATE_BUCKETS.clear()
|
|
119
|
+
return await call_next(request)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TranscribeRequest(BaseModel):
|
|
123
|
+
source: str = Field(..., description="Media URL or local file path")
|
|
124
|
+
language: str | None = Field(None, description="ISO language code; auto-detected if omitted")
|
|
125
|
+
formats: list[str] = Field(default_factory=lambda: ["json", "srt", "txt"])
|
|
126
|
+
output_dir: str | None = Field(None, description="Directory for rendered files; omit for none")
|
|
127
|
+
model: str = Field("small", description="Whisper model size")
|
|
128
|
+
device: str | None = Field(None, description="cuda or cpu; auto-detected if omitted")
|
|
129
|
+
cookies_from_browser: str | None = None
|
|
130
|
+
diarize: bool = False
|
|
131
|
+
translate_to: str | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BatchRequest(BaseModel):
|
|
135
|
+
jobs: list[TranscribeRequest]
|
|
136
|
+
resume: bool = False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _submission_request(req: TranscribeRequest) -> SubmissionRequest:
|
|
140
|
+
return SubmissionRequest(
|
|
141
|
+
**req.model_dump(), input_root=server_input_root(), work_dir=service_work_root()
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.get("/health")
|
|
146
|
+
def health() -> dict[str, Any]:
|
|
147
|
+
"""Liveness probe."""
|
|
148
|
+
return {"status": "ok", "version": __version__}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.get("/sources")
|
|
152
|
+
def sources() -> dict[str, Any]:
|
|
153
|
+
"""Capabilities: platforms, input kinds, output formats."""
|
|
154
|
+
from textflowkit.sources.detect import PLATFORMS
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"platforms": sorted(PLATFORMS),
|
|
158
|
+
"input_kinds": ["local", "direct"],
|
|
159
|
+
"formats": list(SUPPORTED_FORMATS),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.post("/jobs", status_code=202)
|
|
164
|
+
def create_job(req: TranscribeRequest) -> dict[str, Any]:
|
|
165
|
+
"""Submit a transcription job. Returns 202 with a job id immediately."""
|
|
166
|
+
store = get_default_store()
|
|
167
|
+
try:
|
|
168
|
+
job = submit_request(store, _submission_request(req))
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
171
|
+
except QueueFullError as exc:
|
|
172
|
+
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
173
|
+
return job.to_dict()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.post("/jobs/batch", status_code=202)
|
|
177
|
+
def create_batch(req: BatchRequest) -> dict[str, Any]:
|
|
178
|
+
"""Queue multiple independent jobs through the same core contract."""
|
|
179
|
+
try:
|
|
180
|
+
requests = [_submission_request(item) for item in req.jobs]
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
183
|
+
results = submit_batch(get_default_store(), requests, resume=req.resume)
|
|
184
|
+
return {"count": len(results), "jobs": results}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.post("/jobs/{job_id}/resume", status_code=202)
|
|
188
|
+
def resume_job(job_id: str) -> dict[str, Any]:
|
|
189
|
+
"""Resume a durable interrupted job by its saved request and checkpoint."""
|
|
190
|
+
try:
|
|
191
|
+
job = core_resume_job(
|
|
192
|
+
get_default_store(), job_id,
|
|
193
|
+
input_root=str(server_input_root()) if server_input_root() else None,
|
|
194
|
+
work_dir=service_work_root(),
|
|
195
|
+
)
|
|
196
|
+
except QueueFullError as exc:
|
|
197
|
+
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
200
|
+
return job.to_dict()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.get("/jobs")
|
|
204
|
+
def list_jobs(limit: int = 20, state: str | None = None) -> dict[str, Any]:
|
|
205
|
+
"""List recent jobs, newest first."""
|
|
206
|
+
try:
|
|
207
|
+
validate_list_limit(limit)
|
|
208
|
+
except ValueError as exc:
|
|
209
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
210
|
+
store = get_default_store()
|
|
211
|
+
filter_state = None
|
|
212
|
+
if state:
|
|
213
|
+
try:
|
|
214
|
+
filter_state = JobState(state.lower())
|
|
215
|
+
except ValueError:
|
|
216
|
+
raise HTTPException(
|
|
217
|
+
status_code=422,
|
|
218
|
+
detail={"error": f"unknown state '{state}'",
|
|
219
|
+
"available_states": [s.value for s in JobState]},
|
|
220
|
+
) from None
|
|
221
|
+
jobs = store.list(limit=limit, state=filter_state)
|
|
222
|
+
return {"count": len(jobs), "jobs": [j.to_dict() for j in jobs]}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.get("/jobs/{job_id}")
|
|
226
|
+
def get_job(job_id: str) -> dict[str, Any]:
|
|
227
|
+
"""Lean job status; use the paged transcript endpoint for content."""
|
|
228
|
+
job = get_default_store().get(job_id)
|
|
229
|
+
if job is None:
|
|
230
|
+
raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
|
|
231
|
+
return job.to_dict()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.post("/jobs/{job_id}/cancel")
|
|
235
|
+
def cancel_job(job_id: str) -> dict[str, Any]:
|
|
236
|
+
"""Request cancellation. 202-style: accepted, then poll for state.
|
|
237
|
+
|
|
238
|
+
A queued job is cancelled immediately. A running job stops at its next stage
|
|
239
|
+
boundary and reports state 'cancelled' once it does.
|
|
240
|
+
"""
|
|
241
|
+
store = get_default_store()
|
|
242
|
+
job = store.get(job_id)
|
|
243
|
+
if job is None:
|
|
244
|
+
raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
|
|
245
|
+
if job.is_terminal:
|
|
246
|
+
return {
|
|
247
|
+
"job_id": job_id,
|
|
248
|
+
"cancelled": False,
|
|
249
|
+
"state": job.state.value,
|
|
250
|
+
"reason": f"job is already {job.state.value}",
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
accepted = get_default_executor().cancel(job_id)
|
|
254
|
+
latest = store.get(job_id)
|
|
255
|
+
return {
|
|
256
|
+
"job_id": job_id,
|
|
257
|
+
"cancelled": accepted,
|
|
258
|
+
"state": latest.state.value if latest is not None else job.state.value,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _finished_transcript(job_id: str):
|
|
263
|
+
"""Shared guard: 404/409/500 and return (job, transcript)."""
|
|
264
|
+
job = get_default_store().get(job_id)
|
|
265
|
+
if job is None:
|
|
266
|
+
raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
|
|
267
|
+
if job.state is not JobState.DONE:
|
|
268
|
+
raise HTTPException(
|
|
269
|
+
status_code=409,
|
|
270
|
+
detail={"error": f"job not finished (state: {job.state.value})", "state": job.state.value},
|
|
271
|
+
)
|
|
272
|
+
tr = transcript_for(job)
|
|
273
|
+
if tr is None:
|
|
274
|
+
raise HTTPException(status_code=500, detail="job contains no transcript")
|
|
275
|
+
return job, tr
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.get("/jobs/{job_id}/transcript")
|
|
279
|
+
def get_transcript(
|
|
280
|
+
job_id: str,
|
|
281
|
+
format: str = "json",
|
|
282
|
+
offset: int = 0,
|
|
283
|
+
limit: int | None = None,
|
|
284
|
+
start: float | None = None,
|
|
285
|
+
end: float | None = None,
|
|
286
|
+
):
|
|
287
|
+
"""Transcript for a completed job, optionally a slice.
|
|
288
|
+
|
|
289
|
+
`offset`/`limit` page through segments; `start`/`end` select a time range in
|
|
290
|
+
seconds. The JSON form reports total_segments and has_more.
|
|
291
|
+
"""
|
|
292
|
+
job, tr = _finished_transcript(job_id)
|
|
293
|
+
if production_enabled():
|
|
294
|
+
limit = 100 if limit is None else limit
|
|
295
|
+
if limit > 500:
|
|
296
|
+
raise HTTPException(status_code=422, detail="transcript page limit must be <= 500")
|
|
297
|
+
|
|
298
|
+
fmt = format.lower().lstrip(".")
|
|
299
|
+
if fmt not in TEXT_FORMATS:
|
|
300
|
+
raise HTTPException(
|
|
301
|
+
status_code=422,
|
|
302
|
+
detail={
|
|
303
|
+
"error": f"'{format}' cannot be returned inline",
|
|
304
|
+
"available_formats": list(TEXT_FORMATS),
|
|
305
|
+
"hint": "binary formats (docx, pdf) are written to disk via /export",
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
page = page_segments(tr, offset=offset, limit=limit, start=start, end=end)
|
|
311
|
+
except ValueError as exc:
|
|
312
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
313
|
+
|
|
314
|
+
sliced = Transcript(
|
|
315
|
+
source=tr.source,
|
|
316
|
+
language=tr.language,
|
|
317
|
+
platform=tr.platform,
|
|
318
|
+
duration=tr.duration,
|
|
319
|
+
engine=tr.engine,
|
|
320
|
+
metadata=tr.metadata,
|
|
321
|
+
segments=page.segments,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if fmt == "json":
|
|
325
|
+
response = {
|
|
326
|
+
"job_id": job.id,
|
|
327
|
+
**page.as_dict(),
|
|
328
|
+
"transcript": sliced.to_dict(),
|
|
329
|
+
}
|
|
330
|
+
try:
|
|
331
|
+
enforce_output_limit(len(json.dumps(response, ensure_ascii=False).encode("utf-8")))
|
|
332
|
+
except ServiceConfigurationError as exc:
|
|
333
|
+
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
|
334
|
+
return response
|
|
335
|
+
content = render(sliced, fmt)
|
|
336
|
+
try:
|
|
337
|
+
enforce_output_limit(len(content.encode("utf-8")))
|
|
338
|
+
except ServiceConfigurationError as exc:
|
|
339
|
+
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
|
340
|
+
return PlainTextResponse(content)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@app.get("/jobs/{job_id}/search")
|
|
344
|
+
def search(job_id: str, q: str, limit: int = 20, context: int = 1,
|
|
345
|
+
case_sensitive: bool = False) -> dict[str, Any]:
|
|
346
|
+
"""Search a completed transcript for a phrase."""
|
|
347
|
+
job, tr = _finished_transcript(job_id)
|
|
348
|
+
if production_enabled() and (limit > 500 or context > 20):
|
|
349
|
+
raise HTTPException(status_code=422, detail="search limit/context exceeds production cap")
|
|
350
|
+
try:
|
|
351
|
+
matches = search_segments(
|
|
352
|
+
tr, q, limit=limit, context=context, case_sensitive=case_sensitive
|
|
353
|
+
)
|
|
354
|
+
except ValueError as exc:
|
|
355
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
356
|
+
|
|
357
|
+
response = {
|
|
358
|
+
"job_id": job.id,
|
|
359
|
+
"query": q,
|
|
360
|
+
"match_count": len(matches),
|
|
361
|
+
"matches": [
|
|
362
|
+
{
|
|
363
|
+
"index": m.index,
|
|
364
|
+
"start": m.segment.start,
|
|
365
|
+
"end": m.segment.end,
|
|
366
|
+
"text": m.segment.display_text(),
|
|
367
|
+
"context_before": [c.display_text() for c in m.context_before],
|
|
368
|
+
"context_after": [c.display_text() for c in m.context_after],
|
|
369
|
+
}
|
|
370
|
+
for m in matches
|
|
371
|
+
],
|
|
372
|
+
}
|
|
373
|
+
try:
|
|
374
|
+
enforce_output_limit(len(json.dumps(response, ensure_ascii=False).encode("utf-8")))
|
|
375
|
+
except ServiceConfigurationError as exc:
|
|
376
|
+
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
|
377
|
+
return response
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.post("/jobs/{job_id}/export")
|
|
381
|
+
def export(
|
|
382
|
+
job_id: str,
|
|
383
|
+
formats: Annotated[
|
|
384
|
+
list[str] | None,
|
|
385
|
+
Query(description="Repeat for each format, e.g. ?formats=docx&formats=pdf"),
|
|
386
|
+
] = None,
|
|
387
|
+
output_dir: str = ".",
|
|
388
|
+
) -> dict[str, Any]:
|
|
389
|
+
"""Write a completed transcript to disk.
|
|
390
|
+
|
|
391
|
+
`formats` is declared as an explicit query parameter: a bare `list[str]` on a
|
|
392
|
+
POST is treated by FastAPI as a request *body* field, which meant the argument
|
|
393
|
+
was silently ignored and the defaults were always used.
|
|
394
|
+
"""
|
|
395
|
+
job = get_default_store().get(job_id)
|
|
396
|
+
if job is None:
|
|
397
|
+
raise HTTPException(status_code=404, detail=f"no job with id '{job_id}'")
|
|
398
|
+
if job.state is not JobState.DONE:
|
|
399
|
+
raise HTTPException(status_code=409, detail={"error": "job not finished"})
|
|
400
|
+
tr = transcript_for(job)
|
|
401
|
+
if tr is None:
|
|
402
|
+
raise HTTPException(status_code=500, detail="job contains no transcript")
|
|
403
|
+
|
|
404
|
+
fmt_list = formats or ["srt", "vtt", "txt", "json"]
|
|
405
|
+
normalized = [f.lower().lstrip(".") for f in fmt_list]
|
|
406
|
+
bad = [f for f in normalized if f not in SUPPORTED_FORMATS]
|
|
407
|
+
if bad:
|
|
408
|
+
raise HTTPException(status_code=422, detail=f"unsupported format(s): {', '.join(bad)}")
|
|
409
|
+
if len(normalized) != len(set(normalized)):
|
|
410
|
+
raise HTTPException(status_code=422, detail="duplicate output format")
|
|
411
|
+
try:
|
|
412
|
+
rendered = [(f, render_bytes(tr, f, title=job.id)) for f in normalized]
|
|
413
|
+
except (ValueError, ImportError) as exc:
|
|
414
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
415
|
+
try:
|
|
416
|
+
out = ensure_output_dir(output_dir)
|
|
417
|
+
except UnsafeOutputPathError as exc:
|
|
418
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
419
|
+
|
|
420
|
+
written = []
|
|
421
|
+
for norm, content in rendered:
|
|
422
|
+
path = out / f"{job.id}.{norm}"
|
|
423
|
+
atomic_write_bytes(path, content, replace=True)
|
|
424
|
+
written.append(str(path))
|
|
425
|
+
return {"job_id": job.id, "written": written}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def main(argv: list[str] | None = None) -> int:
|
|
429
|
+
import argparse
|
|
430
|
+
|
|
431
|
+
import uvicorn
|
|
432
|
+
|
|
433
|
+
parser = argparse.ArgumentParser(
|
|
434
|
+
prog="textflowkit-http", description="textflowkit HTTP API"
|
|
435
|
+
)
|
|
436
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind host (default 127.0.0.1)")
|
|
437
|
+
parser.add_argument("--port", type=int, default=8767, help="Bind port (default 8767)")
|
|
438
|
+
parser.add_argument(
|
|
439
|
+
"--allow-remote",
|
|
440
|
+
action="store_true",
|
|
441
|
+
help=(
|
|
442
|
+
"permit binding to a non-loopback address. Developer mode has no "
|
|
443
|
+
"authentication; use a gateway or the production profile. "
|
|
444
|
+
f"({ENV_ALLOW_REMOTE}=1 also works)"
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
parser.add_argument("--version", action="version", version=f"textflowkit-http {__version__}")
|
|
448
|
+
args = parser.parse_args(argv)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
validate_production_config()
|
|
452
|
+
check_bind_safety(args.host, allow_remote=args.allow_remote or None)
|
|
453
|
+
except (UnsafeBindError, ServiceConfigurationError) as exc:
|
|
454
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
455
|
+
return 2
|
|
456
|
+
|
|
457
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
458
|
+
return 0
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__":
|
|
462
|
+
raise SystemExit(main())
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
|