Self-hosted subtitle extraction and translation for Jellyfin.
Scan your media library, extract embedded subtitles, translate to any language — fully automated.
⚡ Built as a quick homelab project with AI assistance. Works great with Jellyfin, Radarr, and Sonarr.
streamrip-subs is a lightweight API server that runs in Docker alongside your media stack. It:
- Scans your media library and indexes every video file with its embedded subtitle streams
- Extracts text-based subtitle streams to
.srtfiles using ffmpeg — named exactly how Jellyfin expects - Translates extracted subtitles to any language using Google Translate (free, no API key needed)
- Serves a web dashboard so you can do all of the above without touching a terminal
It runs a nightly auto-scan, supports Jellyfin webhooks for instant detection of new media, and tracks all jobs with progress bars and cancellation support.
- Web UI dashboard — library overview, subtitle status per file, one-click extract and translate, live job progress
- Async library scan — concurrent ffprobe scanning with configurable parallelism
- Smart extraction — correct Jellyfin filename format (
movie.en.srt,movie.en.sdh.srt,movie.en.forced.srt) - Codec detection — automatically identifies image-based subtitles (PGS/VOBsub/DVB) and skips them gracefully with a clear warning instead of silently failing
- Multi-language translation — translate to Sinhala, Tamil, Hindi, French, or any Google Translate language
- Batch translation with retry — translates in batches of 40 blocks, with recursive batch-splitting on count mismatch and exponential backoff on network errors
- Job system — every scan/extract/translate is a tracked job with status, progress, error reporting, and kill/cancel support
- Duplicate prevention — won't start a second job if one is already running for the same file
- Auto-scan scheduler — nightly cron-style scan via APScheduler, configurable via env vars
- Jellyfin webhook — receives Jellyfin "Item Added" events and triggers a scan automatically
- Auto-cleanup — removes DB entries for files deleted from disk on every scan
- Job purge — one-click cleanup of old completed jobs via API or UI
- SQLite + WAL — fast, concurrent, zero-configuration database
| Component | Technology |
|---|---|
| API server | FastAPI + Uvicorn |
| Database | SQLite with WAL mode |
| Video probing | ffprobe (part of ffmpeg) |
| Subtitle extraction | ffmpeg |
| Translation | Google Translate (free unofficial API) |
| HTTP client | httpx (async, retry + backoff) |
| Scheduler | APScheduler |
| Container | Docker |
# 1. Pull the image
docker pull gwa99a9/streamrip-subs:latest
# 2. Download the compose file
curl -O https://raw.githubusercontent.com/gwa99a9/streamrip-subs/main/docker-compose.yml
# 3. Edit your media path and timezone (see Configuration below)
nano docker-compose.yml
# 4. Start
docker compose up -dgit clone https://github.com/gwa99a9/streamrip-subs.git
cd streamrip-subs
# Edit docker-compose.yml with your paths
nano docker-compose.yml
docker compose up -d --buildhttp://localhost:5200
Click Scan library in the dashboard, or:
curl -X POST http://localhost:5200/scanEdit docker-compose.yml — the only required changes are the volume paths:
volumes:
- /your/media/path:/media:rw # your Jellyfin media folder
- /your/appdata/streamrip-subs:/data # where the database is stored| Variable | Default | Description |
|---|---|---|
TZ |
Europe/London |
Timezone for logs and scheduler |
SCAN_CONCURRENCY |
4 |
Parallel ffprobe processes during scan |
SCAN_CRON_HOUR |
3 |
Hour for nightly auto-scan (24h) |
SCAN_CRON_MINUTE |
0 |
Minute for nightly auto-scan |
DEFAULT_TRANSLATE_LANG |
en |
Default translation target language code |
PYTHONUNBUFFERED |
1 |
Real-time log output |
streamrip-subs/
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .gitignore
├── README.md
├── CONTRIBUTING.md
├── .github/
│ ├── workflows/
│ │ └── docker.yml # auto-build and push to Docker Hub on push/tag
│ └── ISSUE_TEMPLATE/
└── backend/
├── app.py # FastAPI routes, job management, web UI, scheduler, webhook
├── scanner_async.py # Async file walker, ffprobe, ffmpeg extraction, codec detection
├── translator.py # SRT parser, Google Translate batching with recursive split fallback
├── db.py # SQLite schema, migrations, helpers
└── requirements.txt # Pinned Python dependencies
# 1. Scan your library (or let the nightly auto-scan do it)
curl -X POST http://localhost:5200/scan
# 2. Find a movie's file_id
curl "http://localhost:5200/search?q=MovieName"
# → [{"id": 42, "path": "/media/movies/MovieName/..."}]
# 3. Check what subtitle streams it has
curl http://localhost:5200/file/42/subtitle-status
# 4. Extract English subtitles
curl -X POST "http://localhost:5200/extract-sub/42?lang=en"
# → {"job_id": "abc-123", "reused": false}
# 5. Poll the job
curl http://localhost:5200/jobs/abc-123
# → {"status": "done (2 extracted)", ...}
# 6. Translate to Sinhala
curl -X POST "http://localhost:5200/translate-sub/42?source_lang=en&target_lang=si"
# 7. Poll translation progress
curl http://localhost:5200/jobs/<job_id>streamrip-subs can automatically scan when new media is added to Jellyfin:
- In Jellyfin: Dashboard → Plugins → Catalog → install the Webhook plugin
- Dashboard → Webhooks → Add Generic Destination
- URL:
http://<your-host>:5200/webhook/jellyfin - Enable event: Item Added
- Save
| Method | Endpoint | Description |
|---|---|---|
GET |
/files?limit=100&offset=0 |
List all indexed video files |
GET |
/file/{file_id} |
Get details for a single file |
GET |
/file/{file_id}/subtitle-status |
Subtitle streams, disk status, job history |
GET |
/subs/{file_id} |
List subtitle streams for a file |
GET |
/search?q=... |
Search files by path substring |
| Method | Endpoint | Description |
|---|---|---|
POST |
/scan |
Start a full library scan |
POST |
/extract-sub/{file_id}?lang=en |
Extract subtitle stream to .srt |
POST |
/translate-sub/{file_id}?source_lang=en&target_lang=si |
Translate .srt to target language |
| Method | Endpoint | Description |
|---|---|---|
GET |
/jobs |
List recent jobs (optional ?status=running) |
GET |
/jobs/{job_id} |
Get status and progress of a job |
POST |
/jobs/{job_id}/cancel |
Cancel and kill a running job |
DELETE |
/jobs/purge?older_than_days=7 |
Delete old completed/errored jobs |
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Health check + next auto-scan time |
GET |
/scheduler |
Auto-scan schedule info |
GET |
/stats |
File, subtitle, and job counts |
GET |
/languages |
Supported translation language codes |
GET |
/docs |
Swagger UI (interactive API explorer) |
POST |
/webhook/jellyfin |
Jellyfin webhook receiver |
| Codec | Name | Notes |
|---|---|---|
subrip |
SRT | Perfect — direct copy |
ass / ssa |
ASS/SSA | Text extracted, styling stripped |
webvtt |
WebVTT | Text extracted, cue tags stripped |
mov_text |
MP4 text | Apple/iTunes — works fine |
microdvd |
MicroDVD | Frame timing converted |
ttml / dfxp |
TTML | Text extracted, XML styling stripped |
| Codec | Name | Common source |
|---|---|---|
hdmv_pgs_subtitle |
PGS | Blu-ray rips |
dvd_subtitle |
VOBsub | DVD rips |
dvb_subtitle |
DVB | TV broadcast rips |
xsub |
XSUB | Old DivX files |
Image-based streams are detected automatically. The UI shows a red image only badge and disables the Extract button with a tooltip. The job log shows a clear skip message rather than a silent ffmpeg failure.
For OCR conversion of PGS/VOBsub:
| Type | Filename |
|---|---|
| Regular | MovieName.en.srt |
| SDH (hearing impaired) | MovieName.en.sdh.srt |
| Forced (foreign parts) | MovieName.en.forced.srt |
| Translated | MovieName.si.srt |
Any Google Translate language code works. Common ones:
| Code | Language | Code | Language |
|---|---|---|---|
si |
Sinhala | ja |
Japanese |
ta |
Tamil | ko |
Korean |
hi |
Hindi | zh |
Chinese |
fr |
French | ar |
Arabic |
de |
German | pt |
Portuguese |
es |
Spanish | ru |
Russian |
Full list: GET /languages
# View logs (filter polling noise)
docker logs streamrip-subs -f 2>&1 | grep -v "GET /jobs\|GET /stats\|GET /health"
# Rebuild after code changes
docker compose up -d --build
# Purge old jobs
curl -X DELETE "http://localhost:5200/jobs/purge?older_than_days=7"
# Wipe database and start fresh
docker stop streamrip-subs
rm /your/appdata/streamrip-subs/media.db
docker start streamrip-subsPermissionError: /app/app.py
chmod 644 backend/*.py && chmod 755 backend/sqlite3.OperationalError: no such column: error
docker exec -it streamrip-subs sqlite3 /data/media.db "ALTER TABLE jobs ADD COLUMN error TEXT;"
docker restart streamrip-subsScan finds 0 files
docker exec streamrip-subs ls /media # check the volume mountExtract button disabled for all files
Run a fresh scan — codec detection populates on scan and the buttons re-enable automatically.
Blu-ray files show many streams but nothing extracts
Blu-ray rips use PGS (image-based) subtitles — see the compatibility table above.
# Build and tag
docker build -t gwa99a9/streamrip-subs:latest .
docker build -t gwa99a9/streamrip-subs:1.0.0 .
# Push
docker push gwa99a9/streamrip-subs:latest
docker push gwa99a9/streamrip-subs:1.0.0Automated builds via GitHub Actions are configured in .github/workflows/docker.yml — every push to main builds and pushes automatically. Add these secrets to your GitHub repo:
DOCKERHUB_USERNAME→gwa99a9DOCKERHUB_TOKEN→ your Docker Hub access token
PRs welcome! See CONTRIBUTING.md for dev setup and areas that need work most.
MIT — do whatever you want with it.
Built for homelab use · Works with Jellyfin · No cloud required
Docker Hub · Issues