Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
compose.override.yml
__pycache__/
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
19 changes: 18 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<DOMAIN>`, 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/<id>/…`)
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.
Expand Down
17 changes: 17 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -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:
3 changes: 3 additions & 0 deletions test/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 12 additions & 3 deletions unplayer
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,28 @@ Usage: unplayer [options] <url> [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
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

Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading