# jellyfin-client HTPC kiosk **client** in a privileged Incus container on `nuc`: Jellyfin Media Player fullscreen inside `cage` (Wayland kiosk compositor) rendering directly to HDMI via DRM/KMS — no X server or desktop on the host. Also hosts the Spotify Connect endpoint (go-librespot, see below). - Image: `images:ubuntu/24.04`, IP: `192.168.0.6` (LAN bridge) - Video: Intel Alder Lake-N iGPU → HDMI (`gpu` device, full card access) - Audio: Pioneer USB audio (`08e4:0176`), ALSA card `Device`, set as ALSA default via `/etc/asound.conf` - Input: Logitech Unifying receiver (K400) via `/dev/input` bind + host udev database bind; FR (AZERTY) keymap - Why privileged: hotplug-following directory bind-mounts of `/dev/snd` and `/dev/input`. Unprivileged alternative: one `unix-char` device per node (doesn't follow card renumbering on replug). ## How the tricky parts work Container-specific pitfalls that cost debugging time — all handled by the install script: 1. **seatd must not bind a VT** (`SEATD_VTBOUND=0` drop-in): with VT binding it tries to open the host's active tty, which isn't in the container, and cage hangs forever ("running", `Tasks: 0`, black screen). 2. **libinput needs a udev database**: it only accepts input devices carrying `ID_INPUT*` properties. The container can't run its own udevd (sysfs uevent writes are silently denied by the Incus AppArmor profile — an explicit deny rule that `raw.apparmor` cannot override), so the **host's** `/run/udev` is bind-mounted read-only to `/opt/host-udev` and a `run-udev.mount` unit re-binds it onto `/run/udev` at boot (a direct bind would be shadowed by systemd's `/run` tmpfs). `WLR_LIBINPUT_NO_DEVICES=1` additionally keeps cage alive when no input device is present. 3. **ALSA default must be pinned**: mpv opens device `default`, which maps to card 0 (Intel HDA, HDMI-only pcm devices 3/7/8/9) → open fails and mpv silently falls back to the **null** output (video OK, no sound). `/etc/asound.conf` pins the default to the Pioneer by card *name*. 4. The kiosk service must NOT use `TTYPath`/`StandardInput=tty` — it hides all cage/JMP errors on an invisible tty. Log to the journal. ## Install script ```bash #!/usr/bin/env bash set -euxo pipefail CNAME="${CNAME:-jellyfin-client}" IMAGE="${IMAGE:-images:ubuntu/24.04}" JMP_VER="${JMP_VER:-1.12.0}" # https://github.com/jellyfin/jellyfin-desktop/releases incus launch "$IMAGE" "$CNAME" -c security.privileged=true for i in $(seq 1 30); do incus exec "$CNAME" -- getent hosts github.com >/dev/null 2>&1 && break sleep 2 done incus config set "$CNAME" environment.DEBIAN_FRONTEND=noninteractive # --- Host devices ------------------------------------------------------------- # iGPU (card + render nodes). gid=44 = "video" group inside the container. incus config device add "$CNAME" gpu gpu gid=44 # Sound (Pioneer USB audio -> ALSA card) and input devices (keyboard/remote). # Directory bind-mounts follow hotplug events, so the USB card can be # re-plugged without restarting the container. No VT/tty devices needed: # seatd runs with SEATD_VTBOUND=0 (see below). # # The host udev database is bound to /opt/host-udev; a mount unit inside the # container re-binds it to /run/udev at boot (binding /run/udev directly gets # shadowed when systemd mounts its own tmpfs on /run). libinput only accepts # input devices that carry ID_INPUT* properties from a udev database, and the # container cannot run its own udevd (sysfs uevent writes are blocked by the # Incus AppArmor profile), so it reads the host's database instead. RAW_LXC="$(cat <<'EOF' lxc.cgroup2.devices.allow = c 116:* rwm lxc.cgroup2.devices.allow = c 13:* rwm lxc.mount.entry = /dev/snd dev/snd none bind,optional,create=dir lxc.mount.entry = /dev/input dev/input none bind,optional,create=dir lxc.mount.entry = /run/udev opt/host-udev none bind,ro,optional,create=dir EOF )" incus config set "$CNAME" raw.lxc="$RAW_LXC" incus restart "$CNAME" # raw.lxc mount entries apply at container start sleep 5 # --- Packages ------------------------------------------------------------------ incus exec "$CNAME" -- apt-get update incus exec "$CNAME" -- apt-get upgrade -y incus exec "$CNAME" -- apt-get install -y --no-install-recommends software-properties-common curl ca-certificates incus exec "$CNAME" -- add-apt-repository -y universe incus exec "$CNAME" -- add-apt-repository -y multiverse incus exec "$CNAME" -- apt-get install -y --no-install-recommends \ cage seatd dbus dbus-user-session qtwayland5 \ intel-media-va-driver-non-free vainfo mesa-utils-bin \ alsa-utils # Jellyfin Media Player (repo renamed to jellyfin-desktop upstream) incus exec "$CNAME" -- bash -c "curl -fLo /tmp/jmp.deb \ https://github.com/jellyfin/jellyfin-desktop/releases/download/v${JMP_VER}/jellyfin-media-player_${JMP_VER}-noble.deb" incus exec "$CNAME" -- apt-get install -y /tmp/jmp.deb # --- Default audio output = Pioneer ------------------------------------------- # mpv opens ALSA device "default", which otherwise maps to card 0 (Intel HDA, # HDMI-only pcm devices 3/7/8/9 -> open fails with ENOENT and mpv silently # falls back to the null output). Pin the default to the Pioneer by card NAME # so it survives card renumbering. "!" is required to override the compound # definition in alsa.conf. incus exec "$CNAME" -- bash -c 'cat > /etc/asound.conf </dev/null 2>&1 || useradd -m -G video,render,input,audio kiosk' # In a container seatd must NOT bind the seat to a VT (there is no usable VT; # it would try to open the host's active tty and hang the compositor forever). incus exec "$CNAME" -- mkdir -p /etc/systemd/system/seatd.service.d incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/seatd.service.d/novt.conf < /etc/systemd/system/run-udev.mount < /etc/systemd/system/jellyfin-kiosk.service </dev/null 2>&1 || useradd -r -m -d /var/lib/go-librespot -G audio spotify mkdir -p /var/lib/go-librespot/config cat > /var/lib/go-librespot/config/config.yml < /etc/systemd/system/go-librespot.service <