English · Português
Lunio is a web video player built for watching together. Paste a stream link, upload a file or open a YouTube/Google Drive video, create a room, and everyone in it watches in sync — with chat, ASS subtitles rendered like a desktop player, alternate audio tracks and chapters. It also runs as a Discord Activity (in development).
- Rooms (Watch Party) — create a room from any source and share an 8-character code or link. The Host controls play, pause, seek and speed; everyone else follows with continuous drift correction. Chat, member list, Host transfer, kick and ban. Skipping the opening/ending: the Host skips right away and everyone else votes (the room skips when more than half vote); an optional setting skips openings and endings automatically after 5 s (any key cancels; only for the Host or when watching alone, since viewers always vote by hand).
- Queue (playlist) — inside a room, anyone can add links to a queue; the Host reorders it, plays an item now, and the next one starts by itself when the video ends.
- Video sources
- Direct stream links: Stremio, TorBox, and other debrids,
.mkv/.mp4/.webm/HLS, proxied through the local server (HTTP Range, CORS bypass). - File upload (up to 50 GB) — the room exists while the file uploads. Watching alone plays the file locally without uploading.
- YouTube videos and lives.
- Google Drive files shared as "Anyone with the link".
- Direct stream links: Stremio, TorBox, and other debrids,
- MKV support — audio and subtitle tracks are detected with FFmpeg. Switching to another audio track plays it through a shared HLS stream: the video is copied (not re-encoded) and the chosen audio becomes AAC in ~6 s segments cached on the server, so everyone in the room who picks the same audio shares one FFmpeg, and seeking takes a fraction of a second. Files without a Matroska index or without H.264 video fall back to an on-the-fly fMP4 remux.
- Subtitles — embedded ASS/SSA rendered with libass (JASSUB, WebAssembly) including embedded fonts; external
.ass,.srt(converted to WebVTT) and.vttby file or URL; sync offset and font size. - Player — chapter-segmented timeline, "Skip intro/outro" for detected chapters, playback speed, audio delay, volume boost, aspect modes, picture-in-picture, resume where you left off, keyboard shortcuts, and a link that opens at a given moment (
?room=CODE&t=90,t=1m30sort=1:30; honored by the Host or when watching alone). The room panel's Share menu has a "Link at this moment" option that copies one for where the video is now. - Catalog (experimental) — browse and search movies and series (metadata from Stremio's public Cinemeta addon), with seasons, episodes and a personal list. The catalog only provides metadata: to watch, you still pick a video source.
- Status page — server health (FFmpeg, Python, Redis), rooms and people online, ping and speed to the server, uptime, version, cache, uploads and disk.
- Languages — Portuguese and English; follows the browser language, switchable on the home screen.
| Home | Player |
|---|---|
![]() |
![]() |
Video in the screenshots: Sintel © Blender Foundation, CC BY 3.0.
| Tool | Needed for | Notes |
|---|---|---|
| Node.js 20+ | Everything | |
| FFmpeg | Track detection, audio track switching, subtitle extraction fallback | FFMPEG_PATH, then ffmpeg on the PATH, then known Windows paths (mpv, C:\ffmpeg) |
| Python 3.10+ | Fast embedded subtitle extraction (mkv_extractor) |
Standard library only. Found as PYTHON_PATH, python3, python or py -3. Without it, subtitles fall back to FFmpeg (slower) |
npm install
npm run dev # development (hot reload)
npm start # production: builds, then runs the production server (no Vite)Open http://localhost:3000. The dev server also listens on your local network, so friends on the same network can join using your machine's IP.
Copy .env.example to .env. Locally nothing is required (only the Discord Activity needs the first two); on a server set at least SESSION_SECRET and TRUST_PROXY. Variables already in the environment (systemd, Docker) win over the file.
| Variable | Description |
|---|---|
VITE_DISCORD_CLIENT_ID |
Discord application ID used by the Activity |
DISCORD_CLIENT_SECRET |
Discord application secret, used by /api/token for the OAuth2 exchange |
SESSION_SECRET |
Signs the session tokens. Without it a new one is generated at each start |
ALLOWED_ORIGINS |
Extra origins (comma separated) allowed to call the API and open the WebSocket. Default: same origin + the Discord Activity |
TRUST_PROXY |
1 behind Caddy/nginx: the client IP (used for rate limits) comes from X-Forwarded-For |
FFMPEG_PATH |
(optional) FFmpeg executable. Otherwise ffmpeg on the PATH, then known Windows paths |
PYTHON_PATH |
(optional) Python 3.10+ for the subtitle extractor. Otherwise python3, python, py -3 |
MAX_UPLOAD_DISK_GB / MAX_UPLOAD_FILE_GB |
Disk quota for uploads (default 20) and size limit per upload (default 50) |
UPLOAD_TTL_HOURS |
Hours an upload is kept after its room closes (default 24) |
REDIS_URL |
(optional) A local Redis, e.g. redis://127.0.0.1:6379. Rooms then survive a restart and the upload index is kept in it. See Redis |
ROOM_STATE_TTL_HOURS / ROOM_HOST_GRACE_SECONDS |
With Redis: how long a room's saved state is valid (default 24) and how long the previous host has to come back after a restart before someone else takes over (default 60) |
UPLOAD_IDLE_HOURS |
Hours an upload in an open room may go unread before it is removed (default 24) |
MIN_FREE_DISK_GB |
Free space to keep on the machine's disk; uploads are refused below it (default 5) |
MAX_CACHE_GB |
Limit for .cache/ (default 10, includes the HLS segments); the least recently used is deleted first |
MAX_FFMPEG_PROCS |
FFmpeg/Python processes at once (default 8) |
FFMPEG_THROUGHPUT_MBPS |
Starting guess (default 40) of how fast FFmpeg reads a CDN, used to choose between the subtitle extractor and reading the whole file. After real reads, the server uses the measured average per CDN host |
ALLOW_PRIVATE_URLS |
Local tests only: lets the proxy reach private network addresses. Keep empty in production |
DISABLE_HMR |
(optional) true disables Vite hot reload |
| Command | What it does |
|---|---|
npm run dev |
Development: app + API + WebSocket on port 3000, with hot reload |
npm start |
Production: npm run build, then npm run serve |
npm run serve |
Production server (src/server/main.ts): Express serving dist/ + API + WebSocket on PORT (default 3000), without Vite. Needs a previous npm run build |
npm run build |
Builds the frontend into dist/ |
npm run preview |
Vite's preview of an existing dist/ with the API plugged in (handy for quick checks; production uses serve) |
npm run lint |
Type-checks the project (tsc --noEmit) |
npm test |
Vitest: room server (join/leave, Host authority, kick/ban, empty rooms) and API routes (SSRF, uploads/quota, Range, subtitle errors). Needs FFmpeg for the media routes. Tests against a real Redis run only with REDIS_TEST_URL set |
npm run test:e2e |
Builds, then Playwright E2E against the production server with a local FFmpeg-generated MKV (two users in one room, sync, kick/ban, alternate audio, Host reload). First time: npx playwright install chromium |
python -m pytest tests |
Runs the mkv_extractor tests |
The backend lives in
src/server/and is independent of Vite:npm run serveruns it as a plain Express app, andnpm run dev/npm run previewjust plug the same router into Vite. In development, editing a server file restarts the dev server (and drops open rooms); in production nothing watches the code. WithREDIS_URLset, rooms survive a restart or deploy (host, bans, queue, video and position are restored; the previous host has 60 s to come back before someone else takes over); without it, rooms drop on a restart.
Browser (React + Vidstack)
│ HTTP ── /api/proxy, /api/tracks, /api/subtitle, /api/upload …
│ WebSocket ── /api/ws (rooms, sync, chat)
▼
Backend: Express router (src/server/app.ts), served by src/server/main.ts in production
or plugged into the Vite dev/preview server (vite.config.ts)
├─ media proxy: HTTP Range passthrough, FFmpeg fMP4 remux for alternate audio
├─ track inspection and subtitle/font extraction (FFmpeg + mkv_extractor)
├─ uploads (.uploads/) and Google Drive resolver
└─ room server (src/server/roomServer.ts)
| Route | Description |
|---|---|
GET /api/proxy?url= |
Streams a remote video (Range support). With audio= it remuxes to fMP4 with that audio track |
POST /api/hls/start, GET /api/hls/<id>/<audio>/index.m3u8, GET /api/hls/<id>/<audio>/<n>.ts |
Shared HLS for an alternate audio track: playlist and ~6 s segments generated on demand and cached (MKV with Cues + H.264 only; otherwise start answers 501 and the player uses the remux) |
GET /api/tracks?url= |
Audio, subtitle, chapter and font tracks of a file |
GET /api/subtitle?url=&track= |
Extracts an embedded subtitle (cached in .cache/subtitles) |
GET /api/font?url=&track= |
Extracts an embedded font. Fonts are stored once per content (hardlinks in .cache/fonts/_shared), so episodes of the same group share them |
GET /api/fontmap?url= |
The names each embedded font can be requested by (family, full, PostScript). The player loads only the fonts the subtitle uses instead of all ~24 attached to the MKV |
GET /api/resolve?url= |
Resolves Torrentio/debrid redirects to the final CDN URL |
GET /api/upstream?url= |
Whether the video's provider (or its CDN) rate-limited the server just now (429). The player asks on a playback error to say "the provider limited the requests". Makes no request to the provider |
GET /api/room?id= |
Whether a room exists or was closed |
POST /api/upload/start, PUT /api/upload/<id>?offset=, POST /api/upload/<id>/finish, DELETE /api/upload/<id> |
Chunked, resumable upload (up to 50 GB) into .uploads/. Chunks are 64 MB by default so each request stays under proxies that cap the body (Cloudflare's free plan allows 100 MB) |
POST /api/uploads/claim, POST /api/uploads/status |
Reopen a recent upload in a new room, and check whether it is still on the server |
GET /api/uploads/<id>/<name> |
Serves an uploaded file with Range support |
GET /api/drive?url= |
Resolves a Google Drive share link to a playable URL |
POST /api/session |
Session token for watching alone (room members get theirs over the WebSocket). The media routes require it |
GET /api/status |
Whether FFmpeg and Python were found (versions only), rooms and people online, uptime, cache and Redis state |
POST /api/token |
Discord OAuth2 code exchange |
WS /api/ws |
Rooms: membership, playback sync, chat, moderation |
Empty rooms are closed about 30 seconds after the last person leaves (EMPTY_ROOM_TTL_MS in src/server/roomServer.ts). A room restored from Redis that nobody comes back to closes after the host's 60 s grace plus those 30 seconds.
A small VPS is enough. Lunio runs as a single Node process (npm run serve); Caddy sits in front for HTTPS and WebSocket, and systemd keeps it running. Replace lunio.example.com with your domain (its DNS must point to the server) and open ports 80 and 443.
1. Install Node 22, FFmpeg, Python and Caddy
sudo apt update && sudo apt install -y ffmpeg python3 git curl debian-keyring debian-archive-keyring apt-transport-https
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs
# Caddy: https://caddyserver.com/docs/install#debian-ubuntu-raspbian
sudo apt install -y caddyUbuntu's python3 is 3.12 (Lunio needs 3.10+) and is detected automatically; there is no python command and none is needed.
2. Get the code and configure
sudo useradd --system --create-home --home-dir /opt/lunio --shell /usr/sbin/nologin lunio
sudo -u lunio git clone https://github.com/gabszap/Lunio.git /opt/lunio/app
cd /opt/lunio/app
sudo -u lunio npm ci
sudo -u lunio npm run build
sudo -u lunio cp .env.example .env
sudo -u lunio nano .envIn .env set at least:
SESSION_SECRET=<output of: openssl rand -hex 32>
TRUST_PROXY=1 # Caddy is in front: use the real client IP
ALLOWED_ORIGINS= # only if the site is served from other origins too
VITE_DISCORD_CLIENT_ID=... # only for the Discord Activity
DISCORD_CLIENT_SECRET=...Leave ALLOW_PRIVATE_URLS empty. It exists only for local tests and turns off the protection that keeps the server from reaching your internal network.
3. Build and systemd service — /etc/systemd/system/lunio.service
[Unit]
Description=Lunio
After=network.target
[Service]
User=lunio
WorkingDirectory=/opt/lunio/app
ExecStart=/usr/bin/npm run serve
Restart=always
RestartSec=3
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload && sudo systemctl enable --now lunio
journalctl -u lunio -f # at startup it logs which FFmpeg and Python it foundWorkingDirectory matters: .uploads/, .cache/, dist/ and .env live there. npm run serve does not build, so run npm run build once after each git pull (restarts are then instant). PORT and HOST in .env change where it listens (default 0.0.0.0:3000).
4. Caddy (HTTPS + WebSocket) — /etc/caddy/Caddyfile
lunio.example.com {
reverse_proxy 127.0.0.1:3000 {
flush_interval -1
}
}sudo systemctl reload caddyCaddy gets the certificate by itself and proxies WebSocket with no extra configuration, so the browser connects to wss://lunio.example.com/api/ws. flush_interval -1 sends the alternate-audio stream to viewers as FFmpeg produces it, with no buffering.
5. Check
Open https://lunio.example.com, go to Status: Server, FFmpeg and Python should all be green. Then create a room, open it in a second browser, and switch an audio track.
Updating: cd /opt/lunio/app && sudo -u lunio git pull && sudo -u lunio npm ci && sudo -u lunio npm run build && sudo systemctl restart lunio. Without REDIS_URL, restarting closes open rooms; with it, they come back.
Lunio runs fine without Redis. With it, rooms survive a restart or deploy: the host, bans, queue, current video and position are saved and restored before the server accepts connections, and the previous host gets 60 s to come back (anyone reconnecting sooner joins as a viewer). Without it, a restart closes every open room. Chat and who is connected are never saved. Redis also keeps the upload index (last read, rooms, closed marks), which is rebuilt from disk on every start.
Redis is never the source of truth and never blocks the server: each call has a 300 ms ceiling and falls back silently (one log warning per minute). It holds no secrets worth persisting, so run it in RAM only and bound to localhost:
This uses Docker; without it, sudo apt install redis-server works too, as long as it listens only on 127.0.0.1 and you disable persistence (save "", appendonly no).
docker run -d --name lunio-redis --restart unless-stopped -p 127.0.0.1:6379:6379 redis:8-alpine \
redis-server --save "" --appendonly no --maxmemory 128mb --maxmemory-policy volatile-ttlThen add REDIS_URL=redis://127.0.0.1:6379 to /opt/lunio/app/.env and sudo systemctl restart lunio. Status shows "Redis: connected" when it works. Never expose the port publicly.
Everything runs on Windows without extra setup: npm install then npm run dev. FFmpeg is looked up in this order: FFMPEG_PATH → ffmpeg on the PATH → C:\Program Files\mpv\ffmpeg.exe → C:\ffmpeg\ffmpeg.exe. Python: PYTHON_PATH → python3 → python → py -3. The server prints what it found when it starts, and Status shows it too. To run a second copy beside npm run dev, use npx vite --port=3001 --strictPort.
src/
App.tsx Screens: Home (catalog, room, status) and player
components/
home/ Room menu and source steps (stream, upload, YouTube, Drive, join)
catalog/ Catalog, search and title details
VideoPlayer.tsx Player composition (Vidstack): shared state, hook order, rendering
player/ The player's hooks (room sync, alternate audio, subtitles, controls, shortcuts…) and overlays
PlayerControls.tsx Controls bar and menus
WatchPartyPanel.tsx Chat and participants
ui.tsx Shared UI primitives (design system)
lib/ Sync client, subtitles, media helpers, catalog, Discord
server/ Backend: app.ts (API router), main.ts (production entry), routes/ (one file per
endpoint), media/ (FFmpeg remux, tracks, subtitles, cache), roomServer.ts, roomStore.ts (rooms in Redis), security
mkv_extractor/ Python extractor for subtitles inside remote MKV files (HTTP Range)
public/ Static files: JASSUB (libass), fallback fonts, Sintel sample
assets/ README images (readme/) and Discord art (discord/)
DESIGN.md Design system notes
| Key | Action |
|---|---|
Space / K |
Play / pause |
J / ← · L / → |
Back / forward 10 s |
Tab |
Skip the detected opening/ending (viewers vote) |
N / O |
Skip 90 s |
↑ / ↓ |
Volume ±5% |
M |
Mute |
C |
Toggle subtitles |
G / H |
Subtitle sync ±50 ms (Shift for ±250 ms) |
[ / ] |
Audio delay ±50 ms |
Z |
Aspect mode |
F |
Fullscreen |
W |
Watch Party panel |
- Catalog is experimental: it works, but is an initial implementation and will change.
- Torrent links and screen sharing appear in the menu as "Coming soon". Torrent needs a torrent engine on the server. Screen sharing is planned on MoQ/WebTransport (not WebRTC):
getDisplayMedia()→ WebCodecs → MoQ/WebTransport → relay → viewers, which needs HTTP/3 and a relay on the server. - Uploaded files stay in
.uploads/until the cleanup removes them:UPLOAD_TTL_HOURSafter the room closes, or afterUPLOAD_IDLE_HOURS(24) without being read, and the oldest go first if the disk quota is hit. - A ban applies to the browser's identity (kept in
localStorage): a new tab keeps it, but another browser, a private window or cleared site data gets a new identity. There are no accounts, so that is as far as it goes.


