Files
ersatztv/docs/m3u-xmltv.md
T
timothy d6aec6de76
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 33s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 34s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
docs(release): prepare v26.11.0 promotion — correct the deploy host to jazz
The media transcoders (Jellyfin, ersatztv, ersatztv-test) moved from bumblebee
to jazz (192.168.1.29) on 2026-07-20, server-management#633. Our docs still sent
the release operator to bumblebee for security-scan.sh and named it as the docker
host, which would have scanned the wrong box.

Also fixes the inverse error: `jazz` was an EARLIER name for the .99 host, so
three pre-migration references meaning today's bumblebee read as jazz. Those are
now bumblebee, and a Hosts table + name-reuse warning is added at the top of
ci-cd.md so the next session resolves hostnames by IP rather than re-breaking it
in either direction.

Version table had drifted (stopped at v26.9.0) — adds v26.10.0 + v26.11.0.
2026-07-20 19:18:03 +02:00

12 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 bumblebee (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 now has an optional "advertised base URL" setting for IPTV (iptv.base_url, #340 — see "IPTV base URL" below). When it is unset (the default), every absolute URL ErsatzTV emits is derived from the incoming request's Scheme + Host + PathBase, exactly as described below; when it is set, the two IPTV generators (M3U + XMLTV) use the configured scheme/host/base instead:

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:

  • With iptv.base_url unset, any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) still gets that host echoed back into stream + logo + artwork URLs. Setting iptv.base_url pins the M3U + XMLTV URLs to a fixed public origin regardless of the request Host, which is the mitigation for a reverse-proxy / split-DNS deployment (server-management). The HDHomeRun lineup URLs are not covered by iptv.base_url (deliberately out of scope for #340) and still echo the request host.
  • 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 — this is exactly how iptv.base_url (ConfigElementKey.IptvBaseUrl) was added.

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) — now configurable via iptv.base_url (#340)

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. By default every absolute URL is still hostage to the request Host, so if a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns.

The mitigation is now built-in: set the IPTV base URL (iptv.base_url, see below) to a fixed public origin and the M3U + XMLTV URLs stop depending on the request Host entirely. It stays opt-in — unset behaviour is byte-for-byte the request-derived behaviour described above, so nothing changes for a deployment that doesn't need it.

IPTV base URL (#340)

Originally logged here as "optional future hardening (NOT committed)"; implemented in #340. An optional advertised base URL for the IPTV surface:

  • Config: stored as a single ConfigElement under ConfigElementKey.IptvBaseUrl (key iptv.base_url, key/value, no EF migration). Distinct from the ETV_BASE_URL environment variable, which only sets the ASP.NET Core PathBase (request routing) and does not advertise a host.
  • Helper: a pure Core helper, ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs.
    • TryParse(string) validates the configured value and returns Option<(scheme, host, baseUrl)> — it must be an absolute http(s) URL with no credentials, query, or fragment; the port and any path prefix are preserved, and a trailing slash is normalized off. Anything that fails these rules parses to None.
    • Resolve(configured, requestScheme, requestHost, requestBaseUrl) returns the parsed override when the configured value is present and valid, and otherwise falls back to the request-derived scheme/host/base. So a blank or malformed value is transparently ignored.
  • Where it's applied: resolution happens inside the two generation handlers, not in the controller — GetChannelPlaylistHandler (M3U guide/logo/stream URLs, via ChannelPlaylist) and GetChannelGuideHandler (both XMLTV {RequestBase} substitution sites). Because unset/invalid falls back to the request values, the golden tests (ChannelPlaylistGoldenTests, ChannelGuideGoldenTests) are unchanged.
  • Scope: M3U + XMLTV only. Deliberately NOT applied to the HDHomeRun lineup URLs (out of scope for #340).
  • API + UI: managed through a new "IPTV" settings group — GET/PUT /api/v1/settings/iptv on SettingsController (tier [RequiresAuthentication]), body { baseUrl }. A blank value clears the key; a non-blank malformed value → 422. A new "IPTV" section on the SPA Settings screen edits it.

Covered by unit tests on AdvertisedBaseUrl (parse/validate/resolve) plus the unchanged M3U/XMLTV goldens proving unset = today's output.

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.