Add udev rule + oneshot service + script on nuc that restart the container kiosk when the Logitech Unifying receiver is replugged, since hotplug uevents don't cross into the container's netns and cage only enumerates input at startup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 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 (
gpudevice, full card access) - Audio: Pioneer USB audio (
08e4:0176), ALSA cardDevice, set as ALSA default via/etc/asound.conf - Input: Logitech Unifying receiver (K400) via
/dev/inputbind + host udev database bind; FR (AZERTY) keymap - Why privileged: hotplug-following directory bind-mounts of
/dev/sndand/dev/input. Unprivileged alternative: oneunix-chardevice 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:
- seatd must not bind a VT (
SEATD_VTBOUND=0drop-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). - 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 thatraw.apparmorcannot override), so the host's/run/udevis bind-mounted read-only to/opt/host-udevand arun-udev.mountunit re-binds it onto/run/udevat boot (a direct bind would be shadowed by systemd's/runtmpfs).WLR_LIBINPUT_NO_DEVICES=1additionally keeps cage alive when no input device is present. - 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.confpins the default to the Pioneer by card name. - 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
- On the TV, connect JMP to the server:
http://192.168.0.5:8096. - 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).
- 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
Input hotplug auto-recovery (host side)
cage only enumerates input devices at startup, and udev hotplug events don't
cross into the container (see tricky-parts #2). So re-plugging the Unifying
receiver leaves the kiosk bound to the old, now-dead device node — no
keyboard/mouse until jellyfin-kiosk is restarted. These three files on the
host (nuc, not the container) automate that: the host does receive the
uevent, so a host udev rule fires on the receiver's USB add and restarts the
container's kiosk. Match is by vendor/product (046d:c52b), not sysfs path, so
it works regardless of which USB port the dongle lands on.
/etc/udev/rules.d/99-jellyfin-kiosk-recover.rules:
# Logitech Unifying receiver (re)plugged -> recover the jellyfin-client kiosk
ACTION=="add", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="046d", ATTR{idProduct}=="c52b", RUN+="/usr/bin/systemctl --no-block start jellyfin-kiosk-recover.service"
/etc/systemd/system/jellyfin-kiosk-recover.service — oneshot, so a burst of
udev events during one plug merges into a single restart (natural debounce):
[Unit]
Description=Restart jellyfin-client kiosk after Logitech receiver hotplug
[Service]
Type=oneshot
ExecStart=/usr/local/bin/jellyfin-kiosk-recover.sh
/usr/local/bin/jellyfin-kiosk-recover.sh — waits for the receiver to enumerate
its paired K400 node, then restarts the kiosk only if the container is up (so
host-boot coldplug events are a no-op):
#!/bin/bash
# Restart the jellyfin-client Wayland kiosk after the Logitech Unifying
# receiver is (re)plugged, so cage/libinput re-enumerates the keyboard/mouse.
sleep 6
if ! incus info jellyfin-client 2>/dev/null | grep -qi 'Status:.*RUNNING'; then
exit 0
fi
incus exec jellyfin-client -- systemctl restart jellyfin-kiosk.service
logger -t jellyfin-kiosk-recover "Restarted jellyfin-kiosk after Logitech receiver hotplug"
Apply, then watch it work by replugging the dongle:
chmod +x /usr/local/bin/jellyfin-kiosk-recover.sh
udevadm control --reload-rules && systemctl daemon-reload
journalctl -t jellyfin-kiosk-recover -f
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: 0alone is normal —PAMName=loginmoves the process into a logind session scope. - "libinput initialization failed, no input devices" → udev db not
visible (#2); check
run-udev.mountis active. - Video OK, no sound; JMP log shows
AO: [null]→ ALSA default broken (#3); check/etc/asound.confand the card name inaplay -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. Re-plugging the
Unifying receiver is now handled automatically by the host-side recovery
rule (see "Input hotplug auto-recovery"); manual fallback for other input
hardware:
incus exec jellyfin-client -- systemctl restart jellyfin-kiosk. - cage can't open card0 — something on the host holds DRM master; the
host must boot to
multi-user.targetwith no display manager. - Jellyfin + Spotify at the same time — the Pioneer PCM is exclusive;
the second opener gets "device busy". Add an ALSA
dmixconfig if simultaneous mixing is ever needed. - JMP log:
/home/kiosk/.local/share/jellyfinmediaplayer/logs/jellyfinmediaplayer.log.