A self-hosted, personal-Netflix-style video server for your home network.
Point it at a folder of .mp4 / .mkv / .webm files, open the URL on
your phone, and stream your library straight from disk — no cloud, no
upload, no account, no tracking.
It's a single Node.js process plus a static front-end. There is no build
step, no database to install (the watch-progress store is a single SQLite
file), and no dependency beyond express and better-sqlite3.
- Recursive video library scan (mp4, mkv, webm, mov, m4v, avi).
- Netflix-style home screen: Continue watching, Recently watched, Recently added, and one carousel per top-level folder.
- Resumable playback — the player picks up where you left off, even after closing the tab.
- Library grid with infinite scroll, plus a folder view and a search box.
- HTTP range streaming — handles 4 GB+ movie files without buffering the whole thing.
- Per-folder picker so you can repoint the library at a different drive from any device on the LAN.
- Modern, accessible UI: ARIA roles, keyboard navigation (Esc closes the player), reduced-motion support, mobile-first responsive layout.
| Concern | Mitigation |
|---|---|
| Read access to files outside the library root | Every media path is resolved with path.resolve and then path.relative is checked against the library root. The walk refuses to follow symbolic links. Bounded by LOCALMEDIA_MAX_DEPTH (default 12) and LOCALMEDIA_MAX_FILES (default 50,000). |
Read access to host filesystem via /api/browse |
The folder picker is restricted to inside the current library root. |
| Unauthenticated folder changes | POST /api/config requires a bearer token via the LOCALMEDIA_TOKEN environment variable. The token is compared with crypto.timingSafeEqual. |
| Unauthenticated writes | POST /api/progress requires the same bearer token. |
| Watch-progress privacy | Progress is keyed by libraryRoot + relativePath, so two users pointing at different libraries don't collide. |
| Read-only API for honest devices | GET /api/config, GET /api/media, GET /api/home, GET /api/progress/*, and GET /media/* are open so phones on the LAN can browse without a login. |
| Cross-site scripting | Every dynamic string (folder names, filenames, captions) is rendered with textContent. No innerHTML write ever receives user-controlled input. |
| MIME sniffing of error responses | /media/* errors set Content-Type: text/plain; charset=utf-8. |
| Click-jacking / frame embedding | X-Frame-Options: DENY and CSP frame-ancestors 'none'. |
| MIME type sniffing of responses | X-Content-Type-Options: nosniff on every response. |
| Referrer leakage | Referrer-Policy: no-referrer on every response. |
| Privilege-creep via geolocation / camera / mic | Permissions-Policy denies them all. |
| Server DoS via body floods | JSON body limit is 32 KB. |
| Walk DoS via huge trees | LOCALMEDIA_MAX_DEPTH and LOCALMEDIA_MAX_FILES cap the recursion. |
If LOCALMEDIA_TOKEN is not set, all mutating endpoints will refuse every
request with HTTP 503. Set it to a long random string to enable them.
Requires Node 18+ and a folder of video files on the host.
npm install
LOCALMEDIA_TOKEN="$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" npm startThen open the URL the server prints, e.g. http://192.168.1.42:3013, on
any device on the same LAN. The library root defaults to ~/Videos; click
the gear icon and paste the bearer token to change the folder.
| Variable | Default | Purpose |
|---|---|---|
PORT |
3013 |
TCP port the server listens on. |
LOCALMEDIA_PATH |
~/Videos |
Initial library root. Overridden by data/settings.json once the user picks a folder. |
LOCALMEDIA_TOKEN |
(unset) | Bearer token required for POST /api/config, POST /api/progress, and GET /api/browse. When unset, those endpoints return 503. |
LOCALMEDIA_MAX_DEPTH |
12 |
Recursion limit for the library walk. |
LOCALMEDIA_MAX_FILES |
50000 |
Hard cap on the number of files in the index. |
mp4, mkv, webm, mov, m4v, avi.
npm install
node --check server.js
node --check public/app.js
LOCALMEDIA_TOKEN=*** npm startThe project is small enough that the whole server fits in one file
(server.js) and the whole front-end in public/.
MIT — see LICENSE.