From 5f4ddf8ab3c3251576a9837dc5c274d95cfe01fa Mon Sep 17 00:00:00 2001 From: Jonathan Telep Date: Sun, 18 Oct 2026 20:57:35 -0400 Subject: [PATCH] Add self-hostable web player unplayer --print finds the stream; web/server.py relays it with ffmpeg as fMP4 HLS (audio re-encoded to plain AAC, 30s start buffer to ride out source stalls) behind an optional password. Docker + Caddy for HTTPS via make deploy, make local for one machine. README covers self-hosting with your own password. Co-Authored-By: Claude Opus 5.5 --- .env.example | 14 +++ .gitignore | 3 + Dockerfile | 9 ++ Makefile | 19 +++- README.md | 87 +++++++++++++++++ compose.yml | 17 ++++ test/test.sh | 3 + unplayer | 15 ++- web/server.py | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 compose.yml create mode 100644 web/server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94212a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Copy to .env (`cp .env.example .env`) and edit. `make deploy` and `make local` read it. +# .env is gitignored, so your password never ends up in git. + +# Public hostname for HTTPS (make deploy). Point its DNS A record at your server. +# Not used by `make local`, but leave something here. +DOMAIN=streams.example.com + +# Optional phrase that locks the site. Visitors type it once per browser (remembered a year). +# Empty = open to anyone, so set one on a public server. Generate one: openssl rand -base64 24 +# Changing it logs everyone out. +PASSWORD= + +# Max streams relayed at once (each is one ffmpeg process plus its bandwidth). +MAX_STREAMS=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74eb3f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +compose.override.yml +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20eed56 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM alpine:3.23 +RUN apk add --no-cache bash curl ffmpeg python3 +# yt-dlp straight from its releases: distro builds lag and YouTube breaks old versions. +# ADD re-downloads on every build, so `make deploy` also updates it. +ADD --chmod=755 https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp /usr/local/bin/yt-dlp +COPY unplayer /usr/local/bin/unplayer +COPY web/server.py /app/server.py +EXPOSE 8080 +CMD ["python3", "-u", "/app/server.py"] diff --git a/Makefile b/Makefile index 08d33b5..bcd130f 100644 --- a/Makefile +++ b/Makefile @@ -17,4 +17,21 @@ release: test git push origin main v$(VERSION) gh release create v$(VERSION) unplayer --title "unplayer v$(VERSION)" --generate-notes -.PHONY: install uninstall test release +# web player + Caddy (HTTPS) in Docker; needs .env (cp .env.example .env) +deploy: + @test -f .env || { echo 'cp .env.example .env and set DOMAIN' >&2; exit 1; } + docker compose up -d --build + +# just the app on this machine, no Caddy/HTTPS: http://localhost:8080 +local: + @test -f .env || { echo 'cp .env.example .env' >&2; exit 1; } + docker compose up -d --build app + docker compose rm -sf caddy + @echo 'unplayer is up: http://localhost:8080' + +# stop and remove what deploy started (containers, network, built image); keeps stats + TLS cert volumes. +# Wipe those too: docker compose down -v +clean: + docker compose down --rmi local + +.PHONY: install uninstall test release deploy local clean diff --git a/README.md b/README.md index e0c2394..6b246a3 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,93 @@ if a window closes unexpectedly. mpv's `1`–`8` picture keys (brightness, contrast…) are disabled, so a stray keypress can't turn the video black. +## Web player (self-hosted) + +Run unplayer as a website on your own server, so you and anyone you share the password with +can watch from any device, including phones and TVs. Paste a page URL and you land on a +plain video player: fullscreen, volume, nothing else. Each attempt is logged on `/stats`. + +All you need is a machine with [Docker](https://docs.docker.com/engine/install/) and the +Compose plugin: a VPS, a home server or your desktop. + +### 1. Configure + +```bash +git clone https://github.com/JonTelep/unplayer.git && cd unplayer +cp .env.example .env +``` + +Edit `.env`: + +| Setting | What it does | +|---|---| +| `PASSWORD` | Phrase that locks every page. Visitors type it once per browser (remembered for a year). **Set one on anything reachable from the internet.** Empty = open to anyone. Generate one with `openssl rand -base64 24`. | +| `DOMAIN` | Hostname for HTTPS, e.g. `streams.example.com`. Only used by `make deploy`. | +| `MAX_STREAMS` | Max streams relayed at once (default 5). Each one is an ffmpeg process plus its bandwidth. | + +`.env` is gitignored, so your password stays out of git. + +### 2a. Public server with HTTPS + +```bash +make deploy # docker compose up -d --build: the app + Caddy with automatic Let's Encrypt HTTPS +``` + +- Add a DNS **A record** for `DOMAIN` pointing at the server's IP. On Cloudflare, set it + to **DNS only (grey cloud)**. Cloudflare's proxy isn't meant for relaying video, and it + blocks Caddy from getting its certificate. +- Open ports 80 and 443 in the server's firewall (`sudo ufw allow 80,443/tcp`, plus your + host's panel firewall if it has one). +- Open `https://`, enter your password and paste a link. + +### 2b. Just this machine (or your home network / Tailscale) + +```bash +make local # the app only, no Caddy: http://localhost:8080 +``` + +It only listens on localhost. To reach it from your phone over your LAN or +[Tailscale](https://tailscale.com), add a `compose.override.yml` (gitignored) with that +machine's IP and run `make local` again: + +```yaml +services: + app: + ports: ["100.x.y.z:8080:8080"] # your Tailscale or LAN IP +``` + +Then open `http://100.x.y.z:8080` on the phone. + +### Running it + +- **Update:** `git pull && make deploy` (or `make local`). Rebuilding also pulls the latest + yt-dlp, which sites like YouTube need often. +- **Change the password:** edit `.env`, then `make deploy` again. Everyone has to re-enter it. +- **Logs:** `docker compose logs -f app`. **Health check:** `/healthz`. +- **Stop:** `make clean` (keeps the stats and TLS volumes; `docker compose down -v` wipes them). +- Stats live in SQLite on the `data` volume and survive rebuilds. + +### How the web player plays + +- The server finds the stream with `unplayer --print`. ffmpeg then relays it as HLS (fMP4) + that browsers play directly: iPhone/Safari natively, everything else through hls.js. The + video isn't re-encoded. The audio is re-encoded to plain AAC so it plays cleanly in every + browser. +- Live sources often freeze for 10–20 seconds. The player waits until 30 seconds are buffered + before it starts, and that cushion absorbs the freezes. Expect to be a bit behind live and + a short "Buffering…" wait when a stream opens. +- If the browser blocks autoplay (Brave does by default), tap **▶ Tap to play**. +- A stream stops 5 minutes after its last viewer leaves. + +### Security notes + +- The password protects the pages and the "start a stream" form. Stream files (`/s//…`) + stay open so a TV can play them over AirPlay. Their URLs are random and stop working when + the stream ends. +- Anyone with the password can make your server fetch public web pages and relay video. The + server refuses private and local addresses, so it can't be pointed at your own network. + Bandwidth is on you, so keep `MAX_STREAMS` sensible for your plan. + ## How it works 1. **Fetch the page** with `curl`. This is just text, so no scripts run. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..2ab8fcb --- /dev/null +++ b/compose.yml @@ -0,0 +1,17 @@ +# `docker compose up -d --build` (or `make deploy`). Caddy gets a Let's Encrypt cert for DOMAIN. +services: + app: + build: . + restart: unless-stopped + env_file: .env + volumes: [data:/data] # stats.db survives rebuilds + ports: ["127.0.0.1:8080:8080"] # `make local`; this machine only, so the VPS still goes through Caddy + caddy: + image: caddy:2.10-alpine + restart: unless-stopped + ports: ["80:80", "443:443"] + command: caddy reverse-proxy --from ${DOMAIN:?set DOMAIN in .env} --to app:8080 + volumes: [caddy:/data] # keeps the TLS cert across restarts +volumes: + data: + caddy: diff --git a/test/test.sh b/test/test.sh index 7bdfa35..e3e2bb2 100755 --- a/test/test.sh +++ b/test/test.sh @@ -29,5 +29,8 @@ out=$(../unplayer "$base/page.html") grep -q 'Opened in mpv' <<<"$out" for _ in $(seq 20); do grep -q "MPV .*$base/s2/high.m3u8" "$root/state/unplayer.log" 2>/dev/null && break; sleep 0.1; done grep -q "MPV .*$base/s2/high.m3u8" "$root/state/unplayer.log" +# --print: stream, referer, UA on stdout; progress on stderr +out=$(../unplayer --print "$base/page.html" 2>/dev/null) +[[ "$(sed -n 1,2p <<<"$out")" == "$base/s2/high.m3u8"$'\n'"$base/" ]] ! ../unplayer "$base/page.html" 9 2>/dev/null # out-of-range stream number echo PASS diff --git a/unplayer b/unplayer index 8d0b617..62645e0 100755 --- a/unplayer +++ b/unplayer @@ -17,17 +17,20 @@ Usage: unplayer [options] [N] Options: -f, --foreground keep mpv attached to this terminal (shows its output) + -p, --print print the stream URL, referer and user agent (one per line) + instead of playing it; progress goes to stderr -h, --help show this help -V, --version show version EOF } -fg= +fg= print= while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; -V|--version) echo "unplayer $VERSION"; exit 0 ;; -f|--foreground) fg=1; shift ;; + -p|--print) print=1; exec 3>&1 1>&2; shift ;; -*) echo "unplayer: unknown option $1" >&2; usage >&2; exit 2 ;; *) break ;; esac @@ -35,7 +38,7 @@ done [[ $# -ge 1 ]] || { usage >&2; exit 2; } url=$1 pick=${2:-} [[ -z "$pick" || "$pick" =~ ^[1-9][0-9]*$ ]] || { echo "unplayer: N must be a positive number" >&2; exit 2; } -for dep in curl mpv; do +for dep in curl $([[ -n "$print" ]] || echo mpv); do command -v "$dep" >/dev/null || { echo "unplayer: '$dep' is required but not installed" >&2; exit 1; } done @@ -68,7 +71,12 @@ play() { } # Sites whose player hides the stream (YouTube, Twitch, ~1800 more): mpv's yt-dlp # hook pulls the raw stream, so the site's player and its ads never load. -ytdl() { echo "$1 Handing off to yt-dlp..."; play "$url"; } +ytdl() { + echo "$1 Handing off to yt-dlp..." + [[ -z "$print" ]] && play "$url" + local src; src=$(yt-dlp -g -f b "$url" | head -1) && [[ -n "$src" ]] || exit 1 + printf '%s\n\n%s\n' "$src" "$UA" >&3; exit 0 +} # Tags may span lines (iframe src on its own line), so flatten: every grep here is line-based. fetch() { curl -sL -m 15 -A "$UA" ${2:+-e "$2"} "$1" | tr '\n' ' ' || true; } @@ -117,6 +125,7 @@ for i in "${!srcs[@]}"; do seg=$(resolve "$src" "$(grep -v '^#' <<<"$m" | grep . | tail -1 || true)") if [[ -n "$seg" ]] && is_video "$seg" "$ref"; then echo "Playing stream $((i+1)): $src" + [[ -n "$print" ]] && { printf '%s\n%s\n%s\n' "$src" "$ref" "$UA" >&3; exit 0; } play --no-ytdl --demuxer-lavf-o=extension_picky=0,allowed_extensions=ALL \ --force-media-title="$(basename "${url%/*}")" \ --referrer="$ref" --user-agent="$UA" --http-header-fields="Origin: ${ref%/}" "$src" diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..7e9d90b --- /dev/null +++ b/web/server.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""unplayer web: paste a page URL, watch its stream in a clean player. + +unplayer --print finds the stream; ffmpeg remuxes it (no transcode) to local HLS, +which plays natively on iOS/Safari and through hls.js everywhere else. +Every attempt is logged to SQLite and shown on /stats. +PASSWORD (optional) locks every page behind a one-field landing page (remembered by cookie). +Stream files (/s//) stay open so a TV can play them over AirPlay. +""" +import hashlib, html, ipaddress, os, re, secrets, shutil, socket, sqlite3, subprocess, threading, time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +PASSWORD = os.environ.get("PASSWORD", "") +TOKEN = hashlib.sha256(PASSWORD.encode()).hexdigest() # cookie value; changing PASSWORD logs everyone out +MAX_STREAMS = int(os.environ.get("MAX_STREAMS", "5")) # concurrent ffmpeg relays; protects the VPS +DB = os.environ.get("DB", "/data/stats.db") +ROOT = "/tmp/streams" +IDLE = 300 # seconds without a viewer before ffmpeg is stopped +streams = {} # id -> {"proc": Popen, "seen": last request time, "url": page url} +lock = threading.Lock() + +PAGE = """ +unplayer +
{}
""" + +def form(msg="", url=""): + """The paste page; errors re-render it with the URL still filled in so retrying is one tap.""" + err = f"
{msg}
" if msg else "" + return PAGE.format(f"""

