Transcribe videos without subtitles via faster-whisper fallback

When yt-dlp yields no subtitle files, the server now runs
scripts/transcribe.py (faster-whisper, large-v3-turbo, CUDA with CPU
fallback) on the downloaded media and feeds the resulting VTT through
the existing Markdown conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P1w53ybPWArpZpcei9JPYX
This commit is contained in:
Claude Fable 5 2026-08-29 22:20:55 +02:00
parent 891886b6e8
commit a3eb962b90
6 changed files with 159 additions and 0 deletions

3
.gitignore vendored
View File

@ -27,3 +27,6 @@ users.txt
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Whisper venv
.venv/

View File

@ -80,6 +80,30 @@ Edit `start.sh` to configure the variables below, or pass them directly on the c
| `USERS_FILE` | `users.txt` next to `start.sh` | Path to the user database file | | `USERS_FILE` | `users.txt` next to `start.sh` | Path to the user database file |
| `ADS_DIR` | same directory as `start.sh` | Directory containing `ads-left.html` and `ads-right.html` | | `ADS_DIR` | same directory as `start.sh` | Directory containing `ads-left.html` and `ads-right.html` |
### Whisper transcription fallback
When a download yields no subtitles (e.g. Instagram, archive.org), the server
transcribes the media locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
and feeds the result through the same VTT→Markdown conversion. The fallback
is skipped with a warning if faster-whisper is not installed.
Setup (GPU recommended; the pip-installed cuBLAS/cuDNN libraries are found
automatically, no `LD_LIBRARY_PATH` needed):
```bash
python3 -m venv .venv
.venv/bin/pip install faster-whisper "nvidia-cublas-cu12<13" "nvidia-cudnn-cu12<10"
```
The model (~1.6 GB for `large-v3-turbo`) is downloaded from Hugging Face on
first use and cached in `~/.cache/huggingface`.
| Variable | Default | Purpose |
|---|---|---|
| `WHISPER_PYTHON` | `python3` | Python interpreter with faster-whisper installed (e.g. `.venv/bin/python`) |
| `WHISPER_MODEL` | `large-v3-turbo` | faster-whisper model name (`large-v3` for best quality, `small` for low-end machines) |
| `WHISPER_DEVICE` | _(auto)_ | Force `cuda` or `cpu`; by default CUDA is tried first with CPU fallback |
### ZIP & Send mode ### ZIP & Send mode
When `ZIP_AND_SEND=true`, all downloaded files are packed into a ZIP and offered as a browser download instead of (only) being saved on the server. A random prefix is added to the temporary directory name to avoid collisions when multiple users download the same video simultaneously. When `ZIP_AND_SEND=true`, all downloaded files are packed into a ZIP and offered as a browser download instead of (only) being saved on the server. A random prefix is added to the temporary directory name to avoid collisions when multiple users download the same video simultaneously.

View File

@ -7,6 +7,7 @@ YouTube Downloader & Transcript Extractor — a local web frontend for `yt-dlp`
- Paste a YouTube URL and download the best available video quality - Paste a YouTube URL and download the best available video quality
- Subtitles downloaded automatically in the video's original language, English, and German (where available) - Subtitles downloaded automatically in the video's original language, English, and German (where available)
- Subtitles converted to clean Markdown (timestamps stripped, text deduplicated and paragraph-wrapped) - Subtitles converted to clean Markdown (timestamps stripped, text deduplicated and paragraph-wrapped)
- Videos without subtitles (Instagram, archive.org, …) are transcribed locally via Whisper (faster-whisper, GPU-accelerated) — see `INSTALL.md`
- Optional audio extraction to MP3 via ffmpeg - Optional audio extraction to MP3 via ffmpeg
- Real-time progress log streamed to the browser - Real-time progress log streamed to the browser
- Files saved to `~/YouTube/<video title>/` - Files saved to `~/YouTube/<video title>/`

