Files
pz8-relay/README.md
T
2026-05-06 11:54:00 +02:00

125 lines
5.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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:
```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.