Paste a stream link

{err}
+ +
""") + +def gate(next="/", wrong=False): + err = "
Wrong phrase.
" if wrong else "" + return PAGE.format(f"""

unplayer

{err}
+ +
""") + +PLAYER = """ +

Loading stream…

%s

+ +""" + +TYPES = {".m3u8": "application/vnd.apple.mpegurl", ".m4s": "video/iso.segment", ".mp4": "video/mp4"} + + +def db(): + c = sqlite3.connect(DB) + c.execute("CREATE TABLE IF NOT EXISTS attempts (ts INTEGER, url TEXT, ok INTEGER, detail TEXT, secs REAL)") + return c + + +def record(url, ok, detail, secs): + with db() as c: + c.execute("INSERT INTO attempts VALUES (?,?,?,?,?)", (int(time.time()), url, ok, detail, round(secs, 1))) + + +def public_url(url): + """http(s) URL whose host resolves only to public IPs (no fetching the VPS's own network).""" + if not re.fullmatch(r"https?://\S+", url): + return False + try: + host = urlsplit(url).hostname + return bool(host) and all(ipaddress.ip_address(a[4][0]).is_global for a in socket.getaddrinfo(host, None)) + except (OSError, ValueError): + return False + + +def start(url): + """Find the stream and start remuxing it. Returns the id, or raises with a reason.""" + r = subprocess.run(["unplayer", "--print", url], capture_output=True, text=True, timeout=180) + if r.returncode or not r.stdout.strip(): + raise RuntimeError((r.stderr.strip().splitlines() or ["No working stream."])[-1]) + src, ref, ua = (r.stdout.splitlines() + ["", "", ""])[:3] + sid = secrets.token_hex(16) + out = f"{ROOT}/{sid}" + os.makedirs(out) + # -re: read at play speed, or a VOD gets remuxed and rotated out before anyone watches it; + # but grab the first 15s (a live source's whole window) at once so the player starts with a cushion + cmd = ["ffmpeg", "-loglevel", "error", "-re", "-readrate_initial_burst", "15", "-user_agent", ua] + if ref: + cmd += ["-referer", ref, "-headers", f"Origin: {ref.rstrip('/')}\r\n"] + # HLS input (anything unplayer found itself has a referer; playlist URLs may lack .m3u8): + # hosts disguise segments as .txt, which ffmpeg refuses by default + if ref or ".m3u8" in src: + cmd += ["-allowed_extensions", "ALL", "-allowed_segment_extensions", "ALL", "-extension_picky", "0"] + # video as-is; audio re-encoded with its timestamps smoothed over source hiccups. + # fMP4 segments, not .ts: browsers play them directly, while hls.js rewrites .ts audio itself + # (and labels plain AAC as HE-AAC on Chromium), which came out deep and slowed down in Brave + cmd += ["-i", src, "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-af", "aresample=async=1", + "-f", "hls", "-hls_segment_type", "fmp4", "-hls_time", "4", "-hls_list_size", "90", + "-hls_flags", "delete_segments+omit_endlist", f"{out}/index.m3u8"] + proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL) + with lock: + streams[sid] = {"proc": proc, "seen": time.time(), "url": url} + for _ in range(60): # wait for a few segments so the player starts with some buffer + if proc.poll() is not None: + break + if os.path.exists(f"{out}/index.m3u8") and open(f"{out}/index.m3u8").read().count("#EXTINF") >= 3: + return sid + time.sleep(0.5) + if proc.poll() is None and os.path.exists(f"{out}/index.m3u8"): + return sid # slow source: play what we have + stop(sid) + raise RuntimeError("Found a stream but couldn't relay it.") + + +def stop(sid): + with lock: + s = streams.pop(sid, None) + if s: + s["proc"].kill() + s["proc"].wait() + shutil.rmtree(f"{ROOT}/{sid}", ignore_errors=True) + + +def reaper(): + while True: + time.sleep(30) + with lock: + idle = [k for k, s in streams.items() if time.time() - s["seen"] > IDLE or s["proc"].poll() is not None] + for sid in idle: + stop(sid) + + +def stats_page(): + with db() as c: + total, ok = c.execute("SELECT count(*), coalesce(sum(ok),0) FROM attempts").fetchone() + day = c.execute("SELECT count(*) FROM attempts WHERE ts > ?", (time.time() - 86400,)).fetchone()[0] + rows = c.execute("SELECT ts, url, ok, detail, secs FROM attempts ORDER BY rowid DESC LIMIT 200").fetchall() + rate = f"{100 * ok // total}%" if total else "–" + tiles = (f"
{total}attempts
{rate}success
" + f"
{day}last 24h
{len(streams)}live now
") + trs = "".join( + f"{time.strftime('%Y-%m-%d %H:%M', time.gmtime(ts))}{html.escape(u)}" + f"{'ok' if k else 'failed'}{html.escape(d)}{s}s" + for ts, u, k, d, s in rows) + return PAGE.format(f"

