Spaces:
Sleeping
Sleeping
| """ | |
| VOD Transcriber — Hugging Face Spaces | |
| FastAPI + faster-whisper + yt-dlp | |
| """ | |
| import asyncio | |
| import glob | |
| import hashlib | |
| import hmac | |
| import json | |
| import os | |
| import secrets | |
| import shutil | |
| import threading | |
| import uuid | |
| from concurrent.futures import ThreadPoolExecutor | |
| from typing import Optional | |
| import yt_dlp | |
| from faster_whisper import WhisperModel | |
| from fastapi import FastAPI, File, Form, Request, UploadFile | |
| from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| PASSWORD = os.getenv("APP_PASSWORD", "changeme") | |
| MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # 500 MB | |
| WHISPER_MODEL = "base.en" | |
| COOKIE_NAME = "auth" | |
| _SECRET = os.urandom(32) # ephemeral signing key | |
| # --------------------------------------------------------------------------- | |
| # Auth helpers | |
| # --------------------------------------------------------------------------- | |
| def _make_token(pw: str) -> str: | |
| return hmac.new(_SECRET, pw.encode(), hashlib.sha256).hexdigest() | |
| def _is_authed(request: Request) -> bool: | |
| token = request.cookies.get(COOKIE_NAME, "") | |
| return secrets.compare_digest(token, _make_token(PASSWORD)) | |
| class AuthMiddleware(BaseHTTPMiddleware): | |
| async def dispatch(self, request: Request, call_next): | |
| if request.url.path in ("/login",): | |
| return await call_next(request) | |
| if not _is_authed(request): | |
| return RedirectResponse("/login", status_code=302) | |
| return await call_next(request) | |
| # --------------------------------------------------------------------------- | |
| # App | |
| # --------------------------------------------------------------------------- | |
| app = FastAPI() | |
| app.add_middleware(AuthMiddleware) | |
| executor = ThreadPoolExecutor(max_workers=2) | |
| _model: Optional[WhisperModel] = None | |
| _model_lock = threading.Lock() | |
| def get_model() -> WhisperModel: | |
| global _model | |
| if _model is None: | |
| with _model_lock: | |
| if _model is None: | |
| _model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8") | |
| return _model | |
| # --------------------------------------------------------------------------- | |
| # Login routes | |
| # --------------------------------------------------------------------------- | |
| LOGIN_HTML = """<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>VOD Transcriber — Login</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: #0f0f11; color: #e2e2e5; | |
| min-height: 100vh; display: flex; align-items: center; justify-content: center; | |
| } | |
| .card { | |
| background: #1a1a1f; border: 1px solid #2a2a32; border-radius: 16px; | |
| padding: 2.5rem; width: 100%; max-width: 360px; | |
| box-shadow: 0 8px 40px rgba(0,0,0,0.4); | |
| } | |
| h1 { font-size: 1.2rem; font-weight: 600; color: #fff; margin-bottom: 0.3rem; } | |
| .sub { font-size: 0.82rem; color: #555; margin-bottom: 2rem; } | |
| label { display: block; font-size: 0.78rem; font-weight: 500; color: #888; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; } | |
| input[type="password"] { | |
| width: 100%; background: #111115; border: 1px solid #2a2a32; border-radius: 10px; | |
| padding: 0.7rem 1rem; font-size: 0.9rem; color: #e2e2e5; outline: none; | |
| margin-bottom: 1rem; transition: border-color 0.15s; | |
| } | |
| input[type="password"]:focus { border-color: #5b5bf6; } | |
| button { | |
| width: 100%; background: #5b5bf6; color: #fff; border: none; border-radius: 10px; | |
| padding: 0.75rem; font-size: 0.95rem; font-weight: 600; cursor: pointer; | |
| transition: background 0.15s; | |
| } | |
| button:hover { background: #4a4ae0; } | |
| .err { color: #f66; font-size: 0.82rem; margin-top: 0.75rem; display: none; } | |
| .err.visible { display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <h1>VOD Transcriber</h1> | |
| <p class="sub">Enter password to continue</p> | |
| <form method="POST" action="/login"> | |
| <label for="pw">Password</label> | |
| <input type="password" id="pw" name="password" autofocus autocomplete="current-password"> | |
| <button type="submit">Sign in</button> | |
| </form> | |
| <p class="err {err_class}">{err_msg}</p> | |
| </div> | |
| </body> | |
| </html>""" | |
| async def login_page(): | |
| return LOGIN_HTML.replace("{err_class}", "").replace("{err_msg}", "") | |
| async def login(password: str = Form(...)): | |
| if secrets.compare_digest(password.encode(), PASSWORD.encode()): | |
| resp = RedirectResponse("/", status_code=302) | |
| resp.set_cookie(COOKIE_NAME, _make_token(PASSWORD), httponly=True, samesite="lax") | |
| return resp | |
| html = LOGIN_HTML.replace("{err_class}", "visible").replace("{err_msg}", "Incorrect password.") | |
| return HTMLResponse(html, status_code=401) | |
| # --------------------------------------------------------------------------- | |
| # Job store | |
| # --------------------------------------------------------------------------- | |
| jobs: dict[str, dict] = {} | |
| def new_job() -> str: | |
| jid = str(uuid.uuid4()) | |
| jobs[jid] = {"messages": [], "done": False, "transcript": None, "error": None} | |
| return jid | |
| def push(jid: str, msg: str): | |
| jobs[jid]["messages"].append(msg) | |
| # --------------------------------------------------------------------------- | |
| # Transcription worker (runs in thread) | |
| # --------------------------------------------------------------------------- | |
| def transcribe_file(jid: str, path: str, cleanup_paths: list[str]): | |
| try: | |
| push(jid, "Loading whisper model…") | |
| model = get_model() | |
| push(jid, f"Transcribing {os.path.basename(path)}…") | |
| segments, info = model.transcribe(path, language="en", beam_size=5) | |
| lines = [] | |
| for seg in segments: | |
| ts = f"[{seg.start:.1f}s – {seg.end:.1f}s]" | |
| lines.append(f"{ts} {seg.text.strip()}") | |
| push(jid, f"{ts} {seg.text.strip()}") | |
| jobs[jid]["transcript"] = "\n".join(lines) | |
| push(jid, "✓ Done") | |
| except Exception as e: | |
| jobs[jid]["error"] = str(e) | |
| push(jid, f"ERROR: {e}") | |
| finally: | |
| jobs[jid]["done"] = True | |
| for p in cleanup_paths: | |
| try: | |
| os.remove(p) | |
| except OSError: | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # Routes | |
| # --------------------------------------------------------------------------- | |
| async def index(): | |
| return HTML | |
| async def transcribe_upload(file: UploadFile = File(...)): | |
| # Size check via content-length header | |
| size = int(file.headers.get("content-length", 0)) | |
| if size > MAX_UPLOAD_BYTES: | |
| raise HTTPException(413, f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024} MB.") | |
| jid = new_job() | |
| ext = os.path.splitext(file.filename or "video.mp4")[1] or ".mp4" | |
| tmp_path = f"/tmp/{jid}{ext}" | |
| push(jid, f"Saving upload ({file.filename})…") | |
| with open(tmp_path, "wb") as f: | |
| shutil.copyfileobj(file.file, f) | |
| # Double-check actual size | |
| actual = os.path.getsize(tmp_path) | |
| if actual > MAX_UPLOAD_BYTES: | |
| os.remove(tmp_path) | |
| raise HTTPException(413, f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024} MB.") | |
| executor.submit(transcribe_file, jid, tmp_path, [tmp_path]) | |
| return {"job_id": jid} | |
| async def transcribe_url( | |
| url: str = Form(...), | |
| cookies: UploadFile = File(None), | |
| ): | |
| jid = new_job() | |
| push(jid, f"Fetching URL: {url}") | |
| # Save cookies file if provided | |
| cookie_path = None | |
| if cookies and cookies.filename: | |
| cookie_path = f"/tmp/{jid}_cookies.txt" | |
| with open(cookie_path, "wb") as f: | |
| shutil.copyfileobj(cookies.file, f) | |
| push(jid, "Cookies loaded.") | |
| def download_and_transcribe(): | |
| output_tpl = f"/tmp/{jid}.%(ext)s" | |
| downloaded = [] | |
| class Hook: | |
| def __call__(self, d): | |
| if d["status"] == "downloading": | |
| pct = d.get("_percent_str", "").strip() | |
| spd = d.get("_speed_str", "").strip() | |
| if pct: | |
| push(jid, f"Downloading {pct} at {spd}") | |
| elif d["status"] == "finished": | |
| downloaded.append(d["filename"]) | |
| push(jid, "Download complete.") | |
| opts = { | |
| "format": "bestaudio/best", | |
| "outtmpl": output_tpl, | |
| "progress_hooks": [Hook()], | |
| "quiet": True, | |
| "no_warnings": True, | |
| # Bypass YouTube datacenter IP blocks | |
| "extractor_args": {"youtube": {"player_client": ["ios", "web"]}}, | |
| "http_headers": { | |
| "User-Agent": ( | |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | |
| "AppleWebKit/537.36 (KHTML, like Gecko) " | |
| "Chrome/124.0.0.0 Safari/537.36" | |
| ), | |
| }, | |
| "source_address": "0.0.0.0", # force IPv4 | |
| } | |
| if cookie_path: | |
| opts["cookiefile"] = cookie_path | |
| try: | |
| with yt_dlp.YoutubeDL(opts) as ydl: | |
| ydl.download([url]) | |
| # Find downloaded file (extension may differ from template) | |
| files = glob.glob(f"/tmp/{jid}.*") | |
| if not files: | |
| raise RuntimeError("Download produced no output file.") | |
| video_path = files[0] | |
| # Check size | |
| if os.path.getsize(video_path) > MAX_UPLOAD_BYTES: | |
| os.remove(video_path) | |
| raise RuntimeError(f"Downloaded file exceeds 500 MB limit.") | |
| transcribe_file(jid, video_path, files) | |
| except Exception as e: | |
| jobs[jid]["error"] = str(e) | |
| push(jid, f"ERROR: {e}") | |
| jobs[jid]["done"] = True | |
| finally: | |
| if cookie_path: | |
| try: | |
| os.remove(cookie_path) | |
| except OSError: | |
| pass | |
| executor.submit(download_and_transcribe) | |
| return {"job_id": jid} | |
| async def progress(jid: str): | |
| if jid not in jobs: | |
| raise HTTPException(404, "Job not found.") | |
| async def stream(): | |
| sent = 0 | |
| while True: | |
| job = jobs[jid] | |
| msgs = job["messages"] | |
| while sent < len(msgs): | |
| yield f"data: {json.dumps({'msg': msgs[sent]})}\n\n" | |
| sent += 1 | |
| if job["done"]: | |
| yield f"data: {json.dumps({'done': True, 'transcript': job['transcript'], 'error': job['error']})}\n\n" | |
| del jobs[jid] | |
| break | |
| await asyncio.sleep(0.4) | |
| return StreamingResponse(stream(), media_type="text/event-stream") | |
| # --------------------------------------------------------------------------- | |
| # HTML | |
| # --------------------------------------------------------------------------- | |
| HTML = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>VOD Transcriber</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: #0f0f11; | |
| color: #e2e2e5; | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 2rem; | |
| } | |
| .card { | |
| background: #1a1a1f; | |
| border: 1px solid #2a2a32; | |
| border-radius: 16px; | |
| padding: 2.5rem; | |
| width: 100%; | |
| max-width: 660px; | |
| box-shadow: 0 8px 40px rgba(0,0,0,0.4); | |
| } | |
| .card-header { margin-bottom: 2rem; } | |
| h1 { font-size: 1.4rem; font-weight: 600; color: #fff; margin-bottom: 0.3rem; letter-spacing: -0.02em; } | |
| .subtitle { font-size: 0.85rem; color: #555; } | |
| /* Tabs */ | |
| .tabs { display: flex; gap: 0.25rem; background: #111115; border-radius: 10px; padding: 0.25rem; margin-bottom: 1.25rem; } | |
| .tab { | |
| flex: 1; text-align: center; padding: 0.55rem; border-radius: 8px; | |
| font-size: 0.85rem; font-weight: 500; cursor: pointer; color: #666; | |
| transition: background 0.15s, color 0.15s; user-select: none; | |
| } | |
| .tab.active { background: #2a2a32; color: #e2e2e5; } | |
| /* Input areas */ | |
| .input-panel { display: none; } | |
| .input-panel.active { display: block; } | |
| label { display: block; font-size: 0.8rem; font-weight: 500; color: #888; margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; } | |
| .upload-area { | |
| background: #111115; | |
| border: 1.5px dashed #2a2a32; | |
| border-radius: 10px; | |
| padding: 2rem; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: border-color 0.15s; | |
| margin-bottom: 1rem; | |
| } | |
| .upload-area:hover, .upload-area.drag { border-color: #5b5bf6; } | |
| .upload-area input { display: none; } | |
| .upload-icon { font-size: 2rem; margin-bottom: 0.5rem; } | |
| .upload-hint { font-size: 0.82rem; color: #555; } | |
| .upload-hint span { color: #5b5bf6; } | |
| .file-selected { font-size: 0.82rem; color: #e2e2e5; margin-top: 0.5rem; } | |
| .url-row { display: flex; gap: 0.75rem; margin-bottom: 1rem; } | |
| input[type="text"] { | |
| flex: 1; background: #111115; border: 1px solid #2a2a32; border-radius: 10px; | |
| padding: 0.7rem 1rem; font-size: 0.9rem; color: #e2e2e5; outline: none; | |
| transition: border-color 0.15s; | |
| } | |
| input[type="text"]:focus { border-color: #5b5bf6; } | |
| input[type="text"]::placeholder { color: #444; } | |
| .url-hint { font-size: 0.78rem; color: #444; margin-bottom: 1rem; } | |
| .cookie-section { margin-bottom: 1rem; } | |
| .cookie-label { font-size: 0.78rem; color: #555; cursor: pointer; user-select: none; transition: color 0.15s; } | |
| .cookie-label:hover { color: #888; } | |
| button.primary { | |
| width: 100%; background: #5b5bf6; color: #fff; border: none; border-radius: 10px; | |
| padding: 0.75rem; font-size: 0.95rem; font-weight: 600; cursor: pointer; | |
| transition: background 0.15s, opacity 0.15s; | |
| } | |
| button.primary:hover:not(:disabled) { background: #4a4ae0; } | |
| button.primary:disabled { opacity: 0.4; cursor: not-allowed; } | |
| /* Status */ | |
| .status { display: none; align-items: center; gap: 0.6rem; font-size: 0.85rem; color: #888; margin-top: 1.25rem; } | |
| .status.visible { display: flex; } | |
| .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; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* Log */ | |
| .log-box { | |
| display: none; background: #111115; border: 1px solid #2a2a32; border-radius: 10px; | |
| padding: 1rem; font-family: "SF Mono", "Fira Code", monospace; font-size: 0.75rem; | |
| line-height: 1.6; color: #666; max-height: 220px; overflow-y: auto; | |
| margin-top: 1.25rem; white-space: pre-wrap; word-break: break-all; | |
| } | |
| .log-box.visible { display: block; } | |
| /* Result */ | |
| .result { display: none; background: #0d1f17; border: 1px solid #1a3a28; border-radius: 10px; padding: 1rem 1.25rem; margin-top: 1.25rem; } | |
| .result.visible { display: block; } | |
| .result-label { font-size: 0.75rem; font-weight: 600; color: #3d9e6a; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.75rem; } | |
| .result-actions { display: flex; gap: 0.75rem; } | |
| .btn-dl { | |
| background: #1a3a28; color: #5bf6a0; border: 1px solid #1a3a28; border-radius: 8px; | |
| padding: 0.5rem 1rem; font-size: 0.82rem; font-weight: 600; cursor: pointer; | |
| text-decoration: none; display: inline-block; transition: background 0.15s; | |
| } | |
| .btn-dl:hover { background: #224d36; } | |
| /* Error */ | |
| .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; } | |
| .error-box.visible { display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <h1>VOD Transcriber</h1> | |
| <p class="subtitle">whisper · local processing · no data retained</p> | |
| </div> | |
| <div class="tabs"> | |
| <div class="tab active" onclick="switchTab('file')">File Upload</div> | |
| <div class="tab" onclick="switchTab('url')">Video URL</div> | |
| </div> | |
| <!-- File panel --> | |
| <div class="input-panel active" id="panel-file"> | |
| <div class="upload-area" id="drop-zone" onclick="document.getElementById('file-input').click()" | |
| ondragover="event.preventDefault(); this.classList.add('drag')" | |
| ondragleave="this.classList.remove('drag')" | |
| ondrop="onDrop(event)"> | |
| <input type="file" id="file-input" accept="video/*,audio/*" onchange="onFileSelect(this)"> | |
| <div class="upload-icon">🎬</div> | |
| <div class="upload-hint">Drop video or audio file here, or <span>browse</span></div> | |
| <div class="upload-hint">MP4, MOV, MKV, WebM, MP3, WAV · max 500 MB</div> | |
| <div class="file-selected" id="file-name"></div> | |
| </div> | |
| </div> | |
| <!-- URL panel --> | |
| <div class="input-panel" id="panel-url"> | |
| <div class="url-row"> | |
| <input type="text" id="url-input" placeholder="https://youtube.com/watch?v=..." autocomplete="off" spellcheck="false"> | |
| </div> | |
| <p class="url-hint">YouTube, Twitter/X, Vimeo, Twitch, and 1000+ sites via yt-dlp</p> | |
| <div class="cookie-section"> | |
| <div class="cookie-label" onclick="toggleCookies()"> | |
| <span id="cookie-arrow">▸</span> YouTube blocked? Add cookies | |
| </div> | |
| <div id="cookie-panel" style="display:none; margin-top:0.75rem;"> | |
| <div class="upload-area" style="padding:1rem;" onclick="document.getElementById('cookies-input').click()"> | |
| <input type="file" id="cookies-input" accept=".txt" onchange="onCookieSelect(this)"> | |
| <div class="upload-hint">Upload <span>cookies.txt</span> exported from your browser</div> | |
| <div class="file-selected" id="cookie-name"></div> | |
| </div> | |
| <p class="url-hint" style="margin-top:0.5rem;">Use <a href="https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc" target="_blank" style="color:#5b5bf6;">Get cookies.txt LOCALLY</a> Chrome extension → export for youtube.com</p> | |
| </div> | |
| </div> | |
| </div> | |
| <button class="primary" id="btn" onclick="run()">Transcribe</button> | |
| <div class="status" id="status"><div class="spinner"></div><span id="status-text">Processing…</span></div> | |
| <pre class="log-box" id="log"></pre> | |
| <div class="result" id="result"> | |
| <div class="result-label">Transcript ready</div> | |
| <div class="result-actions"> | |
| <a class="btn-dl" id="dl-link" download="transcript.txt">Download .txt</a> | |
| </div> | |
| </div> | |
| <div class="error-box" id="error"></div> | |
| </div> | |
| <script> | |
| let activeTab = 'file'; | |
| let selectedFile = null; | |
| let selectedCookies = null; | |
| let transcriptText = null; | |
| function switchTab(tab) { | |
| activeTab = tab; | |
| document.querySelectorAll('.tab').forEach((t, i) => t.classList.toggle('active', (i === 0) === (tab === 'file'))); | |
| document.getElementById('panel-file').classList.toggle('active', tab === 'file'); | |
| document.getElementById('panel-url').classList.toggle('active', tab === 'url'); | |
| } | |
| function onFileSelect(input) { | |
| selectedFile = input.files[0] || null; | |
| document.getElementById('file-name').textContent = selectedFile ? selectedFile.name : ''; | |
| } | |
| function onCookieSelect(input) { | |
| selectedCookies = input.files[0] || null; | |
| document.getElementById('cookie-name').textContent = selectedCookies ? selectedCookies.name : ''; | |
| } | |
| function toggleCookies() { | |
| const panel = document.getElementById('cookie-panel'); | |
| const arrow = document.getElementById('cookie-arrow'); | |
| const open = panel.style.display === 'none'; | |
| panel.style.display = open ? 'block' : 'none'; | |
| arrow.textContent = open ? '▾' : '▸'; | |
| } | |
| function onDrop(e) { | |
| e.preventDefault(); | |
| document.getElementById('drop-zone').classList.remove('drag'); | |
| const f = e.dataTransfer.files[0]; | |
| if (f) { | |
| selectedFile = f; | |
| document.getElementById('file-name').textContent = f.name; | |
| } | |
| } | |
| function reset() { | |
| ['log','result','error'].forEach(id => document.getElementById(id).classList.remove('visible')); | |
| document.getElementById('log').textContent = ''; | |
| document.getElementById('status').classList.remove('visible'); | |
| transcriptText = null; | |
| } | |
| function appendLog(msg) { | |
| const el = document.getElementById('log'); | |
| el.classList.add('visible'); | |
| el.textContent += msg + '\n'; | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| async function run() { | |
| reset(); | |
| const btn = document.getElementById('btn'); | |
| btn.disabled = true; | |
| document.getElementById('status').classList.add('visible'); | |
| let jobId; | |
| try { | |
| if (activeTab === 'file') { | |
| if (!selectedFile) { showError('Select a file first.'); btn.disabled = false; return; } | |
| if (selectedFile.size > 500 * 1024 * 1024) { showError('File exceeds 500 MB limit.'); btn.disabled = false; return; } | |
| const form = new FormData(); | |
| form.append('file', selectedFile); | |
| document.getElementById('status-text').textContent = 'Uploading…'; | |
| const res = await fetch('/transcribe/file', { method: 'POST', body: form }); | |
| if (!res.ok) { showError(await res.text()); btn.disabled = false; return; } | |
| jobId = (await res.json()).job_id; | |
| } else { | |
| const url = document.getElementById('url-input').value.trim(); | |
| if (!url) { showError('Enter a URL first.'); btn.disabled = false; return; } | |
| document.getElementById('status-text').textContent = 'Submitting…'; | |
| const form = new FormData(); | |
| form.append('url', url); | |
| if (selectedCookies) form.append('cookies', selectedCookies); | |
| const res = await fetch('/transcribe/url', { method: 'POST', body: form }); | |
| if (!res.ok) { showError(await res.text()); btn.disabled = false; return; } | |
| jobId = (await res.json()).job_id; | |
| } | |
| } catch (e) { | |
| showError('Request failed: ' + e.message); | |
| btn.disabled = false; | |
| return; | |
| } | |
| document.getElementById('status-text').textContent = 'Processing…'; | |
| // Stream progress via SSE | |
| const es = new EventSource(`/progress/${jobId}`); | |
| es.onmessage = (e) => { | |
| const data = JSON.parse(e.data); | |
| if (data.msg) appendLog(data.msg); | |
| if (data.done) { | |
| es.close(); | |
| document.getElementById('status').classList.remove('visible'); | |
| btn.disabled = false; | |
| if (data.error) { | |
| showError(data.error); | |
| } else { | |
| transcriptText = data.transcript; | |
| showResult(data.transcript); | |
| } | |
| } | |
| }; | |
| es.onerror = () => { | |
| es.close(); | |
| document.getElementById('status').classList.remove('visible'); | |
| btn.disabled = false; | |
| showError('Connection lost during processing.'); | |
| }; | |
| } | |
| function showResult(text) { | |
| const blob = new Blob([text], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.getElementById('dl-link'); | |
| link.href = url; | |
| link.download = 'transcript.txt'; | |
| document.getElementById('result').classList.add('visible'); | |
| } | |
| function showError(msg) { | |
| const el = document.getElementById('error'); | |
| el.textContent = msg; | |
| el.classList.add('visible'); | |
| document.getElementById('status').classList.remove('visible'); | |
| } | |
| document.getElementById('url-input').addEventListener('keydown', e => { if (e.key === 'Enter') run(); }); | |
| </script> | |
| </body> | |
| </html>""" | |