Files
2026-05-06 11:54:00 +02:00

5.0 KiB
Raw Permalink Blame History

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 /playlist URL 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-id values. The merged EPG is served at /epg and the playlist's #EXTM3U header 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):

  1. Download the upstream playlist, stream-rewrite the #EXTM3U header so url-tvg points at <PUBLIC_URL>/epg, extract every tvg-id, and atomically swap the cached playlist.m3u into place.
  2. If EPG is enabled, fetch each configured EPG URL (transparently gunzipping .gz URLs) into a per-source temp file.
  3. 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's tvg-id set are dropped. Channel ids in the output are rewritten to the exact playlist form.
  4. 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.