103
scripts/transcribe.py Executable file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Transcribe a media file to WebVTT using faster-whisper.
Usage: transcribe.py <mediafile>
The VTT is written next to the input file as <base>.<detected-lang>.vtt
and its path is printed as a final "TRANSCRIPT_VTT:<path>" line so the
calling server can pick it up.
Environment variables:
WHISPER_MODEL faster-whisper model name (default: large-v3-turbo)
WHISPER_DEVICE "cuda" or "cpu" (default: try cuda, fall back to cpu)
Exit codes: 0 ok, 2 usage/input error, 3 faster-whisper not installed.
"""
import ctypes
import glob
import os
import sys
def preload_nvidia_libs():
"""Load pip-installed cuBLAS/cuDNN so ctranslate2 finds them without
LD_LIBRARY_PATH (dlopen by soname succeeds once they are in-process)."""
site_dirs = [d for d in sys.path if d.endswith("site-packages")]
for site in site_dirs:
for lib in sorted(glob.glob(os.path.join(site, "nvidia", "*", "lib", "*.so*"))):
try:
ctypes.CDLL(lib, mode=ctypes.RTLD_GLOBAL)
except OSError:
pass
def fmt_ts(seconds):
ms = int(round(seconds * 1000))
h, ms = divmod(ms, 3_600_000)
m, ms = divmod(ms, 60_000)
s, ms = divmod(ms, 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def main():
if len(sys.argv) != 2:
print(__doc__.strip(), file=sys.stderr)
return 2
media = sys.argv[1]
if not os.path.isfile(media):
print(f"Input file not found: {media}", file=sys.stderr)
return 2
preload_nvidia_libs()
try:
from faster_whisper import WhisperModel
except ImportError:
print("faster-whisper is not installed in this Python environment", file=sys.stderr)
return 3
model_name = os.environ.get("WHISPER_MODEL", "large-v3-turbo")
device = os.environ.get("WHISPER_DEVICE")
attempts = [(device, None)] if device else [("cuda", "float16"), ("cpu", "int8")]
model = None
for dev, compute in attempts:
try:
model = WhisperModel(model_name, device=dev, compute_type=compute or "default")
print(f"Whisper model {model_name} loaded on {dev}", flush=True)
break
except Exception as err:
print(f"Could not load model on {dev}: {err}", file=sys.stderr, flush=True)
if model is None:
return 1
segments, info = model.transcribe(media, vad_filter=True)
print(f"Detected language: {info.language} "
f"(p={info.language_probability:.2f}), duration {fmt_ts(info.duration)}", flush=True)
base, _ = os.path.splitext(media)
vtt_path = f"{base}.{info.language}.vtt"
lines = ["WEBVTT", ""]
for seg in segments:
text = seg.text.strip()
if not text:
continue
lines.append(f"{fmt_ts(seg.start)} --> {fmt_ts(seg.end)}")
lines.append(text)
lines.append("")
# progress for the live log (segments arrive lazily during transcription)
print(f"[transcribe] {fmt_ts(seg.start)} {text}", flush=True)
if len(lines) <= 2:
print("No speech detected — no transcript written", flush=True)
return 0
with open(vtt_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"TRANSCRIPT_VTT:{vtt_path}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -9,6 +9,7 @@ import { config, zipStore } from '$lib/server/store.js';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const YTDLP = process.env.YTDLP_PATH ?? 'yt-dlp'; const YTDLP = process.env.YTDLP_PATH ?? 'yt-dlp';
const FFMPEG = process.env.FFMPEG_PATH ?? 'ffmpeg'; const FFMPEG = process.env.FFMPEG_PATH ?? 'ffmpeg';
const WHISPER_PYTHON = process.env.WHISPER_PYTHON ?? 'python3';
export async function GET({ url, request }) { export async function GET({ url, request }) {
const videoUrl = url.searchParams.get('url'); const videoUrl = url.searchParams.get('url');
@ -105,6 +106,28 @@ async function runDownload(videoUrl, audioOnly, send, signal) {
} }
} }
// Fallback: no subtitles from the platform — transcribe locally with Whisper
if (subtitlePaths.length === 0) {
if (!videoPath && existsSync(outputDir)) {
const media = readdirSync(outputDir).find(f => /\.(mp4|mkv|webm|m4a|mp3)$/.test(f));
if (media) videoPath = join(outputDir, media);
}
if (videoPath && existsSync(videoPath)) {
send('info', 'No subtitles found — transcribing with Whisper…');
try {
await runProcess(WHISPER_PYTHON, [
join(process.cwd(), 'scripts', 'transcribe.py'), videoPath
], line => {
const vtt = line.match(/^TRANSCRIPT_VTT:(.+)$/);
if (vtt) subtitlePaths.push(vtt[1].trim());
else send('log', line);
}, signal);
} catch (err) {
send('warn', `Transcription failed: ${err.message}`);
}
}
}
const writtenMd = new Set(); const writtenMd = new Set();
for (const subPath of subtitlePaths) { for (const subPath of subtitlePaths) {
if (!existsSync(subPath)) continue; if (!existsSync(subPath)) continue;

View File

@ -30,6 +30,11 @@
# #
# ADS_DIR Directory containing ads-left.html and ads-right.html. # ADS_DIR Directory containing ads-left.html and ads-right.html.
# (default: same directory as start.sh) # (default: same directory as start.sh)
#
# WHISPER_PYTHON Python interpreter with faster-whisper installed, used to
# transcribe media that has no subtitles. (default: python3)
# WHISPER_MODEL faster-whisper model name. (default: large-v3-turbo)
# WHISPER_DEVICE Force "cuda" or "cpu". (default: try cuda, fall back to cpu)
export PORT="${PORT:-3000}" export PORT="${PORT:-3000}"
export ORIGIN="${ORIGIN:-http://localhost:${PORT}}" export ORIGIN="${ORIGIN:-http://localhost:${PORT}}"