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>
318 lines
13 KiB
Markdown
318 lines
13 KiB
Markdown
# 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
|
|
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.
|
|
|
|
```bash
|
|
#!/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):
|
|
|
|
```bash
|
|
#!/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:
|
|
|
|
```sh
|
|
chmod +x /usr/local/bin/jellyfin-kiosk-recover.sh
|
|
udevadm control --reload-rules && systemctl daemon-reload
|
|
journalctl -t jellyfin-kiosk-recover -f
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
```sh
|
|
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. 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.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`.
|