Skip to content

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.