pz8-relay
A small Go service that sits between a frequently-rotating upstream IPTV provider and one or more client devices. It exposes stable URLs for the playlist and (optionally) a merged EPG, so client devices can be configured once and forgotten.
What it does
- Caches the upstream m3u_plus playlist on a configurable interval and
serves it from a stable
/playlistURL behind basic auth. The streams inside the playlist are still hit directly upstream by clients — only the playlist file is cached. - Optionally fetches one or more XMLTV EPG sources, merges them, filters
channels down to those declared in the playlist, and rewrites their ids to
match the playlist's
tvg-idvalues. The merged EPG is served at/epgand the playlist's#EXTM3Uheader is rewritten so clients pick it up automatically.
Channel id matching is heuristic — the normalizer collapses cosmetic
divergences ("Italia 1" vs "Italia 1.it" vs "Italia 1 HD.it" all map
to the same key), which is enough for most Italian providers + open-epg
sources.
For design background, see adrs/01-epg-management.md.
Endpoints
| Path | Auth | Description |
|---|---|---|
/playlist |
basic | Cached playlist; Content-Type: application/vnd.apple.mpegurl |
/ |
basic | Alias for /playlist (back-compat) |
/epg |
none | Merged EPG; honours Accept-Encoding: gzip (~6× smaller) |
/healthz |
none | 200 once first refresh succeeds, else 503 |
/epg is intentionally unauthenticated: XMLTV client apps (TiviMate, IPTV
Smarters, etc.) auto-discover the url-tvg URL from the playlist header
and fetch it without reusing the playlist's credentials. The threat model
is low — an anonymous fetcher gets a few hundred KB of TV listings.
Configuration
All configuration is via environment variables.
| Var | Purpose | Default |
|---|---|---|
PZ8_RELAY_TARGET_URL |
Upstream m3u_plus playlist URL | required |
PZ8_RELAY_USERNAME |
Basic auth username for /playlist |
required |
PZ8_RELAY_PASSWORD |
Basic auth password for /playlist |
required |
PZ8_RELAY_LISTEN_ADDR |
TCP address to listen on | :8080 |
PZ8_RELAY_PUBLIC_URL |
Public base URL of this relay; used to rewrite url-tvg to <base>/epg |
required when EPG is enabled |
PZ8_RELAY_EPG_URLS |
Comma-separated XMLTV URLs (gzipped or plain) | empty (EPG disabled) |
PZ8_RELAY_EPG_REFRESH |
Refresh interval (Go duration) | 12h |
PZ8_RELAY_PREFER_PLAYLIST_EPG |
If true and the upstream playlist declares url-tvg, use that as the only EPG source for the cycle; otherwise fall back to EPG_URLS |
false |
PZ8_RELAY_CACHE_DIR |
Where the cached playlist + EPG live | /var/cache/pz8-relay |
PZ8_RELAY_EPG_CONTENT_TYPE |
Content-Type returned by /epg |
application/xml |
EPG processing is enabled when PZ8_RELAY_EPG_URLS is non-empty or
PZ8_RELAY_PREFER_PLAYLIST_EPG=true. With EPG disabled, the relay simply
caches and serves the upstream playlist unchanged.
How a refresh works
A single background worker drives the refresh on the configured interval (starting immediately on boot):
- Download the upstream playlist, stream-rewrite the
#EXTM3Uheader sourl-tvgpoints at<PUBLIC_URL>/epg, extract everytvg-id, and atomically swap the cachedplaylist.m3uinto place. - If EPG is enabled, fetch each configured EPG URL (transparently
gunzipping
.gzURLs) into a per-source temp file. - Walk the sources twice: pass 1 emits unique
<channel>elements (first source wins for duplicates), pass 2 emits<programme>entries scoped to the source that "owns" each channel. Channels not in the playlist'stvg-idset are dropped. Channel ids in the output are rewritten to the exact playlist form. - The result is gzipped on disk as
epg.xml.gz(XMLTV compresses ~10×) and atomically swapped into place.
If a refresh fails partway through, the previous good cache is preserved
and the next tick retries. /playlist and /epg only return 503 before
the very first successful refresh.
Running
Local build:
go build ./...
./pz8-relay
Docker:
docker build -t pz8-relay .
docker run --rm -p 8080:8080 --env-file .env pz8-relay
The container runs as the distroless nonroot user (uid 65532). The
default cache directory is created in the image with the right ownership;
mount a volume on /var/cache/pz8-relay if you want the cache to survive
restarts.
Minimum .env for EPG-enabled operation:
PZ8_RELAY_TARGET_URL=https://upstream.example/playlist.m3u
PZ8_RELAY_USERNAME=alice
PZ8_RELAY_PASSWORD=hunter2
PZ8_RELAY_PUBLIC_URL=https://relay.example.com
PZ8_RELAY_EPG_URLS=https://www.open-epg.com/files/italy1.xml.gz
Tests
go test ./...
Covers id normalization, playlist header rewriting + tvg-id extraction, and the EPG two-pass merge (filtering, id rewriting, first-source-wins dedup). HTTP wiring and the refresh ticker are not unit-tested; smoke-test manually with the endpoints above.