Watch YouTube videos with your own subtitles.
You add a video and upload subtitle files for it (SRT or VTT). When you open the video in subbed, the subtitles show on top of the player, in sync. A video can have subtitles in several languages, and you pick which one to show.
subbed is a small self-hosted app: one container, no external services except YouTube itself. Optionally, it can make the subtitles for you: it downloads a video's audio, transcribes it with an AI model and translates the result into the languages you pick (this needs an OpenRouter API key).
You need Docker.
docker run -d \
--name subbed \
-p 3000:3000 \
-e HOST=0.0.0.0 \
-e DATABASE_PATH=/app/data/subbed.db \
-e ADMIN_CREDENTIALS=admin:change-me \
-v ./data:/app/data \
--restart unless-stopped \
ghcr.io/abdusco/subbed:latestThen open:
- Player: http://localhost:3000
- Admin: http://localhost:3000/admin (sign in with the username and password from
ADMIN_CREDENTIALS)
Your videos and subtitles are stored in ./data, so they survive updates and restarts.
Docker Compose
services:
subbed:
image: ghcr.io/abdusco/subbed:latest
ports:
- "3000:3000"
environment:
- HOST=0.0.0.0
- DATABASE_PATH=/app/data/subbed.db
- ADMIN_CREDENTIALS=admin:change-me
volumes:
- ./data:/app/data
restart: unless-stoppedRun it with docker compose up -d.
To update, pull the new image and recreate the container (docker pull ghcr.io/abdusco/subbed:latest, then docker compose up -d or re-run the docker run command).
- Open the admin page and click Add video.
- Paste the YouTube link (or just the video ID). A thumbnail preview confirms you picked the right video, and the title is filled in from YouTube. Edit it if you like; a title you typed is never overwritten.
- On the video's card, click Add subtitle.
- Choose a
.srtor.vttfile, enter the language code (for exampleenortr), and click Upload subtitle.
Shortcuts for step 4:
- Drop a subtitle file anywhere on the admin page, or paste it with Ctrl+V (Cmd+V on a Mac). You can even paste the subtitle text itself.
- If the file name ends in a language code, like
movie.tr.srt, the language is filled in for you.
A video has one subtitle per language: uploading a language again replaces the old one.
To rename a video, click Edit on its card. To remove a subtitle, click the × on its tag. Delete removes the video and all of its subtitles.
Instead of uploading a file, subbed can make the subtitles from the video's audio.
- Set
OPENROUTER_API_KEY(see Configuration) and restart. - Open the menu in the top bar and choose Subtitle generation. Here you can:
- set the transcription model, any OpenRouter speech-to-text model (empty uses the default,
openai/whisper-large-v3-turbo). subbed asks the model to label speakers, so dialogue shows as- Hi/- Hello; a model that doesn't support that, like Whisper, is used without labels, - paste your YouTube cookies, which YouTube sometimes asks for before it lets a download through (see below),
- see the yt-dlp version and update it right away.
- set the transcription model, any OpenRouter speech-to-text model (empty uses the default,
- On a video's card there are two separate actions:
- Transcribe downloads the audio and writes a subtitle in the spoken language. Pick the language, or let the model detect it.
- Translate takes one of the subtitles the video already has, whether you uploaded it or transcribed it, and translates it into the languages you pick. It needs no download, so it also works on a video YouTube won't let you download.
- Follow the job under Generation jobs. A running job shows a progress bar and what it is doing (downloading, transcribing chunk 2 of 5, translating to Turkish). Open a job to read its log; it updates by itself while the job runs.
- To stop a job that is queued or running, click its stop button. The trash button deletes a job and its log; if the job is still running, it is stopped first. Subtitles a job already made stay.
A subtitle in a language the video already has is replaced. A failed translation doesn't stop the others; the job is marked failed and its log says which one. Jobs run one at a time, a video can only have one job queued or running, and a job that was running when the server stopped is marked failed.
Cookies. Export them from your browser as a Netscape cookies.txt (for example with the "Get cookies.txt LOCALLY" extension) and paste the text in. New values replace old ones with the same domain and name, and other cookies stay. They are stored in the database and are never shown again. Use a throwaway Google account: downloading while signed in can get the account flagged. Anyone who can read your ./data folder can read the cookies, so protect it and its backups.
yt-dlp. subbed downloads only the audio, as a .opus or .m4a file. YouTube needs a JavaScript runtime and a challenge solver script for this: the image has node, and subbed lets yt-dlp fetch the solver script from GitHub if it doesn't have it (a pip-installed yt-dlp doesn't; the standalone build does). When running outside Docker, install node or deno. If a yt-dlp is in the PATH, subbed uses it and leaves updating to you. Otherwise it downloads the latest nightly build to bin/yt-dlp next to the database on first start. Update it manually from the admin settings. Set YTDLP_PATH to keep it somewhere else.
Cost and quality. Transcription and translation are paid OpenRouter calls, and a long video means many of them. The result is AI text that viewers will read, so check it before relying on it.
Open the player and paste a YouTube link, or go straight to it:
http://localhost:3000/https://www.youtube.com/watch?v=VIDEO_ID
Share that link with anyone who can reach your server. Add &lang=tr to open it with the Turkish subtitles selected (any language code you have a subtitle for). If the video has no subtitle in that language, the player picks as usual. The link doesn't change the viewer's saved choice.
Below the video you can:
- Switch subtitles between the languages you uploaded, or turn them off. Your choice is remembered.
- Change the size of the subtitle text.
- Go fullscreen with the subtitles still visible.
Press [ or ] to seek to the previous or next cue start in the selected subtitle track.
If a video has no subtitles in your library, it still plays, using YouTube's own captions when available.
The admin page is a progressive web app. In Chrome, Edge or Safari use Install (or Add to Home Screen) to add it to your desktop or phone. It opens quickly and the interface loads even when you're offline, but adding videos and uploading subtitles needs a connection to your server.
Both pages follow your browser language (English or Turkish) and your system's light or dark theme. Change either from the settings button in the top bar. The choice is saved in your browser.
- Formats: SRT and VTT. VTT files are converted to SRT when uploaded.
- Several lines in one subtitle are kept as separate lines, so dialogue like
- Hello/- Hi thereshows on two lines. - Formatting:
<i>and<b>tags, and*italic*/**bold**, are supported. Other tags are removed. - Languages: use short codes such as
en,tr,deorpt-br.
Set these as environment variables.
| Variable | Default | What it does |
|---|---|---|
ADMIN_CREDENTIALS |
(required) | Admin login as username:password. |
SESSION_SECRET |
(random) | Key that signs the admin sign-in cookie. If unset, a random key is used and everyone is signed out on restart. |
DATABASE_PATH |
./subbed.db |
Where the SQLite database is stored. In Docker, use a path inside your mounted volume. |
HOST |
127.0.0.1 |
Network interface to listen on. Use 0.0.0.0 in Docker or to allow other devices to connect. |
PORT |
3000 |
Port to listen on. |
OPENROUTER_API_KEY |
OpenRouter key for generating subtitles. Without it, the Generate button answers that it isn't set. | |
YTDLP_PATH |
<data dir>/bin/yt-dlp |
Where subbed keeps its own yt-dlp when none is in the PATH. |
LISTEN_ADDR |
Full address such as 0.0.0.0:8080. Overrides HOST and PORT. |
|
DEBUG |
0 |
Set to 1 to serve the web files from disk and log in a readable format instead of JSON. For development. |
- Choose a real admin password.
admin:adminis only for trying subbed out. - Use HTTPS if it's reachable from the internet. The admin password is sent when you sign in, so put subbed behind a reverse proxy that provides HTTPS (Caddy, Traefik or nginx). The sign-in cookie is marked
Securewhen the proxy sendsX-Forwarded-Proto: https. - Set
SESSION_SECRETfor sign-ins that survive restarts. Use a long random value, for exampleopenssl rand -hex 32. Changing it signs everyone out. Failed sign-ins are rate limited per client. The admin page can only be installed as an app over HTTPS, apart fromlocalhost. - Treat the stored YouTube cookies like a password. They let anyone who reads the database act as that Google account on YouTube. The API never sends them back.
- The player is public. Anyone who can reach the server can watch any video you added. Adding, changing or deleting videos and subtitles requires the admin login.
subbed is a Go (Echo) server with a SQLite database and a plain HTML, CSS and JavaScript frontend using Alpine.js. The frontend files in static/ are embedded into the binary.
# run locally; web files are served from ./static, so edits show on reload
DEBUG=1 ADMIN_CREDENTIALS=admin:admin go run .
# server tests
go test ./...
# frontend unit tests (Node 20+)
node --test tests/*.mjs- Layout:
static/index.htmlandstatic/player/are the player;static/admin.html,static/admin/andstatic/admin-sw.jsare the admin app.internal/youtubehas the oEmbed client and the yt-dlp wrapper;internal/subtitleturns audio into SRT and translates it (it knows nothing about HTTP or the database). Running it locally needsffmpegandffprobefor the transcription step. - Admin service worker: it caches the admin app shell for offline start. Bump
SHELL_VERSIONinstatic/admin-sw.jswhenever a file it caches changes, or users keep the old version. Open tabs then show a "new version is ready" banner. - Debug mode and the service worker: with
DEBUG=1, edits can look ignored until you bump the version, clear the site's service worker, or tick "Update on reload" in your browser's developer tools.
HTTP API
Public:
GET /api/video?url=https://www.youtube.com/watch?v=VIDEO_ID
Returns the video and all of its subtitles (with their full content):
{
"video": { "id": 1, "original_url": "VIDEO_ID", "title": "Video title" },
"subtitles": [
{
"id": 1,
"video_id": 1,
"language": "en",
"type": "srt",
"content": "..."
}
]
}Errors are returned as { "error": "message" } with a matching HTTP status.
Sign in:
POST /api/login: JSON body{ "username": "...", "password": "..." }. Answers204and sets asubbed_sessioncookie, valid for 30 days.POST /api/logout: clears the cookie.
Admin (needs the session cookie; without it the API answers 401, and /admin redirects to /login):
GET /api/admin/videos: list videos with their subtitles (without content)GET /api/admin/oembed?url=...: look up a video's title through YouTube's oEmbed endpoint, as{ "title": "...", "author": "..." }POST /api/admin/videos: add a video, JSON body{ "url": "...", "title": "..." }PATCH /api/admin/videos/:id: change a video's title, JSON body{ "title": "..." }DELETE /api/admin/videos/:id: delete a video and its subtitlesPOST /api/admin/subtitles: upload a subtitle, multipart form withvideo_id,language,type(srtorvtt) andfileDELETE /api/admin/subtitles/:id: delete a subtitle (uploading a language again replaces that language's subtitle)POST /api/admin/videos/:id/transcribe: queue a job that downloads the audio and transcribes it into a subtitle, JSON body{ "source_language": "en" }.source_languageis optional (empty detects it). Answers202with the job;409if the video already has a queued or running job,503ifOPENROUTER_API_KEYis not set.POST /api/admin/videos/:id/translate: queue a job that translates one of the video's existing subtitles, JSON body{ "source_language": "en", "languages": ["tr", "de"] }. Both are required, and the video must have a subtitle insource_language(400otherwise). Same202,409and503answers.GET /api/admin/jobs: the 50 newest jobs, each withkind(transcribeortranslate),status(queued,running,done,failedorcanceled),progress(percent),stage(what a running job is doing),languages,source_language,errorandcreated_atPOST /api/admin/jobs/:id/cancel: stop a queued or running job (it answers once the job has ended;409if the job is already over)DELETE /api/admin/jobs/:id: delete a job and its log, stopping it first if it is activeGET /api/admin/jobs/:id/logs?after=LOG_ID: the job and the log lines after the one with that id ({ "job": {...}, "logs": [{ "id", "at", "level", "message" }] }), so a page can pollGET /api/admin/cookies: how many cookies are stored and for which domains, as{ "count": 2, "domains": ["youtube.com"] }; never the valuesPUT /api/admin/cookies: JSON body{ "cookies": "<text of a Netscape cookies.txt>" }; upserts by domain and nameDELETE /api/admin/cookies: remove all cookiesGET /api/admin/settings,PUT /api/admin/settings: settings includetranscribe_modelandtranslate_model, plus read-onlytranscribe_model_defaultandtranslate_model_default. PUT changes only supplied model fields; an empty model restores its built-in default.GET /api/admin/yt-dlp:{ "installed": true, "version": "..." };POST /api/admin/yt-dlp/updateupdates it