Files
ersatztv/docs/m3u-xmltv.md
T
timothyandClaude Opus 4.8 a638b4f54c
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m37s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m40s
docs: close #1 — M3U localhost is config/topology, not a code bug
Re-investigated ersatztv#1 against the live stack: ErsatzTV emits
request-host-driven URLs (44 cached guide fragments use {RequestBase},
zero baked localhost), and Jellyfin no longer tuners ErsatzTV directly —
it funnels through Dispatcharr. The historical localhost came from a
client fetching with Host: localhost:8409, a path that no longer exists.

Document the live topology + evidence + resolution; reframe the optional
base-URL setting as not-built; note the latent fragility. Remove the now
spent handoff doc.

refs #1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:01:08 +02:00

9.9 KiB

M3U/XMLTV Integration

Overview

ErsatzTV serves M3U playlists and XMLTV guide data via HTTP endpoints. Jellyfin consumes these as an IPTV tuner source.

Endpoints

Route Purpose
GET /iptv/channels.m3u M3U playlist (channel list + stream URLs)
GET /iptv/xmltv.xml XMLTV guide (EPG data)
GET /iptv/logos/{fileName}.jpg Uploaded channel logos
GET /iptv/logos/gen?text={name} Generated text logos (SkiaSharp, 200x100 PNG)
GET /iptv/channel/{number}.ts Transport stream
GET /iptv/channel/{number}.m3u8 HLS stream

All defined in ErsatzTV/Controllers/IptvController.cs (streams) and ErsatzTV/Controllers/ArtworkController.cs (logo generation).

M3U Generation

Code: ErsatzTV.Core/Iptv/ChannelPlaylist.csToM3U()

The controller captures Request.Scheme, Request.Host, and Request.PathBase from the incoming HTTP request and passes them through MediatR to the playlist builder. All URLs in the M3U output use the request-derived host — they are not hardcoded.

