# 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.
