Vtranscript / app.py
EdiBianco
Add YouTube cookies.txt upload as fallback for IP-blocked downloads
bc47940
Raw
History Blame Contribute Delete
23.6 kB
"""
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>"""
@app.get("/login", response_class=HTMLResponse)
async def login_page():
return LOGIN_HTML.replace("{err_class}", "").replace("{err_msg}", "")
@app.post("/login")
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
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index():
return HTML
@app.post("/transcribe/file")
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}
@app.post("/transcribe/url")
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}
@app.get("/progress/{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>"""