Stats

{tiles}" + f"{trs}
When (UTC)URLResultDetailTook
") + + +class Handler(BaseHTTPRequestHandler): + def send(self, code, body, ctype="text/html; charset=utf-8", headers=()): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for k, v in headers: + self.send_header(k, v) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def page(self, code, msg, url=""): + self.send(code, form(msg, url).encode()) + + def authed(self): + """True if unlocked; otherwise answers with the landing page.""" + if not PASSWORD: + return True + cookie = re.search(r"(?:^|;\s*)k=(\w+)", self.headers.get("Cookie", "")) + if cookie and secrets.compare_digest(cookie[1], TOKEN): + return True + self.send(401, gate(self.path if self.command == "GET" else "/").encode()) + return False + + def login(self, body): + form = parse_qs(body) + nxt = form.get("next", ["/"])[0] + if not nxt.startswith("/") or nxt.startswith("//"): # only same-site redirects + nxt = "/" + if not secrets.compare_digest(form.get("phrase", [""])[0].encode(), PASSWORD.encode()): + time.sleep(1) # slows guessing + return self.send(401, gate(nxt, True).encode()) + secure = "; Secure" if self.headers.get("X-Forwarded-Proto") == "https" else "" + self.send(303, b"", headers=[("Location", nxt), + ("Set-Cookie", f"k={TOKEN}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax{secure}")]) + + def do_GET(self): + if self.path == "/": + if self.authed(): + self.send(200, form().encode()) + elif self.path == "/stats": + if self.authed(): + self.send(200, stats_page().encode()) + elif self.path == "/healthz": + self.send(200, b"ok", "text/plain") + elif m := re.fullmatch(r"/w/([0-9a-f]{32})", self.path): + if not self.authed(): + return + if s := streams.get(m[1]): + self.send(200, PAGE.format(PLAYER % (html.escape(s["url"]), m[1])).encode()) + else: + self.page(404, "That stream ended. Paste the link again to restart it.") + elif (m := re.fullmatch(r"/s/([0-9a-f]{32})/([\w-]+(\.m3u8|\.m4s|\.mp4))", self.path)) and m[1] in streams: + streams[m[1]]["seen"] = time.time() + try: + body = open(f"{ROOT}/{m[1]}/{m[2]}", "rb").read() + except FileNotFoundError: # segment already rotated out + return self.send(404, b"gone", "text/plain") + self.send(200, body, TYPES[m[3]], [("Cache-Control", "no-cache")]) + else: + self.send(404, b"not found", "text/plain") + + do_HEAD = do_GET + + def do_POST(self): + body = self.rfile.read(min(int(self.headers.get("Content-Length") or 0), 8192)).decode(errors="replace") + if self.path == "/login": + return self.login(body) + if self.path != "/new" or not self.authed(): + return + url = parse_qs(body).get("url", [""])[0].strip() + if not public_url(url): + record(url, 0, "not a public URL", 0) + return self.page(400, "That doesn't look like a link. Paste the full address, starting with https://", url) + # ponytail: the cap counts relays, not searches in progress; add a search semaphore if people hammer it + if len(streams) >= MAX_STREAMS: + record(url, 0, "busy", 0) + return self.page(503, f"All {MAX_STREAMS} stream slots are in use. Try again in a few minutes.", url) + t = time.time() + try: + sid = start(url) + except Exception as e: + record(url, 0, str(e), time.time() - t) + return self.page(502, html.escape(str(e)), url) + record(url, 1, "", time.time() - t) + self.send(303, b"", headers=[("Location", f"/w/{sid}")]) + + +if __name__ == "__main__": + shutil.rmtree(ROOT, ignore_errors=True) + os.makedirs(ROOT) + os.makedirs(os.path.dirname(DB), exist_ok=True) + db().close() + threading.Thread(target=reaper, daemon=True).start() + ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()