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
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.
164 lines
12 KiB
Markdown
164 lines
12 KiB
Markdown
# 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 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):
|
|
```csharp
|
|
$"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.
|