# 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](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 `/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 `/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 `` elements (first source wins for duplicates), pass 2 emits `` 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: ```sh go build ./... ./pz8-relay ``` Docker: ```sh 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 ```sh 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.