Each channel entry includes:

  • tvg-id — channel number as identifier
  • tvg-chno — channel number for ordering
  • tvg-name — channel display name
  • tvg-logo — logo URL (uploaded artwork or generated text logo)
  • tvc-stream-vcodec / tvc-stream-acodec — codec hints from FFmpeg profile
  • CUID — base64-encoded UniqueId
  • group-title — channel group for categorization
  • Stream URL (format depends on channel's streaming mode)

The XMLTV guide URL is included as an #EXTM3U header attribute.

XMLTV Generation

Code: ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs

XMLTV data is pre-generated from Scriban templates (ErsatzTV/Resources/Templates/_channel.sbntxt) and cached as XML fragments. At request time, the handler:

  1. Reads cached fragments from disk
  2. Substitutes {RequestBase} with the actual scheme://host/base from the request
  3. Substitutes {AccessTokenUri} with version + optional auth token
  4. Filters channels by ShowInEpg
  5. Combines channel list + program entries into the final XML

Jellyfin Integration

Current homelab topology (2026-06): Jellyfin does not tuner ErsatzTV directly — it funnels ErsatzTV through Dispatcharr (dispatcharr:9191), which ingests ErsatzTV and re-serves the channels/EPG. The direct-tuner setup below is the generic reference for pointing any tuner at ErsatzTV; see "Resolution (2026-06-27)" under the Logo URL Issue for the live pipeline.

Tuner Setup (direct — reference)

To point an IPTV tuner directly at ErsatzTV:

http://ersatztv:8409/iptv/channels.m3u

Jellyfin reads the M3U to discover channels and their stream URLs. The Host header in Jellyfin's request determines what host appears in all embedded URLs — so the hostname used in the tuner URL matters.

Guide Data

Jellyfin fetches the XMLTV URL embedded in the M3U header for EPG data. This provides program titles, descriptions, and timing for the TV guide.

Guide Refresh

Jellyfin periodically refreshes the guide data. Channel logos are fetched from the tvg-logo URLs in the M3U. If those URLs are unreachable from Jellyfin's network context, logos break.

Logo URL Issue (Gitea #1)

The Problem

Channel logos break after Jellyfin guide refreshes. The issue was originally reported as hardcoded localhost in tvg-logo URLs.

Investigation Findings

The M3U and XMLTV generation paths correctly use request-derived host:

  • ChannelPlaylist.ToM3U() builds logo URLs from _scheme, _host, _baseUrl (all from the request)
  • GetChannelGuideHandler substitutes {RequestBase} at request time

The actual hardcoded localhost is in ChannelLogoGenerator.GenerateChannelLogoUrl() (ErsatzTV.Core/Images/ChannelLogoGenerator.cs, line 82):

$"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}"

This method is called only by WatermarkSelector.cs (lines 219, 268, 317, 385) — for watermark overlays during transcoding, not for M3U/XMLTV output. Since FFmpeg runs on the same host as ErsatzTV, localhost is correct for watermarks.

Confirmed Root Cause (2026-06-26)

Live experiment against the running ersatztv container on jazz (192.168.1.99:8409) proves the host in tvg-logo is entirely a function of the incoming Host header — there is no hardcoded localhost in the M3U/XMLTV path:

curl -H "Host: localhost:8409" .../iptv/channels.m3u  ->  tvg-logo="http://localhost:8409/iptv/logos/...jpg"
curl -H "Host: ersatztv:8409"  .../iptv/channels.m3u  ->  tvg-logo="http://ersatztv:8409/iptv/logos/...jpg"

So the http://localhost:8409 URLs that broke in Jellyfin were baked in because a client fetched ErsatzTV using Host: localhost:8409 (a misconfigured tuner/XMLTV URL in Jellyfin's Live TV settings, or a proxy passing that Host). The original issue framing ("hardcoded localhost in code") is a red herring — see Investigation Findings above.

This is therefore not a code bug in the M3U/XMLTV generators. The fix is operational, not code:

  • Operational (server-management): Jellyfin's IPTV source must reach ErsatzTV via a host that is resolvable and correct from Jellyfin's network context (e.g. http://ersatztv:8409), never localhost. Owned by server-management (related: its issue #171).

Architecture: absolute-URL generation (relevant for future work)

ErsatzTV has no "advertised/public/base URL" setting. Every absolute URL it emits is derived from the incoming request's Scheme + Host + PathBase:

Output Source of host
M3U stream URLs & tvg-logo ChannelPlaylist ctor args _scheme/_host/_baseUrl, captured in IptvController.GetChannelPlaylist from Request.Scheme/Request.Host/Request.PathBase
XMLTV channel <icon> and programme artwork {RequestBase} placeholder in the cached .xml fragments, substituted in GetChannelGuideHandler with request.Scheme://request.Host{request.BaseUrl}
FFmpeg watermark overlay logo ChannelLogoGenerator.GenerateChannelLogoUrl() — hardcoded http://localhost:{StreamingPort} (correct: FFmpeg runs in-container). Not part of M3U/XMLTV.

Implications for future work:

  • Any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) gets that host echoed back into stream + logo + artwork URLs. This affects the planned REST API (#2) and any reverse-proxy fronting (server-management) too.
  • Settings are stored in the ConfigElement table as simple key/value (ConfigElementKey in ErsatzTV.Core/Domain/ConfigElementKey.cs; read/write via IConfigElementRepository.GetValue<T> / upsert). Adding a new setting needs no EF migration.

Resolution (2026-06-27) — Gitea #1 closed as config/topology, no code change

A second live investigation (against the running stack on bumblebee, 192.168.1.99) confirmed the original symptom is not reproducible in the current architecture, because the topology changed since #1 was filed (Feb 2026):

Jellyfin no longer tuners ErsatzTV directly. Per Jellyfin's livetv.xml, its only Live TV source is Dispatcharr (http://dispatcharr:9191/output/m3u + /output/epg). ErsatzTV is funneled through Dispatcharr:

ErsatzTV ──(Dispatcharr fetches via ersatztv:8409)──▶ Dispatcharr ──▶ Jellyfin
  • channel logos   → Dispatcharr re-hosts as http://dispatcharr:9191/...   (HTTP 200 image/png ✓)
  • programme icons → passed through as http://ersatztv:8409/...  (3095 of them, HTTP 200 image/png ✓)
  • localhost references anywhere Jellyfin consumes: 0

Evidence gathered (all read-only):

  • ErsatzTV fetched via ersatztv:8409 returns correct ersatztv:8409 URLs (no localhost).
  • All 44 cached guide fragments under /config/cache/channel-guide/*.xml contain the {RequestBase} placeholder — zero baked localhost. Nothing is persisted absolute.
  • Dispatcharr's M3U + EPG output contain 0 localhost; sample logo and icon both fetch HTTP 200 image/png from inside the Jellyfin container.

Root cause = configuration/topology, not code. The historical localhost:8409 came from a client fetching ErsatzTV with Host: localhost:8409; that path no longer exists. Closed without a code change.

Note — the forwarded-headers theory (KnownProxies.Clear() in Startup.cs making X-Forwarded-* be ignored) is incorrect: clearing both KnownProxies and KnownNetworks sets checkKnownIps = false, which trusts all proxies, not none. And there is no proxy between Jellyfin and ErsatzTV. Red herring.

Latent fragility (not currently broken)

The EPG Jellyfin consumes still contains 3095 absolute http://ersatztv:8409/... programme-icon URLs. They work only because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. ErsatzTV still has no advertised/base-URL setting — every absolute URL is hostage to the request Host. If a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns.

Optional future hardening (NOT committed)

If that fragility is ever worth removing: add an optional iptv.base_url ConfigElement (key/value, no EF migration) + a "Base URL" settings field; when set, GetChannelPlaylistHandler / GetChannelGuideHandler parse it into scheme/host/base instead of the request values; when blank, behaviour is unchanged. Cover with unit tests on ChannelPlaylist.ToM3U(). Deliberately not built for #1 — the problem no longer manifests, so this stays a documented option rather than committed work.

Historical Workaround (pre-Dispatcharr)

Before the Dispatcharr funnel, a script downloaded logos from ErsatzTV and base64-uploaded them directly to Jellyfin's /Items/{id}/Images/Primary API, bypassing the M3U logo URLs. Documented in server-management (issue #171). No longer needed for the current pipeline.