Self-hosted torrent streaming web app. Self-hosters point it at indexers they have access to, and stream content they have the rights to access — sequentially, in the browser, with watch-state and subtitles.
Use responsibly. This is a generic streaming engine — you supply the indexers, you supply the content. See DISCLAIMER.md.
Status: v1 complete. All backend services and routes implemented, frontend pages wired, dev-server smoke test passing (auth → profile CRUD → SQLite persistence). Untested end-to-end against real torrents; see Verification below.
- Lookup metadata for movies and shows via TMDB.
- Query your configured Prowlarr indexers for matching torrents, rank them, and start streaming the head of the file. No "download finishes, then watch" step.
- Per-profile watch state, resume where you left off.
- Auto-fetch subtitles from OpenSubtitles or Subdl.
- Smart playback: direct-play for browser-friendly content, ffmpeg-transcode for HEVC/MKV/etc.
- Stream-then-delete — the disk does not fill up.
| Layer | Choice |
|---|---|
| Language | TypeScript on Node.js 22 |
| Backend | Fastify, WebTorrent, ffmpeg (system binary) |
| Frontend | React 18, Vite, Tailwind CSS 3 |
| DB | SQLite via better-sqlite3 (WAL mode) |
| Indexers | Prowlarr sidecar container |
| Metadata | TMDB API v3 |
| Subtitles | OpenSubtitles v2 + Subdl |
| Player | HTML5 <video> + hls.js for transcoded streams |
cp .env.example .env
# Edit .env — at minimum set APP_PASSWORD, COOKIE_SECRET, TMDB_API_KEY.
docker compose up --buildThen:
- Open http://localhost:3000 — log in with
APP_PASSWORD. - Pick or create a profile.
- Configure Prowlarr (one-time):
- On the server itself, open http://127.0.0.1:9696. The Prowlarr port is intentionally bound to
127.0.0.1only and is not reachable from the network — see Security. - Add at least one indexer (Settings → Indexers → Add Indexer). 1337x and YTS are free and don't require credentials.
- Copy Prowlarr's API key (Settings → General → API Key) into
.envasPROWLARR_API_KEY.
- On the server itself, open http://127.0.0.1:9696. The Prowlarr port is intentionally bound to
docker compose restart appso the new env var is picked up.
You'll need a Prowlarr instance reachable separately. Easiest: run the Prowlarr container alone, point PROWLARR_URL at http://127.0.0.1:9696.
npm install
cp .env.example .env
# Edit .env
npm run devThe dev script runs:
- Fastify backend on
:3000 - Vite dev server on
:5173(proxies/api/*to:3000)
Open http://localhost:5173.
| Service | Required? | How to get one |
|---|---|---|
| TMDB | Yes | Free at https://www.themoviedb.org/settings/api |
| OpenSubtitles | Recommended | https://www.opensubtitles.com/ — needs all three of OPENSUBS_API_KEY, OPENSUBS_USERNAME, OPENSUBS_PASSWORD. Search works with just the API key, but downloads fail without user credentials. Free tier is 20 downloads/day per user; the app caches aggressively (per <tmdb>-<season>-<episode>-<lang>) so switching torrent variants of the same content reuses the same subtitle file. |
| Subdl | Optional fallback | https://subdl.com/api — exact terms vary; verify before relying on it as primary. Set SUBTITLE_PRIMARY=subdl to flip the priority. |
| Prowlarr API key | Yes | Generated by Prowlarr itself — see Quick start step 3. |
The default port-binding strategy is built around the assumption that you'll reverse-proxy or tunnel the app's port (:3000) but keep Prowlarr local-only.
- App port (
:3000, configurable viaHOST_APP_PORT) is bound to0.0.0.0. Put it behind Cloudflare Tunnel, Tailscale, a reverse proxy, etc. The app's gate isAPP_PASSWORD+ login rate limit (5 attempts / 15 min per IP, with exponential backoff after lockout). - Prowlarr port (
:9696, configurable viaHOST_PROWLARR_PORT) is bound to127.0.0.1only. Do NOT add it to a Cloudflare Tunnel / reverse proxy / port forward without first enabling Prowlarr's built-in authentication. Prowlarr ships with no auth.
If you need remote Prowlarr access:
- Enable Prowlarr's built-in authentication (Settings → General → Authentication).
- Then change
docker-compose.ymlto bind it to0.0.0.0and add it to your tunnel.
| Var | Default | Notes |
|---|---|---|
MAX_CONCURRENT_TRANSCODES |
1 |
Caps simultaneous ffmpeg processes. Direct-play streams don't count against this. On a multi-core server (Xeon E5-2420 v2 6c/12t and similar), 2 is reasonable for two simultaneous viewers; higher will likely thrash. Monitor CPU after raising. |
HOST_APP_PORT |
3000 |
Override if :3000 collides with another service. |
HOST_PROWLARR_PORT |
9696 |
Override if :9696 collides. |
SUBTITLE_PRIMARY |
opensubtitles |
opensubtitles or subdl. The other becomes automatic fallback. |
Real-world deployment patterns and gotchas (Portainer + GitOps, sharing an existing VPN gateway, firewall rules for LAN bypass, container restart ordering, anti-bot indexer proxies, etc.) live in docs/deployment.md. Read that before your first production deploy.
A 20-step end-to-end verification plan lives in the implementation spec (path ~/.claude/plans/heyyy-i-wana-work-twinkling-giraffe.md if you've kept the planning artifacts). Critical checks:
docker compose up --buildbrings both containers up cleanly.- Login + profile picker + create profile works.
- Prowlarr UI loads at
http://127.0.0.1:9696from the server (NOT from a remote browser — that's by design). - Search a known title (any well-seeded torrent your indexers return — Big Buck Bunny, Sintel, and other public-domain titles work as a clean smoke test) → click play → buffer state shows peers → video plays.
- Pause, refresh, "Continue Watching" appears → click → resumes near the same timestamp.
- Tab-close cleanup: force-close the player tab (do not navigate away). Within ~10s,
/data/torrentsshould be empty and thestream_sessionstable row removed. Verifiesnavigator.sendBeacon-driven teardown. - Stall recovery: throttle peers → after 30s of <100 KB/s and buffer <10s, a banner appears: "This source is slow. Try another?" — does NOT auto-switch.
- Concurrency cap: with
MAX_CONCURRENT_TRANSCODES=1, two HEVC streams from different profiles → second shows "Queued" until the first frees the slot. Direct-play streams don't queue. - Login rate limit: 6 wrong passwords from one IP returns 429 on the 6th.
- Prowlarr is NOT internet-reachable:
curl http://<host>:9696from another machine gets connection refused.
src/
shared/types.ts — types shared by server + web
server/
env.ts — zod-validated env
db.ts — SQLite (WAL), migration runner, backup() helper
auth.ts — secure-session, login + rate limit
routes/ — auth, profiles, search, media, torrents,
stream, subtitles, watch, settings
services/
tmdb.ts — TMDB v3 client w/ rate limit + retry
prowlarr.ts — indexer aggregator client
torrent-ranker.ts — score torrents by seeders/quality/codec
torrent-engine.ts — WebTorrent wrapper, sequential streaming,
manual range responses (no createServer)
transcoder.ts — ffprobe + 6-pipeline decision matrix
transcode-queue.ts — semaphore for ffmpeg concurrency
stall-detector.ts — speed/peer/buffer watchdog (prompts user,
never auto-switches)
session-cleaner.ts — reaps zombie streams via heartbeat timeout
subtitles/ — SubtitleProvider interface, OpenSubtitles,
Subdl, cache-aware fetcher
repos/ — prepared-statement wrappers per table
web/
main.tsx, App.tsx — entry + router
lib/api.ts — typed fetch client
lib/profile-store.ts — Zustand store for current profile
components/ — AppShell, PasswordGate, PosterGrid,
FeaturedTile, EpisodeList, SourcePicker,
VideoPlayer (sendBeacon teardown), etc.
pages/ — Login, ProfilePicker, Home, Search,
Movie, Show, Player, Settings
styles/globals.css — Tailwind base + reduced-motion
migrations/0001_init.sql — schema
docker-compose.yml — app + prowlarr
Dockerfile — multi-stage (build deps for better-sqlite3
in build stage; ffmpeg + Node in runtime)
Tracked in the implementation spec. Highlights: cache+evict (currently stream-then-delete), private-tracker seeding, Sonarr/Radarr automation, Chromecast/AirPlay, native mobile apps, per-profile passwords, Trakt sync.
MIT License. See also DISCLAIMER.md for what this project is and isn't intended for.