Skip to content

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 → source TMC.fr or TMCMonteCarlo.ch
  • Resolution markers: strip HD, UHD, 720p, 1080p before 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 JSON exclude/include fields are IGNORED — the expression goes inside the filter field 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-id exactly 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: set x-category on 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 unique channelUniqueID — duplicates silently drop channels from the M3U output.