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.
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.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 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), neverlocalhost. Owned byserver-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_urlunset, 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. Settingiptv.base_urlpins the M3U + XMLTV URLs to a fixed public origin regardless of the requestHost, which is the mitigation for a reverse-proxy / split-DNS deployment (server-management). The HDHomeRun lineup URLs are not covered byiptv.base_url(deliberately out of scope for #340) and still echo the request host. - 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 — this is exactly howiptv.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: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) — 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
ConfigElementunderConfigElementKey.IptvBaseUrl(keyiptv.base_url, key/value, no EF migration). Distinct from theETV_BASE_URLenvironment variable, which only sets the ASP.NET CorePathBase(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 returnsOption<(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 toNone.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, viaChannelPlaylist) andGetChannelGuideHandler(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/iptvonSettingsController(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.