Live TV¶
Plex on nebula gets live TV from public IPTV streams. Two Threadfin instances each pretend to be an HDHomeRun tuner, and each sits behind its own NordVPN tunnel so it can reach streams that are geo-blocked. A third service builds the program guide.
Architecture¶
iptv-org playlists (index.m3u)
↓ filtered to sports networks
threadfin-vpn (US exit, Chicago) ← gluetun sidecar
threadfin-ca-vpn (Canadian exit, Montreal) ← gluetun sidecar
↑ guide.xml ↓ HDHomeRun API + ffmpeg relay
iptv-epg (iptv-org grabber) Plex (nebula), one DVR per tuner
Every byte of video comes through the tunnel. Threadfin relays each stream itself with ffmpeg, so Plex never connects to an upstream stream server directly.
Components¶
| US tuner | Canadian tuner | Guide | |
|---|---|---|---|
| Manifests | heezy-k8s/apps/threadfin/ |
heezy-k8s/apps/threadfin-ca/ |
heezy-k8s/apps/iptv-epg/ |
| Deployment | threadfin-vpn |
threadfin-ca-vpn |
iptv-epg |
| Web UI (LAN only) | https://threadfin.heezy.info/web/ |
https://threadfin-ca.heezy.info/web/ |
none |
| In-cluster address | threadfin.heezy.svc.cluster.local:34400 |
threadfin-ca.heezy.svc.cluster.local:34400 |
iptv-epg.heezy.svc.cluster.local:3000/guide.xml |
| NodePort | 30344 | 30345 | none |
| VPN exit | SERVER_COUNTRIES=United States, standard servers only |
SERVER_COUNTRIES=Canada, SERVER_CITIES=Montreal |
no VPN |
| Channel numbers | from 1000 | from 2000 | n/a |
| Carries | US networks, local NBC/ABC/CBS affiliates, ESPN, FS1, NHL/NFL/NBA/MLB channels | TSN The Ocho, TVA, MSG | listings for both |
Both tunnels use the shared gluetun-secrets (NordVPN service credentials from OpenBao
production/heezy/gluetun/nordvpn). Each tuner has its own Longhorn PVCs for Threadfin config and
gluetun state.
Plex configuration¶
Each tuner is its own DVR in Plex, and both show up together in one guide.
| DVR | Tuner (device address) | Guide (XMLTV) |
|---|---|---|
| US | threadfin.heezy.svc.cluster.local:34400 |
http://threadfin.heezy.svc.cluster.local:34400/xmltv/threadfin.xml |
| Canada | threadfin-ca.heezy.svc.cluster.local:34400 |
http://threadfin-ca.heezy.svc.cluster.local:34400/xmltv/threadfin.xml |
Use the svc.cluster.local names, never the heezy.info ones. Plex is hostNetwork with
ClusterFirstWithHostNet, so it resolves cluster names. The heezy.info names go through SWAG over
HTTPS, and the stream URLs Threadfin hands to Plex point at the cluster names anyway.
To add a tuner by hand: Live TV & DVR, Set up, enter the device address manually. At the guide step, choose "Have an XMLTV guide on your server?" and enter the XMLTV URL. Do not pick a cable-provider lineup by postal code. Plex then matches Threadfin's channel numbers against that provider's numbers, and every channel shows the wrong listings.
Channel selection¶
Each tuner loads index.m3u from iptv-org and keeps only what its filters match. Every category
and country playlist is a subset of index.m3u, so loading one beside it lists each channel twice.
Filters are mandatory. With more than 480 streams loaded and no filter, Threadfin drops every stream and empties its channel mapping, including channels Plex is already using.
Filters are Threadfin custom filters: a case-insensitive substring of tvg-id group-title name.
{a,b} requires one of a or b as well, and !{a,b} rejects the stream if either appears. Short
terms are pinned to a country with us@ or ca@ (the tvg-id suffix), because nba appears inside
"Donbass" and nhl inside "VinhLong".
| Tuner | Filters |
|---|---|
| US | NBC, ABC, CBS (each {us@}, excluding news), FOX affiliates (fox.us@), FOX Sports, ESPN, NHL {us@}, NFL, NBA {us@}, MLB, hockey {us@}, basketball |
| Canada | CBC, CTV, TSN, Sportsnet, TVA, RDS, NHL, hockey (each {ca@}), MSG {us@} |
Filters only decide what Threadfin can see. A channel reaches Plex once it is active in Threadfin's Mapping tab. New channels with a guide entry are activated automatically. Channels whose upstream does not respond should be switched off there.
MSG is a US channel but lives on the Canadian tuner: its stream host refuses connections from the NordVPN US exit and answers from the Canadian one.
Guide data¶
iptv-epg runs ghcr.io/iptv-org/epg, which scrapes listings from the sites in iptv-org's
epg project. Its channel list is generated:
cd heezy-k8s
python3 scripts/build-iptv-epg-channels.py # rewrites apps/iptv-epg/channels-configmap.yaml
The script reads both Threadfin seed ConfigMaps (apps/threadfin*/configmap.yaml), downloads their
playlists, applies their filters the way Threadfin does, and finds a guide source for each channel.
It writes the playlist's own tvg-id as each entry's xmltv_id. That is what lets Threadfin match
channels to listings with no manual mapping. Rerun it and commit the result after changing a seed's
playlists or filters.
| Source | Used for |
|---|---|
| tvpassport.com | local affiliates (matched by call sign, e.g. ABC (KAAL) Austin, MN), most cable networks, CBC, TVA, MSG |
| i.mjh.nz | free streaming channels (NFL Channel, NBC Sports NOW, CBS Sports HQ, TSN The Ocho, ...) |
| tvguide.com | FOX Sports 1 |
| clarotvmais.com.br, meuguia.tv | the Brazilian ESPN feeds |
tvtv.us is excluded: its grabber queries one fixed New York lineup and returns 404 for everything else. FloHockey, MLB Strike Zone and CTV 2 Atlantic have no source and show one-hour placeholder blocks.
Schedule (UTC): the grabber runs at startup and at 03:00 and 15:00, fetching 3 days. The Threadfins
refresh their playlists and guide at 04:00 and 16:00. A full run takes about 3 minutes. Until the
first run after a restart finishes, guide.xml returns 404.
iptv-epg resolves DNS through the dnsmasq resolver directly (dnsPolicy: None), not CoreDNS:
CoreDNS answers www.tvpassport.com with 0.0.0.0, which broke 90 channels' listings.
Game alerts¶
hockey-notify (CronJob, 11:00 America/New_York) posts the day's NHL slate to
Discord. It takes broadcasters from the NHL schedule API, finds candidate
channels by network code or by a guide listing of the live game, and test-plays
each one through its Threadfin. A game with a channel that played lists that
channel. Any other game says "Not on your tuners" and lists the league's
broadcasters by country, regional ones tagged with their team, as in
US: NESN (BOS). It posts every day, including days without games, so a
missing post means the job did not run. Source and the network mapping table:
heezy-containers/dockerfiles/hockey-notify/.
What it can find depends on the lineup: national ABC games (any working ABC station), NHL Network, MSG (Rangers, plus Islanders and Devils games the guide shows on it), and FOX stations carrying a team locally (KDFW Stars, KTTV Kings). TNT, Sportsnet, TSN, TVA Sports and US ESPN are not in the playlist.
Settings that matter¶
Threadfin's config lives on its PVC. On first boot, an init container copies the seed from the ConfigMap; after that, changes made in the web UI are kept. These settings in the seed are not optional:
| Setting | Why |
|---|---|
playlist buffer: ffmpeg |
Read per playlist, not from the top-level key. With none, every stream returns 200 with no data. With -, Threadfin redirects Plex to the upstream URL, outside the VPN. |
playlist tuner: 4 |
Per-playlist tuner count. The default is 1. |
ffmpeg.options without -map 0:v |
Threadfin's default copies every HLS quality level into one stream. Plex got five video tracks and showed "this program has ended". |
httpThreadfinDomain |
Written into stream URLs. Left empty, it uses the pod IP, which changes on every restart. |
bindIpAddress: 0.0.0.0 |
Threadfin otherwise binds one interface, and when gluetun is up first that is tun0, unreachable from Plex. The init container enforces this on every start. |
enableServiceLinks: false (pod spec) |
The threadfin Service would inject THREADFIN_PORT=tcp://... over the image's own port variable, and the web server fails to start. |
Access¶
Both web UIs are LAN only. The threadfin*.heezy.info names exist only in dnsmasq, with no
Cloudflare record. The SWAG confs also allowlist the LAN and pod ranges and deny everything else,
including the in-pod cloudflared, so a public record created by mistake would still get a 403.
Known gaps¶
- CBC is unavailable. The playlist's CBC URLs return 404, and CBC's own streams return 403 without a CBC Gem login, even from a Canadian address.
- Sinclair-owned stations return 503 through the VPN (WJLA, WLOS, WPDE, WSYX, KUTV, WKRC, WPEC, WWMT, KSNV, WJAC, WOAI). They appear to block VPN address ranges.
- The playlist has no TSN 1 to 5 or Sportsnet streams. The filters would pick them up if iptv-org adds them.
Troubleshooting¶
A stream drops for everyone at once. Saving any setting in Threadfin's UI or API resets its stream buffer and ends every active stream on that tuner. Make setting changes when nothing is playing.
Every channel says "this program has ended". Check that ffmpeg.options has no -map 0:v, and
that the stream returns data:
kubectl exec -n heezy deployment/plex -c plex -- \
curl -s -m 15 -o /dev/null -w '%{http_code} %{size_download}\n' \
http://threadfin.heezy.svc.cluster.local:34400/stream/<id>
A 200 with 0 bytes means the playlist has no buffer set.
Plex scan finds no channels. Nothing is active in Threadfin's Mapping tab, or every stream was dropped by the 480-stream limit (check the filter list).
Connection refused to a tuner. Threadfin is bound to tun0. Check that bindIpAddress is
0.0.0.0 in /home/threadfin/conf/settings.json.
A tunnel loops on AUTH_FAILED with credentials that work elsewhere. gluetun picked a NordVPN
Dedicated IP server, which refuses accounts without that add-on, and keeps retrying it. The US
tunnel sets SERVER_CATEGORIES: Standard VPN servers and has a liveness probe on gluetun's
healthcheck that restarts the container after about 3 minutes down. The Canadian tunnel is pinned to
Montreal, which has worked; it does not have the filter or the probe yet.
Threadfin crash-loops with FATAL ERROR on a fresh PVC. It treats a failed first guide
download as fatal. It recovers on its own once iptv-epg finishes its first run.