Live demo: subtitled.onrender.com (free tier — first load may take ~50s if the server is asleep)\
Stack: Node.js · Express · TypeScript · React · Vite · MongoDB · Docker · Supadata · DeepL · OpenAI Whisper
Paste a YouTube URL, get synced English subtitles — whether the video already has captions or not.
- Cache check first. Every translated video is stored in MongoDB keyed by video ID. If it's already been processed by anyone, the cached transcript is returned instantly and nothing below runs.
- Supadata transcript. The backend calls the
Supadata API with
mode=auto— it returns native captions when they exist, or an AI-generated transcript when they don't, all in one call. No Whisper or yt-dlp needed on the server. - Translation. If the transcript isn't already in English, it's sent to DeepL for translation before caching and returning.
- Cache check (same as above).
- youtube-transcript-plus. Tries the English caption track first via
YouTube's Innertube API; falls back to any available track if English
isn't present (non-English sets
needsTranslation). - Whisper fallback. If no captions exist at all, the backend downloads
the audio with
yt-dlpand runs OpenAI Whisper locally withtask=translate, which transcribes and translates in one step. - Translation. Same DeepL step if needed.
The React app embeds the video via the YouTube IFrame Player API and overlays the matching subtitle line based on the player's current time (polled every 250ms — there's no event-driven time API on the iframe player).
- On first visit, the backend sets an anonymous, httpOnly "remember this device" cookie — no login involved.
- The transcript cache (MongoDB collection
videos) is global — shared across everyone using the app. Video title and thumbnail are fetched via YouTube's oEmbed endpoint when a video is first cached (on the local dev path, title also comes from the youtube-transcript-plusvideoDetailsresponse, avoiding an extra oEmbed call). - Watch history (collection
history) is per-session — it links your device's cookie to the videoIds you've translated, most recent first, and powers the HISTORY panel in the UI. Re-translating a video hits the cache and re-surfaces it in your history instantly. History cards scroll horizontally inside the panel.
flowchart TD
A[YouTube URL] --> B{In MongoDB cache?}
B -- yes --> H[Return cached transcript]
B -- no --> C{SUPADATA_API_KEY set?}
C -- "yes (production)" --> D[Supadata API<br/>native captions or AI-generated]
C -- "no (local dev)" --> E{Video has captions?}
E -- yes --> F[youtube-transcript-plus]
E -- no --> G[yt-dlp + Whisper<br/>transcribe + translate]
D --> I{English?}
F --> I
G --> J[Cache in MongoDB]
I -- no --> K[DeepL translation]
I -- yes --> J
K --> J
J --> L[React player + synced subtitle overlay]
H --> L
yt-translator/
├── backend/ Express + TypeScript API
│ ├── scripts/ Python helper for the Whisper fallback (local dev only)
│ └── src/
│ └── services/
│ ├── captionService.ts youtube-transcript-plus (local dev)
│ ├── supadataService.ts Supadata SDK (production)
│ ├── whisperService.ts Whisper fallback (local dev)
│ ├── translateService.ts DeepL
│ ├── videoCacheService.ts MongoDB transcript cache
│ └── historyService.ts Per-session watch history
└── frontend/ React + Vite app
cd backend
cp .env.example .env
npm installEdit .env — the values you need depend on how you're running it:
MongoDB (required in all cases — the backend won't start without it):
- Local:
docker run -d -p 27017:27017 --name yt-translator-mongo mongo:7or install MongoDB Community Server directly. - Deployed: use a free MongoDB Atlas
cluster and paste the connection string as
MONGODB_URI.
Supadata (production / Render only):
Set SUPADATA_API_KEY to your key from supadata.ai.
When this variable is set, the backend uses Supadata for all transcript
fetching — no Python, ffmpeg, yt-dlp, or Whisper needed. When it's unset,
the local dev path (youtube-transcript-plus → Whisper) is used instead.
DeepL (both paths, only when a transcript isn't already in English):
Set DEEPL_API_KEY. Free tier keys use api-free.deepl.com; update
DEEPL_API_URL accordingly.
Python / Whisper (local dev only, ignored when SUPADATA_API_KEY is set):
pip install -r scripts/requirements.txtYou'll also need ffmpeg and the yt-dlp CLI on your PATH:
winget install ffmpeg
pip install yt-dlpSet PYTHON_PATH=python (Windows) or python3 (Mac/Linux) and optionally
WHISPER_MODEL (tiny / base / small / medium / large, default base).
Run the backend:
npm run devShould print YT Translator backend running on http://localhost:3001.
In a separate terminal:
cd frontend
npm install
cp .env.example .env
npm run devOpen the printed local URL (usually http://localhost:5173).
If your frontend runs somewhere other than http://localhost:5173, update
FRONTEND_ORIGIN in .env — it must match exactly for the session cookie
to flow cross-domain.
backend/Dockerfile builds a self-contained image with Python, ffmpeg,
yt-dlp, and the Whisper model baked in — mainly useful for testing the
Whisper path in a container, or for deploying to hosts where you want the
Python fallback. For Render deployments using Supadata you don't need
the Python toolchain at all (the Supadata path is pure Node).
docker build -t yt-translator-backend ./backend
docker run -p 3001:3001 --env-file backend/.env yt-translator-backendTwo things to get right when running in a container:
MONGODB_URImust point somewhere reachable from inside the container —localhostthere is the container itself, not your host. An Atlas connection string works; a bare localmongodon your host does not.- On Render, leave
PORTunset — the platform injects it at runtime.
- Paste a YouTube URL into the input and hit TRANSLATE.
- With Supadata (production): resolves in a few seconds for any video
regardless of whether it has native captions — Supadata's
automode handles both. - With Whisper (local dev, no captions): the backend downloads audio
and runs Whisper locally — expect 20 seconds to a couple of minutes
depending on video length and
WHISPER_MODELsize. - Hit HISTORY to see every video you've translated on this device. Cards scroll horizontally. Clicking one reloads instantly from cache.
