EdiBianco commited on
Commit
2a21a53
·
1 Parent(s): 99603dd

Add FastAPI transcriber with file upload, URL support, SSE progress

Browse files
Files changed (4) hide show
  1. Dockerfile +16 -0
  2. README.md +23 -6
  3. app.py +509 -0
  4. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && \
4
+ apt-get install -y ffmpeg && \
5
+ rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY app.py .
13
+
14
+ EXPOSE 7860
15
+
16
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,12 +1,29 @@
1
  ---
2
- title: Vtranscript
3
- emoji: 👁
4
  colorFrom: gray
5
- colorTo: indigo
6
  sdk: docker
 
7
  pinned: false
8
- license: cc-by-nc-nd-4.0
9
- short_description: VT
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: VOD Transcriber
3
+ emoji: 🎙
4
  colorFrom: gray
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
 
9
  ---
10
 
11
+ # VOD Transcriber
12
+ This directory contains `transcribe_vod.sh` and the transcript outputs.
13
+
14
+ ## What was fixed
15
+ - Added a dependency check for `ffmpeg` before extraction.
16
+ - If missing, the script now exits with:
17
+ - `Error: ffmpeg not found in PATH.`
18
+ - `Install ffmpeg with: brew install ffmpeg`
19
+ - Removed suppression of `ffmpeg` extraction errors so failures are visible.
20
+ - Updated Homebrew whisper executable path from `.../bin/whisper-cpp` to `.../bin/whisper-cli`.
21
+
22
+ ## Why extraction previously failed
23
+ The script attempted to run `ffmpeg` without verifying it existed, and stderr was redirected away on extraction. When `ffmpeg` was missing, this produced exit code `127` with limited diagnostics.
24
+
25
+ ## Verification
26
+ After installing `ffmpeg` and updating the script:
27
+ - audio extraction from `vod.mp4` completed successfully
28
+ - whisper transcription completed successfully
29
+ - transcript output was written to `vod_transcript.txt`
app.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VOD Transcriber — Hugging Face Spaces
3
+ FastAPI + faster-whisper + yt-dlp
4
+ """
5
+ import asyncio
6
+ import glob
7
+ import json
8
+ import os
9
+ import secrets
10
+ import shutil
11
+ import threading
12
+ import uuid
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from typing import Optional
15
+
16
+ import yt_dlp
17
+ from faster_whisper import WhisperModel
18
+ from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
19
+ from fastapi.responses import StreamingResponse
20
+ from fastapi.security import HTTPBasic, HTTPBasicCredentials
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Config
24
+ # ---------------------------------------------------------------------------
25
+ PASSWORD = os.getenv("APP_PASSWORD", "changeme")
26
+ MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # 500 MB
27
+ WHISPER_MODEL = "base.en"
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # App + auth
31
+ # ---------------------------------------------------------------------------
32
+ app = FastAPI()
33
+ security = HTTPBasic()
34
+ executor = ThreadPoolExecutor(max_workers=2)
35
+
36
+ _model: Optional[WhisperModel] = None
37
+ _model_lock = threading.Lock()
38
+
39
+
40
+ def get_model() -> WhisperModel:
41
+ global _model
42
+ if _model is None:
43
+ with _model_lock:
44
+ if _model is None:
45
+ _model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
46
+ return _model
47
+
48
+
49
+ def check_auth(credentials: HTTPBasicCredentials = Depends(security)) -> str:
50
+ ok = secrets.compare_digest(credentials.password.encode(), PASSWORD.encode())
51
+ if not ok:
52
+ raise HTTPException(
53
+ status_code=401,
54
+ detail="Unauthorized",
55
+ headers={"WWW-Authenticate": "Basic"},
56
+ )
57
+ return credentials.username
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Job store
62
+ # ---------------------------------------------------------------------------
63
+ jobs: dict[str, dict] = {}
64
+
65
+
66
+ def new_job() -> str:
67
+ jid = str(uuid.uuid4())
68
+ jobs[jid] = {"messages": [], "done": False, "transcript": None, "error": None}
69
+ return jid
70
+
71
+
72
+ def push(jid: str, msg: str):
73
+ jobs[jid]["messages"].append(msg)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Transcription worker (runs in thread)
78
+ # ---------------------------------------------------------------------------
79
+ def transcribe_file(jid: str, path: str, cleanup_paths: list[str]):
80
+ try:
81
+ push(jid, "Loading whisper model…")
82
+ model = get_model()
83
+
84
+ push(jid, f"Transcribing {os.path.basename(path)}…")
85
+ segments, info = model.transcribe(path, language="en", beam_size=5)
86
+
87
+ lines = []
88
+ for seg in segments:
89
+ ts = f"[{seg.start:.1f}s – {seg.end:.1f}s]"
90
+ lines.append(f"{ts} {seg.text.strip()}")
91
+ push(jid, f"{ts} {seg.text.strip()}")
92
+
93
+ jobs[jid]["transcript"] = "\n".join(lines)
94
+ push(jid, "✓ Done")
95
+ except Exception as e:
96
+ jobs[jid]["error"] = str(e)
97
+ push(jid, f"ERROR: {e}")
98
+ finally:
99
+ jobs[jid]["done"] = True
100
+ for p in cleanup_paths:
101
+ try:
102
+ os.remove(p)
103
+ except OSError:
104
+ pass
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Routes
109
+ # ---------------------------------------------------------------------------
110
+ @app.get("/", dependencies=[Depends(check_auth)])
111
+ async def index():
112
+ from fastapi.responses import HTMLResponse
113
+ return HTMLResponse(HTML)
114
+
115
+
116
+ @app.post("/transcribe/file")
117
+ async def transcribe_upload(
118
+ file: UploadFile = File(...),
119
+ _: str = Depends(check_auth),
120
+ ):
121
+ # Size check via content-length header
122
+ size = int(file.headers.get("content-length", 0))
123
+ if size > MAX_UPLOAD_BYTES:
124
+ raise HTTPException(413, f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024} MB.")
125
+
126
+ jid = new_job()
127
+ ext = os.path.splitext(file.filename or "video.mp4")[1] or ".mp4"
128
+ tmp_path = f"/tmp/{jid}{ext}"
129
+
130
+ push(jid, f"Saving upload ({file.filename})…")
131
+ with open(tmp_path, "wb") as f:
132
+ shutil.copyfileobj(file.file, f)
133
+
134
+ # Double-check actual size
135
+ actual = os.path.getsize(tmp_path)
136
+ if actual > MAX_UPLOAD_BYTES:
137
+ os.remove(tmp_path)
138
+ raise HTTPException(413, f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024} MB.")
139
+
140
+ executor.submit(transcribe_file, jid, tmp_path, [tmp_path])
141
+ return {"job_id": jid}
142
+
143
+
144
+ @app.post("/transcribe/url")
145
+ async def transcribe_url(
146
+ url: str = Form(...),
147
+ _: str = Depends(check_auth),
148
+ ):
149
+ jid = new_job()
150
+ push(jid, f"Fetching URL: {url}")
151
+
152
+ def download_and_transcribe():
153
+ output_tpl = f"/tmp/{jid}.%(ext)s"
154
+ downloaded = []
155
+
156
+ class Hook:
157
+ def __call__(self, d):
158
+ if d["status"] == "downloading":
159
+ pct = d.get("_percent_str", "").strip()
160
+ spd = d.get("_speed_str", "").strip()
161
+ if pct:
162
+ push(jid, f"Downloading {pct} at {spd}")
163
+ elif d["status"] == "finished":
164
+ downloaded.append(d["filename"])
165
+ push(jid, "Download complete.")
166
+
167
+ opts = {
168
+ "format": "bestaudio/best",
169
+ "outtmpl": output_tpl,
170
+ "progress_hooks": [Hook()],
171
+ "quiet": True,
172
+ "no_warnings": True,
173
+ }
174
+
175
+ try:
176
+ with yt_dlp.YoutubeDL(opts) as ydl:
177
+ ydl.download([url])
178
+
179
+ # Find downloaded file (extension may differ from template)
180
+ files = glob.glob(f"/tmp/{jid}.*")
181
+ if not files:
182
+ raise RuntimeError("Download produced no output file.")
183
+ video_path = files[0]
184
+
185
+ # Check size
186
+ if os.path.getsize(video_path) > MAX_UPLOAD_BYTES:
187
+ os.remove(video_path)
188
+ raise RuntimeError(f"Downloaded file exceeds 500 MB limit.")
189
+
190
+ transcribe_file(jid, video_path, files)
191
+
192
+ except Exception as e:
193
+ jobs[jid]["error"] = str(e)
194
+ push(jid, f"ERROR: {e}")
195
+ jobs[jid]["done"] = True
196
+
197
+ executor.submit(download_and_transcribe)
198
+ return {"job_id": jid}
199
+
200
+
201
+ @app.get("/progress/{jid}")
202
+ async def progress(jid: str, _: str = Depends(check_auth)):
203
+ if jid not in jobs:
204
+ raise HTTPException(404, "Job not found.")
205
+
206
+ async def stream():
207
+ sent = 0
208
+ while True:
209
+ job = jobs[jid]
210
+ msgs = job["messages"]
211
+ while sent < len(msgs):
212
+ yield f"data: {json.dumps({'msg': msgs[sent]})}\n\n"
213
+ sent += 1
214
+ if job["done"]:
215
+ yield f"data: {json.dumps({'done': True, 'transcript': job['transcript'], 'error': job['error']})}\n\n"
216
+ del jobs[jid]
217
+ break
218
+ await asyncio.sleep(0.4)
219
+
220
+ return StreamingResponse(stream(), media_type="text/event-stream")
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # HTML
225
+ # ---------------------------------------------------------------------------
226
+ HTML = r"""<!DOCTYPE html>
227
+ <html lang="en">
228
+ <head>
229
+ <meta charset="UTF-8">
230
+ <meta name="viewport" content="width=device-width, initial-scale=1">
231
+ <title>VOD Transcriber</title>
232
+ <style>
233
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
234
+ body {
235
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
236
+ background: #0f0f11;
237
+ color: #e2e2e5;
238
+ min-height: 100vh;
239
+ display: flex;
240
+ align-items: center;
241
+ justify-content: center;
242
+ padding: 2rem;
243
+ }
244
+ .card {
245
+ background: #1a1a1f;
246
+ border: 1px solid #2a2a32;
247
+ border-radius: 16px;
248
+ padding: 2.5rem;
249
+ width: 100%;
250
+ max-width: 660px;
251
+ box-shadow: 0 8px 40px rgba(0,0,0,0.4);
252
+ }
253
+ .card-header { margin-bottom: 2rem; }
254
+ h1 { font-size: 1.4rem; font-weight: 600; color: #fff; margin-bottom: 0.3rem; letter-spacing: -0.02em; }
255
+ .subtitle { font-size: 0.85rem; color: #555; }
256
+
257
+ /* Tabs */
258
+ .tabs { display: flex; gap: 0.25rem; background: #111115; border-radius: 10px; padding: 0.25rem; margin-bottom: 1.25rem; }
259
+ .tab {
260
+ flex: 1; text-align: center; padding: 0.55rem; border-radius: 8px;
261
+ font-size: 0.85rem; font-weight: 500; cursor: pointer; color: #666;
262
+ transition: background 0.15s, color 0.15s; user-select: none;
263
+ }
264
+ .tab.active { background: #2a2a32; color: #e2e2e5; }
265
+
266
+ /* Input areas */
267
+ .input-panel { display: none; }
268
+ .input-panel.active { display: block; }
269
+
270
+ label { display: block; font-size: 0.8rem; font-weight: 500; color: #888; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
271
+
272
+ .upload-area {
273
+ background: #111115;
274
+ border: 1.5px dashed #2a2a32;
275
+ border-radius: 10px;
276
+ padding: 2rem;
277
+ text-align: center;
278
+ cursor: pointer;
279
+ transition: border-color 0.15s;
280
+ margin-bottom: 1rem;
281
+ }
282
+ .upload-area:hover, .upload-area.drag { border-color: #5b5bf6; }
283
+ .upload-area input { display: none; }
284
+ .upload-icon { font-size: 2rem; margin-bottom: 0.5rem; }
285
+ .upload-hint { font-size: 0.82rem; color: #555; }
286
+ .upload-hint span { color: #5b5bf6; }
287
+ .file-selected { font-size: 0.82rem; color: #e2e2e5; margin-top: 0.5rem; }
288
+
289
+ .url-row { display: flex; gap: 0.75rem; margin-bottom: 1rem; }
290
+ input[type="text"] {
291
+ flex: 1; background: #111115; border: 1px solid #2a2a32; border-radius: 10px;
292
+ padding: 0.7rem 1rem; font-size: 0.9rem; color: #e2e2e5; outline: none;
293
+ transition: border-color 0.15s;
294
+ }
295
+ input[type="text"]:focus { border-color: #5b5bf6; }
296
+ input[type="text"]::placeholder { color: #444; }
297
+
298
+ .url-hint { font-size: 0.78rem; color: #444; margin-bottom: 1rem; }
299
+
300
+ button.primary {
301
+ width: 100%; background: #5b5bf6; color: #fff; border: none; border-radius: 10px;
302
+ padding: 0.75rem; font-size: 0.95rem; font-weight: 600; cursor: pointer;
303
+ transition: background 0.15s, opacity 0.15s;
304
+ }
305
+ button.primary:hover:not(:disabled) { background: #4a4ae0; }
306
+ button.primary:disabled { opacity: 0.4; cursor: not-allowed; }
307
+
308
+ /* Status */
309
+ .status { display: none; align-items: center; gap: 0.6rem; font-size: 0.85rem; color: #888; margin-top: 1.25rem; }
310
+ .status.visible { display: flex; }
311
+ .spinner { width: 16px; height: 16px; border: 2px solid #2a2a32; border-top-color: #5b5bf6; border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0; }
312
+ @keyframes spin { to { transform: rotate(360deg); } }
313
+
314
+ /* Log */
315
+ .log-box {
316
+ display: none; background: #111115; border: 1px solid #2a2a32; border-radius: 10px;
317
+ padding: 1rem; font-family: "SF Mono", "Fira Code", monospace; font-size: 0.75rem;
318
+ line-height: 1.6; color: #666; max-height: 220px; overflow-y: auto;
319
+ margin-top: 1.25rem; white-space: pre-wrap; word-break: break-all;
320
+ }
321
+ .log-box.visible { display: block; }
322
+
323
+ /* Result */
324
+ .result { display: none; background: #0d1f17; border: 1px solid #1a3a28; border-radius: 10px; padding: 1rem 1.25rem; margin-top: 1.25rem; }
325
+ .result.visible { display: block; }
326
+ .result-label { font-size: 0.75rem; font-weight: 600; color: #3d9e6a; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.75rem; }
327
+ .result-actions { display: flex; gap: 0.75rem; }
328
+ .btn-dl {
329
+ background: #1a3a28; color: #5bf6a0; border: 1px solid #1a3a28; border-radius: 8px;
330
+ padding: 0.5rem 1rem; font-size: 0.82rem; font-weight: 600; cursor: pointer;
331
+ text-decoration: none; display: inline-block; transition: background 0.15s;
332
+ }
333
+ .btn-dl:hover { background: #224d36; }
334
+
335
+ /* Error */
336
+ .error-box { display: none; background: #1f0d0d; border: 1px solid #3a1a1a; border-radius: 10px; padding: 1rem 1.25rem; font-size: 0.85rem; color: #f66; margin-top: 1.25rem; }
337
+ .error-box.visible { display: block; }
338
+ </style>
339
+ </head>
340
+ <body>
341
+ <div class="card">
342
+ <div class="card-header">
343
+ <h1>VOD Transcriber</h1>
344
+ <p class="subtitle">whisper · local processing · no data retained</p>
345
+ </div>
346
+
347
+ <div class="tabs">
348
+ <div class="tab active" onclick="switchTab('file')">File Upload</div>
349
+ <div class="tab" onclick="switchTab('url')">Video URL</div>
350
+ </div>
351
+
352
+ <!-- File panel -->
353
+ <div class="input-panel active" id="panel-file">
354
+ <div class="upload-area" id="drop-zone" onclick="document.getElementById('file-input').click()"
355
+ ondragover="event.preventDefault(); this.classList.add('drag')"
356
+ ondragleave="this.classList.remove('drag')"
357
+ ondrop="onDrop(event)">
358
+ <input type="file" id="file-input" accept="video/*,audio/*" onchange="onFileSelect(this)">
359
+ <div class="upload-icon">🎬</div>
360
+ <div class="upload-hint">Drop video or audio file here, or <span>browse</span></div>
361
+ <div class="upload-hint">MP4, MOV, MKV, WebM, MP3, WAV · max 500 MB</div>
362
+ <div class="file-selected" id="file-name"></div>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- URL panel -->
367
+ <div class="input-panel" id="panel-url">
368
+ <div class="url-row">
369
+ <input type="text" id="url-input" placeholder="https://youtube.com/watch?v=..." autocomplete="off" spellcheck="false">
370
+ </div>
371
+ <p class="url-hint">YouTube, Twitter/X, Vimeo, Twitch, and 1000+ sites via yt-dlp</p>
372
+ </div>
373
+
374
+ <button class="primary" id="btn" onclick="run()">Transcribe</button>
375
+
376
+ <div class="status" id="status"><div class="spinner"></div><span id="status-text">Processing…</span></div>
377
+ <pre class="log-box" id="log"></pre>
378
+
379
+ <div class="result" id="result">
380
+ <div class="result-label">Transcript ready</div>
381
+ <div class="result-actions">
382
+ <a class="btn-dl" id="dl-link" download="transcript.txt">Download .txt</a>
383
+ </div>
384
+ </div>
385
+
386
+ <div class="error-box" id="error"></div>
387
+ </div>
388
+
389
+ <script>
390
+ let activeTab = 'file';
391
+ let selectedFile = null;
392
+ let transcriptText = null;
393
+
394
+ function switchTab(tab) {
395
+ activeTab = tab;
396
+ document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', (i === 0) === (tab === 'file')));
397
+ document.getElementById('panel-file').classList.toggle('active', tab === 'file');
398
+ document.getElementById('panel-url').classList.toggle('active', tab === 'url');
399
+ }
400
+
401
+ function onFileSelect(input) {
402
+ selectedFile = input.files[0] || null;
403
+ document.getElementById('file-name').textContent = selectedFile ? selectedFile.name : '';
404
+ }
405
+
406
+ function onDrop(e) {
407
+ e.preventDefault();
408
+ document.getElementById('drop-zone').classList.remove('drag');
409
+ const f = e.dataTransfer.files[0];
410
+ if (f) {
411
+ selectedFile = f;
412
+ document.getElementById('file-name').textContent = f.name;
413
+ }
414
+ }
415
+
416
+ function reset() {
417
+ ['log','result','error'].forEach(id => document.getElementById(id).classList.remove('visible'));
418
+ document.getElementById('log').textContent = '';
419
+ document.getElementById('status').classList.remove('visible');
420
+ transcriptText = null;
421
+ }
422
+
423
+ function appendLog(msg) {
424
+ const el = document.getElementById('log');
425
+ el.classList.add('visible');
426
+ el.textContent += msg + '\n';
427
+ el.scrollTop = el.scrollHeight;
428
+ }
429
+
430
+ async function run() {
431
+ reset();
432
+ const btn = document.getElementById('btn');
433
+ btn.disabled = true;
434
+ document.getElementById('status').classList.add('visible');
435
+
436
+ let jobId;
437
+ try {
438
+ if (activeTab === 'file') {
439
+ if (!selectedFile) { showError('Select a file first.'); btn.disabled = false; return; }
440
+ if (selectedFile.size > 500 * 1024 * 1024) { showError('File exceeds 500 MB limit.'); btn.disabled = false; return; }
441
+ const form = new FormData();
442
+ form.append('file', selectedFile);
443
+ document.getElementById('status-text').textContent = 'Uploading…';
444
+ const res = await fetch('/transcribe/file', { method: 'POST', body: form });
445
+ if (!res.ok) { showError(await res.text()); btn.disabled = false; return; }
446
+ jobId = (await res.json()).job_id;
447
+ } else {
448
+ const url = document.getElementById('url-input').value.trim();
449
+ if (!url) { showError('Enter a URL first.'); btn.disabled = false; return; }
450
+ document.getElementById('status-text').textContent = 'Submitting…';
451
+ const form = new FormData();
452
+ form.append('url', url);
453
+ const res = await fetch('/transcribe/url', { method: 'POST', body: form });
454
+ if (!res.ok) { showError(await res.text()); btn.disabled = false; return; }
455
+ jobId = (await res.json()).job_id;
456
+ }
457
+ } catch (e) {
458
+ showError('Request failed: ' + e.message);
459
+ btn.disabled = false;
460
+ return;
461
+ }
462
+
463
+ document.getElementById('status-text').textContent = 'Processing…';
464
+
465
+ // Stream progress via SSE
466
+ const es = new EventSource(`/progress/${jobId}`);
467
+ es.onmessage = (e) => {
468
+ const data = JSON.parse(e.data);
469
+ if (data.msg) appendLog(data.msg);
470
+ if (data.done) {
471
+ es.close();
472
+ document.getElementById('status').classList.remove('visible');
473
+ btn.disabled = false;
474
+ if (data.error) {
475
+ showError(data.error);
476
+ } else {
477
+ transcriptText = data.transcript;
478
+ showResult(data.transcript);
479
+ }
480
+ }
481
+ };
482
+ es.onerror = () => {
483
+ es.close();
484
+ document.getElementById('status').classList.remove('visible');
485
+ btn.disabled = false;
486
+ showError('Connection lost during processing.');
487
+ };
488
+ }
489
+
490
+ function showResult(text) {
491
+ const blob = new Blob([text], { type: 'text/plain' });
492
+ const url = URL.createObjectURL(blob);
493
+ const link = document.getElementById('dl-link');
494
+ link.href = url;
495
+ link.download = 'transcript.txt';
496
+ document.getElementById('result').classList.add('visible');
497
+ }
498
+
499
+ function showError(msg) {
500
+ const el = document.getElementById('error');
501
+ el.textContent = msg;
502
+ el.classList.add('visible');
503
+ document.getElementById('status').classList.remove('visible');
504
+ }
505
+
506
+ document.getElementById('url-input').addEventListener('keydown', e => { if (e.key === 'Enter') run(); });
507
+ </script>
508
+ </body>
509
+ </html>"""
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ faster-whisper
4
+ yt-dlp
5
+ python-multipart