Files
doc/nuc/jellyfin-client.md
T
Julien LutranandClaude Fable 5 61e3606d33 Set Europe/Paris timezone in all nuc containers
Applied live to the 5 running containers; install scripts and the
post-install checklist now include the timedatectl step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:52:05 +02:00

11 KiB

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

#!/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
incus exec "$CNAME" -- timedatectl set-timezone Europe/Paris

# --- 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 <<EOF
# Pioneer USB audio (card name "Device") is the default output
defaults.pcm.!card "Device"
defaults.ctl.!card "Device"
EOF'

# --- Kiosk user + seat management ----------------------------------------------
incus exec "$CNAME" -- bash -c 'id kiosk >/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 <<EOF
[Service]
Environment=SEATD_VTBOUND=0
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl enable --now seatd

# Re-bind the host udev database onto /run/udev after systemd sets up /run
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/run-udev.mount <<EOF
[Unit]
Description=Host udev database (read-only bind)
Before=jellyfin-kiosk.service

[Mount]
What=/opt/host-udev
Where=/run/udev
Type=none
Options=bind,ro

[Install]
WantedBy=multi-user.target
EOF'
incus exec "$CNAME" -- systemctl enable run-udev.mount

# --- Autostart service -----------------------------------------------------------
incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/jellyfin-kiosk.service <<EOF
[Unit]
Description=Jellyfin Media Player (cage Wayland kiosk)
After=seatd.service systemd-user-sessions.service network-online.target
Wants=seatd.service network-online.target

[Service]
User=kiosk
PAMName=login
Environment=QT_QPA_PLATFORM=wayland
Environment=LIBSEAT_BACKEND=seatd
Environment=WLR_LIBINPUT_NO_DEVICES=1
Environment=XKB_DEFAULT_LAYOUT=fr
ExecStart=/usr/bin/cage -d -- /usr/bin/jellyfinmediaplayer --tv --fullscreen
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl enable jellyfin-kiosk

incus config set "$CNAME" boot.autostart=true
incus restart "$CNAME"

echo "Done. The Jellyfin Media Player UI should appear on the HDMI output."
echo "Check status with: incus exec $CNAME -- systemctl status jellyfin-kiosk"

First-run configuration

  1. On the TV, connect JMP to the server: http://192.168.0.5:8096.
  2. Settings → Audio: "Auto" lands on the Pioneer (ALSA default). Enable AC3/DTS passthrough only if the DAC/amp decodes them (the A-70 doesn't).
  3. Control: K400 keyboard/touchpad, or any Jellyfin app via "Play On" (JMP announces itself as a remote-control target).

Spotify Connect (go-librespot)

Same container, same Pioneer output. Announces itself as "Pioneer A-70"; zeroconf auth (no credentials stored), Premium account required.

#!/usr/bin/env bash
set -euxo pipefail

CNAME="${CNAME:-jellyfin-client}"
GLS_VER="${GLS_VER:-0.7.4}"   # https://github.com/devgianlu/go-librespot/releases
DEVICE_NAME="${DEVICE_NAME:-Pioneer A-70}"

incus exec "$CNAME" -- bash -c "
set -e
curl -fsSL -o /tmp/golibrespot.tar.gz https://github.com/devgianlu/go-librespot/releases/download/v${GLS_VER}/go-librespot_linux_x86_64.tar.gz
tar -xzf /tmp/golibrespot.tar.gz -C /tmp
mv /tmp/go-librespot /usr/local/bin/go-librespot
chmod 755 /usr/local/bin/go-librespot
id spotify >/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 <<EOF
device_name: ${DEVICE_NAME}
device_type: speaker
bitrate: 320
EOF
chown -R spotify:spotify /var/lib/go-librespot
"

incus exec "$CNAME" -- bash -c 'cat > /etc/systemd/system/go-librespot.service <<EOF
[Unit]
Description=Spotify Connect client (go-librespot)
After=network-online.target avahi-daemon.service
Wants=network-online.target avahi-daemon.service

[Service]
User=spotify
Group=spotify
ExecStart=/usr/local/bin/go-librespot --config_dir /var/lib/go-librespot/config
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF'
incus exec "$CNAME" -- systemctl daemon-reload
incus exec "$CNAME" -- systemctl enable --now go-librespot

Troubleshooting

incus exec jellyfin-client -- systemctl status seatd jellyfin-kiosk go-librespot
incus exec jellyfin-client -- journalctl -u jellyfin-kiosk -b
incus exec jellyfin-client -- su -s /bin/bash kiosk -c vainfo   # GPU/VAAPI
incus exec jellyfin-client -- aplay -l                          # ALSA cards
incus exec jellyfin-client -- su -s /bin/bash kiosk -c "aplay -D default /usr/share/sounds/alsa/Front_Center.wav"
incus exec jellyfin-client -- udevadm info /dev/input/event0    # udev db visible?
  • cage "running" but stuck, Tasks: 0, black screen → seatd VT binding (see "How the tricky parts work" #1). Note: Tasks: 0 alone is normal — PAMName=login moves the process into a logind session scope.
  • "libinput initialization failed, no input devices" → udev db not visible (#2); check run-udev.mount is active.
  • Video OK, no sound; JMP log shows AO: [null] → ALSA default broken (#3); check /etc/asound.conf and the card name in aplay -l.
  • Keyboard plugged in after boot isn't seen — the host udev db is live through the bind, but udev hotplug events don't cross the container's network namespace, so cage only enumerates at startup: systemctl restart jellyfin-kiosk after plugging new input hardware.
  • cage can't open card0 — something on the host holds DRM master; the host must boot to multi-user.target with no display manager.
  • Jellyfin + Spotify at the same time — the Pioneer PCM is exclusive; the second opener gets "device busy". Add an ALSA dmix config if simultaneous mixing is ever needed.
  • JMP log: /home/kiosk/.local/share/jellyfinmediaplayer/logs/jellyfinmediaplayer.log.