21 — Media apps (Jellyfin / tvheadend / Invidious) + Home Assistant¶
Stands up the three media services as AlmaLinux 9 + podman quadlets (the locked clean-build path, same machinery as Forgejo/SearXNG/FMD), plus the Home Assistant OS appliance VM on the SERVERS VLAN (HAOS is an appliance, like OPNsense — not a quadlet-managed EL box).
The media stack¶
| Host | What it is | Home | How |
|---|---|---|---|
jellyfin |
Jellyfin media server | EL9 quadlet (9123, SERVERS .30) |
clean EL9 quadlet; library via NFS from the fileserver |
tvheadend |
DVR/tuner backend, USB tuner 2040:826d (Hauppauge) |
Debian VM (9124, SERVERS .31) |
EL9 lacks the lgdt3306a demod, so Debian + LinuxServer quadlet; claims the USB tuner + serves Jellyfin Live TV |
invidious |
privacy YouTube frontend | EL9 quadlets (9125, DMZ .70) |
app + pg + companion quadlets |
haos |
Home Assistant OS + Zigbee USB 10c4:ea60 |
HAOS appliance VM (105, SERVERS .45) |
appliance VM on the SERVERS VLAN; Zigbee via USB coordinator |
Tuner split is the point. The DVR backend (tvheadend) is separated from the player (Jellyfin): tvheadend owns the USB tuner and feeds Jellyfin's Live TV over HTSP.
Architecture¶
- Jellyfin — SERVERS
10.20.20.30:8096. Config/metadata in/var/lib/jellyfin; the movie/TV library is an NFS automount from the fileserver (nfs_mountsinhost_vars/jellyfin.yml), not local disk. CPU transcode (pass/dev/drilater for VAAPI/QSV). Reached by Roku/IOT (8096, rb06), WG users (rb07), andjellyfin.${domain}via the apps-proxy (Tier 2, internal split-horizon).Auth = FreeIPA LDAP, not Authelia SSO. Jellyfin's clients are native apps (Roku/mobile) that can't do a forward-auth/OIDC browser redirect, so Jellyfin uses its LDAP plugin bound to FreeIPA for accounts — same UID/identity, no SSO.
- tvheadend — SERVERS
10.20.20.31. Debian VM (template 9000) — RHEL/EL9 stripped the DVB stack and ELRepo'skmod-medialacks thelgdt3306ademod this Hauppauge WinTV-dualHD needs; Debian ships it in-kernel so/dev/dvb(dual adapters) works OOTB. Runs the LinuxServer tvheadend quadlet with/dev/dvbpassed in (thebackendsrole is OS-agnostic for podman/NFS;common/alloyare EL-only and skipped here). Web/HTSP9981, HTSP streaming9982. Recordings → fileserver NFS. Jellyfin's Live TV points at10.20.20.31:9981(intra-SERVERS, no fw rule). - Invidious — DMZ
10.20.30.70:3000, quadlets on a private podman net (invidious+invidious-dbpostgres +invidious-companion— the YouTube innertube/PoToken/signature backend). Tier-2 private, served via the apps-proxy athttps://invidious.${domain}(like Jellyfin) — a split-horizon Unbound override → apps-proxy reverse-proxies to this backend:3000(needs the SERVERS→DMZ allow, rb06). Not behind the origin Caddy / Authelia and not delegated through an edge; also reachable directly athttp://10.20.30.70:3000on the LAN. FreeTube can use it as its Invidious backend. - Home Assistant — SERVERS
10.20.20.45:8123(appliance VM, not EL9/quadlet). Zigbee/Z-Wave devices ride the USB coordinator (radio, not network), so HA doesn't need L2-adjacency to the IoT VLAN; the IP IoT devices are reached via an explicitHOMEASSISTANT → IOTallow + mDNS reflection. Reached by clients at:8123(rb06 §WS),ha.${domain}via the apps-proxy (Tier 2), and the HA companion app over WG when away.
Egress note — Invidious → YouTube (single Proton exit, NOT the LB group)¶
Invidious hammers YouTube/Google, which frequently blocks ProtonVPN exit IPs — and,
sharper than that: YouTube PO tokens are bound to the session's exit IP, so the
companion's session breaks whenever the load-balanced Proton group re-homes the host
onto a different tunnel (sticky expiry), even to a non-blocked exit. Observed
2026-07-10→12: days of flapping Failed to validate PO token in the companion journal,
then hard-down when stickiness parked the host on a Google-rejected exit → every watch
page "Video unavailable" while search still worked (browse API isn't PO-token-gated).
Fix (2026-07-12): the invidious host's internet egress is pinned to a single
Proton tunnel via an automation-filter rule — INVIDIOUS → !RFC1918, gateway
PROTON_GW2 (provision/opnsense/fw-automation-rules.json, applied by
scripts/opnsense-fw.py). Still all-via-Proton, still fail-closed; the rule only
removes the per-connection exit hopping for this one host. If that exit's reputation
goes bad (token failures return): rotate gateway to another PROTON_GW* in the
JSON, apply, then restart companion with a cleared session cache:
systemctl stop invidious invidious-companion && rm -rf /var/lib/invidious/companion-cache/* && systemctl start invidious-companion invidious.
Also try bumping the image digests first (see the caveat in
ansible/roles/backends/defaults/main.yml — upstream ships cat-and-mouse fixes often).
Detection is codified (2026-07-12): the Grafana rule fleet-invidious-po-token
(runbook 27) watches the companion's journal in Loki for PO-token failure lines and pages
ntfy lab-alerts within ~10–30 min of a recurrence — the 2026-07-10→12 incident ran ~2
days undetected because the companion's unit stays green while playback dies. An ntfy
page from that rule is the trigger for the rotation procedure above.
Last resort only (operator decision — exposes the home IP to Google): add Google's
ranges to the VPN_BYPASS alias (runbook 05 §6 / runbook 06 §IOT) so only
invidious→Google exits the real WAN. Don't pre-open it.
Triage first: which of the TWO failure modes is it? (added 2026-07-30)¶
The egress/PO-token story above is only one of two ways this instance dies, and they
need opposite fixes. Hit /api/v1/search first — it is the discriminator, because
search/trending/comments are innertube calls invidious makes itself; only the
player/PO-token path goes through companion.
| (a) exit-IP / PO-token | (b) stale image vs a YouTube change | |
|---|---|---|
/api/v1/search |
works (200) | broken (400/500) |
playback / /watch |
broken | broken |
| companion journal | Failed to validate PO token |
Successfully validated PO token (clean!) |
| error text | "Video unavailable" | Youtube API returned status code 400 |
| both units | green | green |
| fix | rotate the pinned Proton gateway (above) | bump the image digests |
Incident 2026-07-30 — mode (b). Every innertube-backed endpoint 400'd
(/api/v1/videos + /watch → 500; search/trending → 400/500) while both units were green
and the companion validated PO tokens cleanly every 5 min — so the fleet-invidious-po-token
rule correctly stayed silent, and nothing paged. Root cause: the pinned images were from
Jul 12; YouTube changed something and upstream shipped v2.20260723.0 (Jul 23) with two
"fix YouTube change" hotfixes. Fix = bump both digests in
ansible/roles/backends/defaults/main.yml (c8b3946→228015f9, companion
53950bb→0602f33b) + restart with a cleared companion cache. Verified after: metadata
10/10, media stream 6/6 (206, full range), search + trending 200.
Surfaced by a client, not by monitoring. FreeTube (which drives the
/api/v1/*API) erroring was the first signal —fleet-invidious-po-tokenonly matchesunit="invidious-companion.service"+ PO-token text, so mode (b) was invisible to it.Both halves are covered now: - Cause, proactively — the weekly image staleness check (rb34) flags the stale pin whether or not anyone is using invidious. - Symptom, reactively — Grafana rule
fleet-invidious-innertube-400watchesunit="invidious.service"forYoutube API returned status code(>3 in 30m). Validated by replaying that query over the real 2026-07-30 window: 4 (would have fired) then, empty (silent) now.Keep both, because the rule is usage-dependent: invidious only logs an error when someone actually requests something, so a broken-but-idle instance stays quiet. That is precisely how this ran 18 days unnoticed — and it is the staleness check, not the rule, that closes it.
Known benign edge case: a few very old videos (e.g.
jNQXAC9IVRw, "Me at the zoo", 2005) return HTTP 200 with an empty body and then cache that empty result (~0.7 ms replays). Isolated per-video quirk, not an instance-wide fault — don't chase it as one.
Check for a newer image without pulling (read-only, from any host):
TOKEN=$(curl -s "https://quay.io/v2/auth?service=quay.io&scope=repository:invidious/invidious:pull" \
| sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -sI -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
"https://quay.io/v2/invidious/invidious/manifests/latest" | grep -i docker-content-digest
Build (PVE + control node)¶
# 1. provision the three media VMs (PVE) — clones + cloud-init from vms.conf
cd /root/lab-provision
./lab-provision.sh up jellyfin # 9123, SERVERS .30 (EL9)
./lab-provision.sh up tvheadend # 9124, SERVERS .31 (Debian, template 9000)
./lab-provision.sh up invidious # 9125, DMZ .70 (EL9)
# 1a. give tvheadend the tuner (lab-provision.sh can't express USB passthrough)
qm set 9124 --usb0 host=2040:826d
# then confirm the host exposes it as a DVB adapter (driver + firmware):
# the Hauppauge needs linux-firmware; after a tuner-VM reboot, `ls /dev/dvb` inside
# the guest must list adapter0. If empty: install linux-firmware on the VM and check
# `dmesg | grep -i dvb`.
# 2. Invidious secrets -> vault (first build only), then configure the fleet
cd ansible # from the repo root (~/src/myos)
ANSIBLE_VAULT_PASSWORD_FILE=~/.config/lab-vault-pass ansible-vault edit group_vars/all/vault.yml
# add: vault_invidious_hmac_key: "<openssl rand -hex 20>"
# vault_invidious_db_password: "<openssl rand -hex 24>"
# 3. configure — common baseline + the app quadlets (+ NFS media mounts + Alloy)
ANSIBLE_VAULT_PASSWORD_FILE=~/.config/lab-vault-pass \
ansible-playbook -i inventory.ini playbooks/site.yml -l jellyfin,tvheadend,invidious
# 4. publish the jellyfin/ha vhosts on the apps-proxy
ansible-playbook -i inventory.ini playbooks/site.yml -l apps-proxy
Jellyfin LDAP auth¶
Install the LDAP-Auth plugin in Jellyfin (Dashboard → Plugins) and point it at FreeIPA:
- LDAP server
10.20.20.2:636(LDAPS), base DNcn=users,cn=accounts,${basedn} - bind with a least-privilege IPA service/bind account; user filter on
uid - map the Jellyfin admin to your IPA admin group.
This gives Jellyfin FreeIPA-backed accounts (same identity as the rest of the fleet) without needing a browser SSO redirect the native apps can't do.
Initial state¶
The quadlets are config-only — services start empty:
- Jellyfin: add libraries pointing at
/media(the NFS mount). Watch state/users live in/var/lib/jellyfin. - tvheadend: build the channel lineup — see "ATSC scan + channel map" below.
- Invidious: accounts/subscriptions are created in the app; data persists in the
invidious-dbpostgres volume.
ATSC scan + channel map (tvheadend)¶
Building the lineup. The Hauppauge is an ATSC (US over-the-air) tuner — its frontend
shows as LG Electronics LGDT3306A VSB/QAM. Confirm /dev/dvb/adapter0 is in the
container (podman exec tvheadend ls /dev/dvb); if the VM has it but the container
doesn't, the quadlet needs AddDevice=/dev/dvb + a container restart. tvheadend UI =
http://10.20.20.31:9981.
Access control (per the auth policy: local admin + no anonymous-admin). tvheadend
ships with a single anonymous * entry that has full admin. tvheadend has no LDAP, so it's
local-only — give it a named local admin and de-privilege anonymous:
- Configuration → Users → Passwords → add tvadmin + a password (record in KeePass;
also vault_tvheadend_admin_password). Auth is digest.
- Configuration → Users → Access Entries → add tvadmin: Admin + Web interface +
Streaming + DVR. Then edit the * (anonymous) entry: uncheck Admin (keep Streaming —
incl. HTSP — and Web interface) so Jellyfin/Kodi HTSP + channel icons keep working but
configuration now requires the tvadmin login.
GUI path (Configuration → DVB Inputs):
1. Networks → Add → ATSC-T Network → name ATSC OTA, Pre-defined muxes =
United States: us-ATSC-center-frequencies-8VSB-072020 → Save. (Pre-loads ~35 muxes.)
2. Adapters → expand the LGDT3306A → tick the ATSC-T frontend → set its
Networks = ATSC OTA, Enabled on → Save. The tuner starts scanning the muxes.
3. Watch Muxes: locked muxes go scan_result = OK; live signal under the adapter
should be SNR ≳ 20 dB, BER/UNC 0 (a good antenna here read 24 dB / −43 dBm).
4. Services tab → "Map all services to channels" with merge same name = on,
and map-encrypted / fuzzy / tidy-name = off (US OTA is free; fuzzy over-merges
subchannels). Channels appear with their ATSC virtual numbers (e.g. 25.1, 38.3).
Gotchas (cost real time — for scripting via the JSON API):
- The service-mapper endpoint is POST /api/service/mapper/save (not mapall),
and its services field is a JSON array of service UUIDs — passing a
comma-string returns HTTP 400 / maps 0. Options: merge_same_name, encrypted,
merge_same_name_fuzzy, tidy_channel_name, check_availability, *_tags.
- A mux can show num_svc > 0 (PAT read) before its service objects exist; force
a per-mux scan (set scan_state = 1) so the PMTs are read and services materialize,
then map. Services aren't persisted to disk (rediscovered on startup scan, same
UUIDs) but channels are — so the lineup survives a restart.
- Query the service grid with a form-POST (--data-urlencode "limit=400"), not a
GET ?limit=1 query string — the latter can report total: 0 even when services exist.
Home Assistant (HAOS appliance VM)¶
HAOS runs as an appliance VM (105) on the SERVERS VLAN with the Zigbee USB coordinator
passed through. Provision/import the HAOS image as VM 105, attach its NIC to the SERVERS
VLAN, and pass through the Zigbee stick:
# On PVE — HAOS NIC on the SERVERS VLAN (tag 20), virtio + firewall on.
qm set 105 --net0 virtio,bridge=vmbr1,firewall=1,tag=20
# Pass the USB Zigbee coordinator (10c4:ea60) through to the VM.
qm set 105 --usb0 host=10c4:ea60
# Group it with the lab.
qm set 105 --tags lab
Then in Home Assistant (Settings → System → Network, or the console network
update): set a static address on the SERVERS VLAN —
- IP:
10.20.20.45/24· Gateway:10.20.20.1· DNS:10.20.20.1(Unbound)
The ha.${domain} vhost on the apps-proxy points there. Add an internal DNS record
(Unbound host override ha.${domain} → 10.20.20.45) if you reach it by name on the LAN.
Why SERVERS, not IOT. HA reaches everything, so it belongs in a trusted zone, not the locked IoT zone. Its Zigbee/Z-Wave devices are radio (USB coordinator), so no VLAN adjacency is needed for them; the IP IoT devices are reached via the explicit
HOMEASSISTANT → IOTallow (rb06 §SERVERS 1a) + mDNS reflection.
Firewall (added in runbook 06 — apply/verify there)¶
- SERVERS §1a:
HOMEASSISTANT (10.20.20.45) → IOT net(default gw) — HA controls IP IoT. Above the SERVERS→RFC1918 block. - WORKSTATIONS §2a: clients →
HOMEASSISTANT:8123+JELLYFIN:8096(default gw) for fast local access. - IOT §2 (already present):
IOT → JELLYFIN:8096(Roku). No change. - mDNS (runbook 08): extend the reflector to SERVERS⇄IOT so HA discovers IP IoT (and so casting to the Roku keeps working from HA).
- WG (runbook 07, optional): to use the HA companion app while away, add the user
peers →
HOMEASSISTANT:8123to their per-peer rules (they already reach Jellyfin).
New host aliases: HOMEASSISTANT = 10.20.20.45 (add in Firewall → Aliases; JELLYFIN
already exists).
AI-assisted configuration (ha-mcp, optional)¶
Claude Code can author HA config (automations, scripts, dashboards, entity registry)
through the community homeassistant-ai/ha-mcp MCP server (REST + WebSocket API).
The official HA mcp_server integration is not used here — it only exposes the Assist
API (control/query of exposed entities), which can't edit config.
- Runs on the operator's laptop, not the fleet:
uvx ha-mcp@<pinned-version>(pin, not@latest— it's driven by a crown-jewel token), registered in that laptop's Claude Code at user scope (claude mcp add ha-mcp -s user -e HOMEASSISTANT_URL=https://ha.${domain} -e HOMEASSISTANT_TOKEN=<KeePass> -- uvx ha-mcp@<ver>). Never at project scope — the token must not enter the tracked tree. - No firewall change. ha-mcp targets
https://ha.${domain}, which over the laptop WG tier rides the existing sanctioned path:WG_LAPTOPS → SERVERS_NET:IPA_PORTSincludes443(rb07 §5.3, rb03IPA_PORTS), Unbound (10.20.80.1) resolves the split-horizon name to the apps-proxy, and apps-proxy reverse-proxies HA:8123incl. its WebSocket (rb06 §112,144). Verified end-to-end: REST200, WS101, valid*.${domain}cert. HA stays Tier-2 private — no new hole, no public exposure. - Credential. A dedicated HA admin user
claude-mcp(config writes need admin — HA has no finer RBAC) with a long-lived access token in KeePasslab.kdbx(T3 custody). This is a known HA credential, revocable independently of the fleet SSH-CA/PAW model (delete the token / disable the user). Distinct access path from T1 automation and the T2 agent PAW onctrl— an app-level grant to a crown jewel, so treat it deliberately: start ha-mcp read-only, take an HA backup before the first write session, rely on its edit-backups. - The beta YAML/file tools need HA's
ha_mcp_toolscustom component; the registry/automation APIs cover most editing without it.
Validation¶
- [ ]
ls /dev/dvb/adapter0inside the tvheadend VM lists the tuner; tvheadend UI (http://10.20.20.31:9981) shows the adapter + scans muxes. - [ ] Jellyfin UI (
http://10.20.20.30:8096) loads; libraries resolve under/media(NFS mount up); Live TV added via the tvheadend HTSP/M3U backend plays. - [ ] Jellyfin login with a FreeIPA account works (LDAP plugin bound to
10.20.20.2). - [ ] From a Roku (IOT): Jellyfin connects on
8096(rb06 IOT rule). - [ ]
http://10.20.30.70:3000(Invidious) loads → search + video plays (proves the companion/sig path). - [ ] Home Assistant reachable at
https://ha.${domain}andhttp://10.20.20.45:8123from a workstation; Zigbee devices paired; HA can control an IP IoT device (proves SERVERS→IOT allow + mDNS). - [ ] All four egress via Proton (check exit IP); invidious still plays — if Google
blocks it, apply the
VPN_BYPASSGoogle-ranges valve. - [ ] (if using ha-mcp) from the laptop on WG:
claude mcp listshowsha-mcpconnected; Claude can list automations (read) and create + delete a trivial test automation (write) — proving theclaude-mcpadmin token + apps-proxy WebSocket path.