Lightweight HLS restream toolkit for self-hosted media servers (Jellyfin, Emby, Plex).
Many free IPTV/HLS sources require specific HTTP headers (User-Agent, Referer) that media servers don't send. This proxy sits between your media server and the upstream, injecting the required headers and rewriting m3u8 playlists so all segment requests also go through the proxy.
| File | Purpose |
|---|---|
hls-proxy.py |
HTTP reverse proxy that adds headers to upstream HLS requests |
refresh-m3u.sh |
Scrapes source pages, extracts m3u8 URLs, writes M3U playlist |
detect-headers.sh |
Auto-detects which HTTP headers a stream requires |
channels.conf |
Your channel list (slug, name, logo, group, source URL) |
┌──────────┐ ┌───────────┐ ┌──────────────┐ ┌──────────┐
│ Jellyfin │────▶│ hls-proxy │────▶│ upstream HLS │────▶│ segments │
│ │ │ :8089 │ │ server │ │ (.ts) │
└──────────┘ └───────────┘ └──────────────┘ └──────────┘
adds headers:
• User-Agent
• Referer
refresh-m3u.shgenerates an M3U file with stable/channel/<slug>URLs pointing to the proxy- When Jellyfin requests
/channel/sporttv1, the proxy scrapes a fresh m3u8 URL on the fly (cached for 1 hour) - The proxy injects the required headers and rewrites the playlist so
.tssegments also go through it - The proxy auto-learns the correct Referer for each upstream host — no manual configuration needed
No more expired tokens — the M3U URLs never change, the proxy handles token refresh transparently.
- Python 3.8+ (stdlib only, no pip packages)
- bash, curl, grep (with PCRE /
-P)
# 1. Clone git clone https://github.com/pcruz1905/hls-restream-proxy.git cd hls-restream-proxy # 2. Configure channels cp channels.conf.example channels.conf # Edit channels.conf with your channels # 3. Start the proxy export HLS_PROXY_PORT=8089 export HLS_PROXY_REFERER="https://your-upstream-embed-domain.com/" python3 hls-proxy.py & # 4. Generate the M3U export M3U_OUTPUT=/path/to/jellyfin/config/iptv.m3u export HLS_PROXY_URL="http://YOUR_HOST_IP:8089" bash refresh-m3u.sh # 5. Add the M3U file as an M3U Tuner in your media server
channels.conf — one channel per line, pipe-delimited:
slug|Display Name|chno|logo_url|Group|source_page_url|mode|referer
- mode:
iframe(default) — page has an iframe whose embed contains the m3u8.direct— page itself contains the m3u8 URL. - referer: optional override for the Referer header when fetching the embed page.
See channels.conf.example for details.
User-level services are provided in systemd/:
# Copy units cp systemd/*.service systemd/*.timer ~/.config/systemd/user/ # Edit paths in the service files, then: systemctl --user daemon-reload systemctl --user enable --now hls-proxy.service systemctl --user enable --now refresh-m3u.timer # Enable linger so services run without an active login session sudo loginctl enable-linger $USER
The timer refreshes URLs every 4 hours by default (edit OnUnitActiveSec in the timer).
If your media server runs in Docker on a bridge network, the proxy URL must use the Docker gateway IP (not 127.0.0.1). Find it with:
docker inspect <container> | grep Gateway # Typically 172.x.0.1
Then set HLS_PROXY_URL=http://172.x.0.1:8089.
Every streaming site is different. Here's how to figure out which headers your upstream needs:
Run the detector tool — it follows the iframe chain, tests every header combination on both the m3u8 playlist and .ts segments, and tells you exactly what you need:
./detect-headers.sh "https://streaming-site.com/channel.php"Output:
=== HLS Header Detector ===
[1/4] Extracting iframe from page...
iframe: https://embed-domain.com/embed/abc123
embed host: https://embed-domain.com
[1/4] Extracting m3u8 from embed page...
m3u8: https://cdn.example.com/hls/abc123.m3u8?s=token&e=123
[2/4] Testing header combinations on m3u8 playlist...
403 no-UA
200 UA
200 UA+Ref(https://embed-domain.com/)
[3/4] Testing header combinations on .ts segments...
403 no-UA
403 UA
200 UA+Ref(https://embed-domain.com/)
[4/4] Recommended configuration:
User-Agent + Referer
export HLS_PROXY_REFERER="https://embed-domain.com/"
You can also pass a direct m3u8 URL:
./detect-headers.sh "https://cdn.example.com/hls/stream.m3u8?token=xxx" --directIf the auto-detector can't find the m3u8 (some sites use heavy JS), use browser DevTools:
- Open the streaming site in Chrome/Firefox
- Press
F12→ Network tab - Filter by
m3u8ormedia - Play the stream — you'll see
.m3u8and.tsrequests appear
Click on the .m3u8 request and look at the Request Headers. Note down:
- User-Agent — usually a standard browser UA
- Referer — this is the key one, usually the embed/iframe domain (not the main site)
- Origin — sometimes needed instead of Referer
# Get a fresh m3u8 URL from the Network tab, then test: # Without headers (probably 403) curl -o /dev/null -w "%{http_code}" "https://example.com/hls/stream.m3u8?token=xxx" # With User-Agent only curl -o /dev/null -w "%{http_code}" -A "Mozilla/5.0" "https://example.com/hls/stream.m3u8?token=xxx" # With User-Agent + Referer curl -o /dev/null -w "%{http_code}" -A "Mozilla/5.0" \ -e "https://embed-domain.com/" \ "https://example.com/hls/stream.m3u8?token=xxx"
Try each combination until you get 200. That tells you which headers are required.
The playlist (.m3u8) and segments (.ts) may need different headers. Grab a .ts URL from the playlist and repeat the curl test:
# Get a segment URL from the m3u8 content curl -s -A "Mozilla/5.0" -e "https://embed-domain.com/" \ "https://example.com/hls/stream.m3u8?token=xxx" | grep ".ts" | head -1 # Test that segment curl -o /dev/null -w "%{http_code}" -A "Mozilla/5.0" \ -e "https://embed-domain.com/" \ "https://example.com/hls/segment-12345.ts"
Once you know the required headers:
export HLS_PROXY_UA="Mozilla/5.0 ..." # usually the default is fine export HLS_PROXY_REFERER="https://embed-domain.com/" # the iframe/embed host
| Site pattern | Usually needs |
|---|---|
| Page → iframe → m3u8 | Referer = iframe embed domain |
| Direct m3u8 with token | User-Agent only |
| Cloudflare-protected | User-Agent + Referer + sometimes Origin |
Most sites follow this pattern: page → iframe → embed page → m3u8
# Extract the iframe src curl -sL -A "Mozilla/5.0" "https://streaming-site.com/channel.php" \ | grep -oP 'iframe\s+src="\K[^"]+' # That gives you the embed domain for the Referer
| Variable | Default | Description |
|---|---|---|
HLS_PROXY_PORT |
8089 |
Proxy listen port |
HLS_PROXY_BIND |
127.0.0.1 |
Bind address (localhost only by default) |
HLS_PROXY_UA |
Chrome UA string | User-Agent sent to upstream |
HLS_PROXY_REFERER |
(empty) | Fallback Referer header (auto-learned from /channel/) |
HLS_ALLOWED_IPS |
(empty) | Comma-separated client IP allowlist (empty = all) |
CHANNELS_CONF |
./channels.conf |
Path to channel config file |
HLS_CACHE_TTL |
3600 |
Seconds to cache scraped m3u8 URLs (per channel) |
M3U_OUTPUT |
/tmp/iptv.m3u |
Output M3U file path |
HLS_PROXY_URL |
http://127.0.0.1:8089 |
Proxy URL written into M3U |
MIT