Plex Live TV / Threadfin EPG¶
The tv Namespace at a Glance¶
The EPG chain that fills most of this page is one corner of a larger namespace. tv runs the full media stack — acquisition, serving and transfer — around a set of shared Ceph volumes:
flowchart LR
subgraph acq["Acquisition"]
arr["sonarr · radarr · prowlarr<br/>bazarr · autobrr · flaresolverr<br/>seerr · tdarr"]
tr["transmission"]
end
subgraph serve["Serving"]
tf["Threadfin"]
pms["Plex Media Server"]
end
subgraph xfer["Transfer"]
sftp["sftpgo"]
rs["rsync"]
end
subgraph cephfs["CephFS RWX — shared library"]
lib["tv-v3-ceph<br/>(working library)"]
finish["tv-finish-film-ceph<br/>tv-finish-serie-ceph<br/>(sorted output)"]
end
scratch["Ceph RBD RWO — per-app scratch<br/>tv-incomplete-ceph · pms-docker-transcode-ceph · tdarr-temp-ceph"]
tr --> scratch --> finish
arr --> finish
tf --> pms
lib --> pms
lib --> sftp
lib --> rs
pms -.-> scratch
| Component | Role | LoadBalancer |
|---|---|---|
pms |
Plex Media Server, including Live TV / DVR | 192.168.1.46 |
transmission |
Download client | .47 (peer port) |
sftpgo (+ web UI) |
SFTP ingest / egress | .57 |
rsync |
Bulk transfer endpoint | .56 |
| sonarr · radarr · prowlarr · bazarr · autobrr · flaresolverr · seerr · tdarr | Acquisition, indexing, subtitles, request handling, transcode management | cluster-internal |
threadfin |
HDHomeRun-emulating IPTV/EPG bridge | cluster-internal |
The media library now lives entirely on the in-cluster Ceph tier via Rook — no Longhorn volume is involved in the bulk-media path any more. Three CephFS (RWX) volumes carry the shared library: tv-v3-ceph, the working tree that Plex, Threadfin, sftpgo and rsync all read from, and the tv-finish-film-ceph / tv-finish-serie-ceph pair that the acquisition side sorts finished downloads into. A separate set of Ceph RBD (RWO) volumes gives each app its own scratch space — tv-incomplete-ceph for transmission's in-flight downloads, pms-docker-transcode-ceph for Plex's transcode cache, tdarr-temp-ceph for Tdarr's working files — so no app's temporary I/O contends with another's on the same volume. See Storage Architecture for the underlying storage classes.
The EPG Chain¶
Plex DVR pulls IPTV streams from a single Threadfin instance (HDHomeRun
emulator) fed by the WebTV M3U (public: iptv-ch.github.io/webtv.m3u, HTTPS
unicast). Since the 2026-08-11 grand curation the lineup is a hand-audited
122 channels — every one stream-probed alive and EPG-mapped — numbered by
language: 1xx Italian, 2xx French, 3xx English (x-category = language,
shown as groups in Plex). Lineup reference: livetv-final-lineup-2026-08-11.tsv
(home dir / NAS archive).
threadfin-bis (BlueTV) was retired 2026-08-11. All 601 of its channels were RTP multicast (
239.186.x.x), which reaches neither the pod network nor the router — it never actually worked.
Threadfin ingests the source XMLTV feed (iptv-epg.org, ~15 700 channels,
~720 MB — note: all channel ids are suffixed .ch regardless of origin,
and programme lang= attributes are unreliable for language classification)
and emits a filtered threadfin.xml that Plex's XMLTV grabber consumes. Plex
stores the resulting airings in tv.plex.providers.epg.xmltv-<uuid>.db
alongside its main com.plexapp.plugins.library.db.
Threadfin channel mapping (xepg.json)¶
Each entry in /home/threadfin/conf/xepg.json represents a lineup channel. The critical field is x-mapping: it must be a real channel ID from the source XMLTV (e.g. SRF1.ch, NDR.de, BBCTwo.ch). Threadfin only emits <programme> entries for channels whose x-mapping resolves to a <channel id="X"> in the source XML.
Auto-generated values like x-mapping: "threadfin-<hex>" are placeholders that do not match any source channel — the channel renders in the lineup but its XMLTV output contains zero programs, so Plex shows "Unknown Airing" for every slot.
Symptoms of broken mappings:
| Symptom | Likely cause |
|---|---|
| All channels show "Unknown Airing" in Plex Guide | Plex EPG DB empty, or every x-mapping is threadfin-<hex> |
| Many channels show the same program | Multiple x-mapping values point to a single source ID |
| One channel shows no EPG | x-mapping: "-" or refers to a stale source channel |
The match between tvg-name / name in xepg.json and the source's <display-name> requires normalization:
- German umlauts:
Züri↔Zueri↔Zuri(source usesü; M3U providers may use either digraph) - German number-words:
eins/zwei/drei↔1/2/3(e.g.ORF1↔ORFeins.ch) - Country tags on M3U names:
TMC CH→ sourceTMC.frorTMCMonteCarlo.ch - Resolution markers: strip
HD,UHD,720p,1080pbefore comparison
Appendix — Operations & Troubleshooting¶
Everything below is runbook material for the EPG chain, kept here because when the guide breaks these are the exact levers.
Threadfin UI channel-number display¶
The web UI multiplies x-channelID by 10 for display. Stored 1149 shows as 11490 in the Mapping page. Inactive channels typically appear at these high numbers — they are the deactivated entries in xepg.json (with x-active: false), not pending source channels.
Forcing a Plex EPG re-ingest¶
Plex's XMLTV grabber refreshes on its own ~every 12 h. A pod restart alone does not force a fresh ingest if Plex thinks the current EPG is recent. To force a full re-ingest, reset the timestamps in media_provider_resources (the EPG provider row, identifier='tv.plex.providers.epg.xmltv') and restart Plex:
-- Inside Plex SQLite, against com.plexapp.plugins.library.db
UPDATE media_provider_resources
SET extra_data = json_set(
json_set(
json_set(extra_data,
'$."pv:timeOfLastRefresh"', '0'),
'$."pv:lastChunkEndedAt"', '0'),
'$."url"',
REPLACE(REPLACE(json_extract(extra_data,'$."url"'),
'pv%3AlastChunkEndedAt=<old_value>', 'pv%3AlastChunkEndedAt=0'),
'pv%3AtimeOfLastRefresh=<old_value>', 'pv%3AtimeOfLastRefresh=0')
),
updated_at = strftime('%s','now')
WHERE identifier='tv.plex.providers.epg.xmltv';
The url field inside extra_data is a URL-encoded mirror of the same JSON values — both layers must be reset for the grabber to treat the EPG as stale.
Plex re-ingest of the full ~20k-program XMLTV takes 5–7 minutes (~400 s database time). Watch for EPG[xmltv]: Step 1/1 and Total time to load EPG in Plex Media Server.log.
Operating on xepg.json¶
The file is JSON — but Threadfin reads it on startup and only writes back when changed via its own UI. Editing the file on disk + restarting the pod is safe:
POD=$(kubectl -n tv get pods -l app=threadfin -o jsonpath='{.items[0].metadata.name}')
kubectl -n tv cp "$POD":/home/threadfin/conf/xepg.json /tmp/xepg.json
# edit /tmp/xepg.json (keep ownership/perms intact)
kubectl -n tv exec "$POD" -- cp /home/threadfin/conf/xepg.json /home/threadfin/conf/xepg.json.bak
kubectl -n tv cp /tmp/xepg.json "$POD":/home/threadfin/conf/xepg.json
kubectl -n tv exec "$POD" -- chown root:root /home/threadfin/conf/xepg.json
kubectl -n tv rollout restart deployment/threadfin
Threadfin regenerates threadfin.xml on startup (~1–2 min for ~15 700 source channels) — wait for XEPG: Ready to use in the pod logs before triggering a Plex re-ingest.
Plex Live TV API endpoints¶
GET /livetv/dvrs (list, with ChannelMapping detail) works in 1.43+ —
use it read-only with the PlexOnlineToken from Preferences.xml. The
refresh/rescan POST endpoints still 404; use the SQL recipes here instead.
Plex's DVR channel map lives in the TUNER row's extra_data (2026-08-11)¶
media_provider_resources id=1 (the Threadfin tuner device) carries three
fields keyed to channel numbers: pv:channelMapping +
pv:channelMappingByKey (base64 of N=N&N=N&…) and pv:channelsEnabled
(CSV), all mirrored again inside the url-encoded $.url value. Renumbering
the Threadfin lineup orphans every entry and the next EPG ingest loads ZERO
channels (fast run, "0 HTTP errors", empty guide). Fix: rebuild all three
fields (+ regenerate the url mirror from the other keys), then run the EPG
timestamp reset and restart Plex. The DVR row (id=13) extra_data also holds
pv:additionalLineups — remove retired tuners' lineups from there or the
grabber aggregates a dead URL.
Threadfin operational gotchas (fork-verified 2026-08-11)¶
- Filters: a
custom-filter's JSONexclude/includefields are IGNORED — the expression goes inside thefilterfield itself, e.g."filter": "!{srf1 hd,srf2 hd,atv hd}". Defining ANY filter flips allow-all to must-match; an empty expression rejects EVERY stream. - Auto-reactivation: channels whose
tvg-idexactly matches an XMLTV id are re-mapped AND re-activated on every M3U update — deactivation does not stick; exclude them at ingest with the filter above. - Per-group emission cap (fork buildM3U): emitted-per-group is capped at
expected − deactivated, which silently drops survivors of a heavily curated group. Bypass: setx-categoryon every active channel (categories are outside the cap map). The same code dedupes exact stream URLs per group — genuinely useful (it exposed Food Network IT/E and CBBC/CBeebies as single provider streams). - Entry binding: xepg entries bind to streams by
tvg-name== current M3U name; provider renames orphan entries (channel vanishes from output). Recreate the entry with the current name, and give hand-created entries a uniquechannelUniqueID— duplicates silently drop channels from the M3U output.