125 lines
5.0 KiB
Markdown
125 lines
5.0 KiB
Markdown
# 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.
|