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>
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.cs → ToM3U()
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 identifiertvg-chno— channel number for orderingtvg-name— channel display nametvg-logo— logo URL (uploaded artwork or generated text logo)tvc-stream-vcodec/tvc-stream-acodec— codec hints from FFmpeg profileCUID— base64-encoded UniqueIdgroup-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:
- Reads cached fragments from disk
- Substitutes
{RequestBase}with the actualscheme://host/basefrom the request - Substitutes
{AccessTokenUri}with version + optional auth token - Filters channels by
ShowInEpg - 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)GetChannelGuideHandlersubstitutes{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), neverlocalhost. Owned byserver-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
ConfigElementtable as simple key/value (ConfigElementKeyinErsatzTV.Core/Domain/ConfigElementKey.cs; read/write viaIConfigElementRepository.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:8409returns correctersatztv:8409URLs (nolocalhost). - All 44 cached guide fragments under
/config/cache/channel-guide/*.xmlcontain the{RequestBase}placeholder — zero bakedlocalhost. Nothing is persisted absolute. - Dispatcharr's M3U + EPG output contain 0
localhost; sample logo and icon both fetch HTTP 200image/pngfrom 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()inStartup.csmakingX-Forwarded-*be ignored) is incorrect: clearing bothKnownProxiesandKnownNetworkssetscheckKnownIps = 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.