diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 72f26f86..34e4dd82 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -133,7 +133,7 @@ jobs:
name: manifest
- name: Create GitHub Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
# Stay out of /releases/latest until publish-manifest has uploaded
# the per-arch images + rpi-imager-manifest (~30 min). odio.love
@@ -170,7 +170,7 @@ jobs:
name: manifest
- name: Create GitHub Pre-Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
tag_name: pr-${{ github.event.pull_request.number }}
name: "PR #${{ github.event.pull_request.number }} — ${{ needs.build.outputs.version }}"
@@ -231,16 +231,24 @@ jobs:
test-playbook:
needs: build-test-image
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - action: test
+ rerun: rerun
+ - action: test-as-other-user
+ rerun: rerun-as-other-user
steps:
- uses: actions/checkout@v5
- - name: Run playbook test
- run: ./tests/test.sh
+ - name: Run playbook (${{ matrix.action }})
+ run: ./tests/test.sh ${{ matrix.action }}
env:
REMOTE_IMAGE: ${{ needs.build-test-image.outputs.image }}
- name: Re-run playbook (idempotence)
- run: ./tests/test.sh rerun
+ run: ./tests/test.sh ${{ matrix.rerun }}
- name: Clean
if: always()
@@ -291,7 +299,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- mode: [install, install-root]
+ mode: [install, install-root, install-as-other-user]
steps:
- uses: actions/checkout@v5
@@ -540,7 +548,7 @@ jobs:
merge-multiple: true
- name: Upload images and per-arch manifests to release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.tag.outputs.value }}
files: |
@@ -581,7 +589,7 @@ jobs:
run: ./scripts/build-rpi-manifests.py odio.rpi-imager-manifest entries/manifest-*/*.json
- name: Upload combined manifest to release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.tag.outputs.value }}
files: odio.rpi-imager-manifest
diff --git a/image-builder/config.sh b/image-builder/config.sh
index abba1f27..31580bc6 100644
--- a/image-builder/config.sh
+++ b/image-builder/config.sh
@@ -14,6 +14,14 @@ export ODIOS_USER="odio"
export XZ_COMPRESSION_LEVEL=9
export XZ_THREADS=0 # 0 = use all available cores
+# Extra packages to install on the image (beyond what Ansible deploys).
+# Currently: yq (Python kislyuk, jq-syntax) — used by odios-firstboot-network.sh
+# to inject the discovered snapweb URL into odio-api's config.yaml.
+# shellcheck disable=SC2034 # sourced by build.sh
+INSTALL_PACKAGES=(
+ yq
+)
+
# Packages to purge from the base image (not needed for headless audio)
# shellcheck disable=SC2034 # sourced by build.sh
PURGE_PACKAGES=(
diff --git a/image-builder/files/odios-firstboot-network.service b/image-builder/files/odios-firstboot-network.service
new file mode 100644
index 00000000..2c0ec7b1
--- /dev/null
+++ b/image-builder/files/odios-firstboot-network.service
@@ -0,0 +1,15 @@
+[Unit]
+Description=odios first-boot network-dependent fixups
+After=network-online.target avahi-daemon.service
+Wants=network-online.target avahi-daemon.service
+ConditionPathExists=!/var/lib/odios/firstboot-network-done
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=/usr/local/bin/odios-firstboot-network.sh
+StandardOutput=append:/var/log/odios-firstboot-network.log
+StandardError=append:/var/log/odios-firstboot-network.log
+
+[Install]
+WantedBy=multi-user.target
diff --git a/image-builder/files/odios-firstboot-network.sh b/image-builder/files/odios-firstboot-network.sh
new file mode 100644
index 00000000..ec6e2d21
--- /dev/null
+++ b/image-builder/files/odios-firstboot-network.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# odios-firstboot-network.sh — Network-dependent post cloud-init fixups
+# Runs once after network-online + avahi-daemon are up. Discovers the
+# snapcast server via snapclientmpris and injects the snapweb URL into
+# odio-api's config so the UI can link to it.
+set -euo pipefail
+
+MARKER="/var/lib/odios/firstboot-network-done"
+ODIOS_USER="odio"
+ODIOS_HOME="/home/${ODIOS_USER}"
+ODIO_API_CONF="${ODIOS_HOME}/.config/odio-api/config.yaml"
+
+echo "odios-firstboot-network: started at $(date)"
+
+# ─── Discover snapweb URL and inject into odio-api config ───────────────────
+
+if ! command -v snapclientmpris &>/dev/null; then
+ echo "odios-firstboot-network: snapclientmpris not installed, skipping snapweb discovery"
+elif [[ ! -f "$ODIO_API_CONF" ]]; then
+ echo "odios-firstboot-network: ${ODIO_API_CONF} not found, skipping snapweb discovery"
+else
+ echo "odios-firstboot-network: discovering snapweb URL via snapclientmpris..."
+ discover_output=$(runuser -u "$ODIOS_USER" -- snapclientmpris --discover 2>&1 || true)
+ # Match `snapweb:` anchored at line start, allow zero-or-more spaces (mirrors
+ # the ansible-side guard in roles/odio_api/tasks/main.yml), keep only the
+ # first match (-m1) so multiple snapcast servers can't produce a multi-line
+ # value that would later corrupt the YAML edit.
+ snapweb_url=$(printf '%s\n' "$discover_output" \
+ | grep -m1 -oE '^snapweb:[[:space:]]*[^[:space:]]+' \
+ | sed -E 's/^snapweb:[[:space:]]*//' || true)
+ if [[ -z "$snapweb_url" ]]; then
+ echo "odios-firstboot-network: no snapweb URL discovered, output was:"
+ echo "$discover_output" | sed 's/^/ /'
+ elif [[ ! "$snapweb_url" =~ ^https?://[^[:space:]]+$ ]]; then
+ echo "odios-firstboot-network: discarding invalid snapweb URL '${snapweb_url}', output was:"
+ echo "$discover_output" | sed 's/^/ /'
+ snapweb_url=""
+ fi
+ if [[ -n "$snapweb_url" ]]; then
+ echo "odios-firstboot-network: snapweb URL = ${snapweb_url}"
+ runuser -u "$ODIOS_USER" -- env SNAPWEB_URL="$snapweb_url" \
+ yq -yi '(.systemd.user[] | select(.name == "snapclient.service")).url = env.SNAPWEB_URL' \
+ "$ODIO_API_CONF"
+ echo "odios-firstboot-network: restarting odio-api..."
+ runuser -u "$ODIOS_USER" -- env XDG_RUNTIME_DIR="/run/user/$(id -u "$ODIOS_USER")" \
+ systemctl --user restart odio-api.service || true
+ fi
+fi
+
+mkdir -p "$(dirname "$MARKER")"
+touch "$MARKER"
+echo "odios-firstboot-network: done"
diff --git a/image-builder/lib/provision.sh b/image-builder/lib/provision.sh
index 5dd0a2fa..ef12562c 100644
--- a/image-builder/lib/provision.sh
+++ b/image-builder/lib/provision.sh
@@ -68,11 +68,21 @@ PYTHONPATH="vendor" python3 vendor/bin/ansible-playbook \\
--connection=local
PROVISION
+ log_info "Installing extra packages: ${INSTALL_PACKAGES[*]}"
+ chroot "$rootfs" apt-get install -y --no-install-recommends "${INSTALL_PACKAGES[@]}"
+
log_info "Installing firstboot script and vendor-data..."
cp "$SCRIPT_DIR/files/odios-firstboot.sh" "$rootfs/usr/local/bin/odios-firstboot.sh"
chmod 755 "$rootfs/usr/local/bin/odios-firstboot.sh"
cp "$SCRIPT_DIR/files/vendor-data" "$rootfs/boot/firmware/vendor-data"
+ log_info "Installing network-dependent firstboot service..."
+ cp "$SCRIPT_DIR/files/odios-firstboot-network.sh" "$rootfs/usr/local/bin/odios-firstboot-network.sh"
+ chmod 755 "$rootfs/usr/local/bin/odios-firstboot-network.sh"
+ cp "$SCRIPT_DIR/files/odios-firstboot-network.service" \
+ "$rootfs/etc/systemd/system/odios-firstboot-network.service"
+ chroot "$rootfs" systemctl enable odios-firstboot-network.service
+
log_info "Purging unnecessary packages..."
local purge_list
purge_list=$(chroot "$rootfs" dpkg -l "${PURGE_PACKAGES[@]}" 2>/dev/null \
diff --git a/installer/README.md b/installer/README.md
index 6b83926d..9f58b72c 100644
--- a/installer/README.md
+++ b/installer/README.md
@@ -330,6 +330,9 @@ Tests `install.sh` from a GitHub release inside a systemd container:
# As root with TARGET_USER=odio — system installation case
./tests/test.sh install-root pr-5
+# As bob (NOPASSWD sudoer) with TARGET_USER=odio — second-admin case
+./tests/test.sh install-as-other-user pr-5
+
# Against the latest stable release
./tests/test.sh install latest
```
diff --git a/installer/ansible/group_vars/all/main.yml b/installer/ansible/group_vars/all/main.yml
index 9ca32bce..69b13206 100644
--- a/installer/ansible/group_vars/all/main.yml
+++ b/installer/ansible/group_vars/all/main.yml
@@ -9,6 +9,9 @@ gpg_file: "/usr/share/keyrings/odio.gpg"
odio_version: "unknown"
mpd_music_directory: "/media/USB"
mpd_mympd_http_port: "8080"
+# Standard MPD user socket (matches the packaged mpd.socket unit's
+# ListenStream=%t/mpd/socket); consumed by mpDris2 and mpd_discplayer.
+mpd_socket_path: "/run/user/{{ target_user_uid }}/mpd/socket"
# Core components (default: enabled)
install_pulseaudio: true
diff --git a/installer/ansible/playbook.yml b/installer/ansible/playbook.yml
index 7999e7b9..754bd8b7 100644
--- a/installer/ansible/playbook.yml
+++ b/installer/ansible/playbook.yml
@@ -16,6 +16,12 @@
msg: "target_user must not be root"
when: target_user == "root"
+ # Computed early so pre_tasks (remote_tmp) and every role can gate become
+ # on it. False when the invoker already is target_user — no become needed.
+ - name: Detect become requirement for target user
+ ansible.builtin.set_fact:
+ become_for_target_user: "{{ target_user != ansible_user_id }}"
+
- name: Ensure dbus running
ansible.builtin.systemd:
name: dbus.service
@@ -44,6 +50,7 @@
group: "{{ target_user }}"
mode: '0700'
become: true
+ when: become_for_target_user
- name: Get target user UID
ansible.builtin.command:
@@ -108,6 +115,7 @@
path: "/home/{{ target_user }}/.ansible/tmp"
state: absent
become: true
+ when: become_for_target_user
- name: Display installation summary
ansible.builtin.debug:
diff --git a/installer/ansible/roles/branding/tasks/main.yml b/installer/ansible/roles/branding/tasks/main.yml
index 1f400805..f3680ed7 100644
--- a/installer/ansible/roles/branding/tasks/main.yml
+++ b/installer/ansible/roles/branding/tasks/main.yml
@@ -6,7 +6,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Install odio-motd script
ansible.builtin.copy:
@@ -15,7 +15,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Hook odio-motd into ~/.profile
ansible.builtin.blockinfile:
@@ -29,7 +29,7 @@
case $- in
*i*) [ -x "$HOME/.local/bin/odio-motd" ] && "$HOME/.local/bin/odio-motd" ;;
esac
- become: true
+ become: "{{ become_for_target_user }}"
- name: Suppress system motd for odio user
ansible.builtin.file:
@@ -40,4 +40,4 @@
mode: '0644'
modification_time: preserve
access_time: preserve
- become: true
+ become: "{{ become_for_target_user }}"
diff --git a/installer/ansible/roles/branding/vars/main.yml b/installer/ansible/roles/branding/vars/main.yml
index e8687cc8..ff03c5ad 100644
--- a/installer/ansible/roles/branding/vars/main.yml
+++ b/installer/ansible/roles/branding/vars/main.yml
@@ -1,2 +1,2 @@
---
-branding_version: "2026.5.0b1"
+branding_version: "2026.5.0b3"
diff --git a/installer/ansible/roles/common/tasks/main.yml b/installer/ansible/roles/common/tasks/main.yml
index 7551f4f8..c947749f 100644
--- a/installer/ansible/roles/common/tasks/main.yml
+++ b/installer/ansible/roles/common/tasks/main.yml
@@ -35,14 +35,14 @@
become: true
# acl is required by ansible-core's `become_user` when the invoking user, root,
-# and target_user are three different identities — ansible drops tmp files via
-# setfacl, and falls back to the BSD `chmod +a` syntax (invalid on Linux) when
-# the package is absent.
+# and target_user are three different identities: ansible drops tmp files via
+# setfacl.
- name: Install acl (needed by become_user)
ansible.builtin.apt:
name: acl
state: present
become: true
+ when: target_user != ansible_user_id
- name: Ensure required groups exist
ansible.builtin.group:
@@ -91,10 +91,12 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
loop:
- "/home/{{ target_user }}/.config/systemd/user"
- "/home/{{ target_user }}/.config/systemd/user/default.target.wants"
+ - "/home/{{ target_user }}/.config/systemd/user/sockets.target.wants"
+ - "/home/{{ target_user }}/.config/systemd/user/timers.target.wants"
- name: Check linger status
ansible.builtin.stat:
diff --git a/installer/ansible/roles/common/vars/main.yml b/installer/ansible/roles/common/vars/main.yml
index 574f9145..f27bd101 100644
--- a/installer/ansible/roles/common/vars/main.yml
+++ b/installer/ansible/roles/common/vars/main.yml
@@ -1,2 +1,2 @@
---
-common_version: "2026.5.0b1"
+common_version: "2026.5.0b3"
diff --git a/installer/ansible/roles/mpd/files/odio-logo.png b/installer/ansible/roles/mpd/files/odio-logo.png
new file mode 100644
index 00000000..eb2a2947
Binary files /dev/null and b/installer/ansible/roles/mpd/files/odio-logo.png differ
diff --git a/installer/ansible/roles/mpd/files/odio.html b/installer/ansible/roles/mpd/files/odio.html
new file mode 100644
index 00000000..87484ae1
--- /dev/null
+++ b/installer/ansible/roles/mpd/files/odio.html
@@ -0,0 +1,4 @@
+
+
+
odio
+
diff --git a/installer/ansible/roles/mpd/handlers/main.yml b/installer/ansible/roles/mpd/handlers/main.yml
index 3f68f991..6ab92c80 100644
--- a/installer/ansible/roles/mpd/handlers/main.yml
+++ b/installer/ansible/roles/mpd/handlers/main.yml
@@ -6,7 +6,7 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
@@ -17,7 +17,7 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
@@ -28,6 +28,6 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/mpd/tasks/main.yml b/installer/ansible/roles/mpd/tasks/main.yml
index 8ce44a6a..b4142a82 100644
--- a/installer/ansible/roles/mpd/tasks/main.yml
+++ b/installer/ansible/roles/mpd/tasks/main.yml
@@ -30,7 +30,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0700'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Create MPD data directories
ansible.builtin.file:
@@ -39,7 +39,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0700'
- become: true
+ become: "{{ become_for_target_user }}"
loop:
- playlists
- cache
@@ -60,7 +60,7 @@
group: "{{ target_user }}"
mode: '0600'
force: false
- become: true
+ become: "{{ become_for_target_user }}"
- name: MPD config - paths
ansible.builtin.blockinfile:
@@ -76,20 +76,22 @@
}
state_file "~/.local/share/mpd/state"
sticker_file "~/.local/share/mpd/sticker.sql"
- become: true
+ auto_update "yes"
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
-- name: MPD config - network
+# Listeners (unix socket + TCP 6600) are owned by the packaged mpd.socket
+# user unit (ListenStream=%t/mpd/socket, ListenStream=6600). MPD inherits them
+# via socket activation, so no bind_to_address/port belongs in mpd.conf. Drop
+# the old managed network block from previously-installed configs to avoid a
+# double-bind ("address already in use") against the socket unit.
+- name: MPD config - drop legacy network block
ansible.builtin.blockinfile:
path: "/home/{{ target_user }}/.config/mpd/mpd.conf"
marker: "# {mark} ANSIBLE MANAGED BLOCK - network"
- block: |
- bind_to_address "0.0.0.0"
- bind_to_address "/run/user/{{ target_user_uid }}/mpd.socket"
- port "6600"
- auto_update "yes"
- become: true
+ state: absent
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
@@ -100,7 +102,7 @@
block: |
zeroconf_enabled "yes"
zeroconf_name "Mpd-%h"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
@@ -114,7 +116,7 @@
plugin "wildmidi"
config_file "/etc/timidity/timidity.cfg"
}
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
@@ -127,7 +129,18 @@
type "pulse"
name "pulseaudio"
}
- become: true
+ become: "{{ become_for_target_user }}"
+ become_user: "{{ target_user }}"
+ notify: Restart mpd
+
+# Optional user-local overrides (~/.config/mpd/mpd_local.conf), parsed last.
+- name: MPD config - local override include
+ ansible.builtin.blockinfile:
+ path: "/home/{{ target_user }}/.config/mpd/mpd.conf"
+ marker: "# {mark} ANSIBLE MANAGED BLOCK - local include"
+ block: |
+ include_optional "mpd_local.conf"
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
@@ -147,7 +160,7 @@
group: "{{ target_user }}"
mode: '0644'
force: false
- become: true
+ become: "{{ become_for_target_user }}"
- name: Create music directory
ansible.builtin.file:
@@ -158,7 +171,11 @@
mode: '0775'
become: true
-- name: Enable user MPD service
+- name: Enable user MPD socket and service
ansible.builtin.include_tasks: "../../../tasks/systemd_enable_user.yml"
vars:
- service_name: mpd.service
+ service_name: "{{ item.name }}"
+ wanted_by: "{{ item.wanted_by | default('default.target') }}"
+ loop:
+ - { name: mpd.service }
+ - { name: mpd.socket, wanted_by: sockets.target }
diff --git a/installer/ansible/roles/mpd/tasks/mpdris2.yml b/installer/ansible/roles/mpd/tasks/mpdris2.yml
index 39d2d3f6..707c44e0 100644
--- a/installer/ansible/roles/mpd/tasks/mpdris2.yml
+++ b/installer/ansible/roles/mpd/tasks/mpdris2.yml
@@ -15,7 +15,6 @@
ansible.builtin.apt:
name:
- "mpdris2>={{ mpd_mpdris2_apt_version }}"
- - python3-mutagen
state: present
install_recommends: false
become: true
@@ -28,7 +27,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0700'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Create mpDris2 config file
ansible.builtin.template:
@@ -36,8 +35,8 @@
dest: "/home/{{ target_user }}/.config/mpDris2/mpDris2.conf"
owner: "{{ target_user }}"
group: "{{ target_user }}"
- mode: '0700'
- become: true
+ mode: '0600'
+ become: "{{ become_for_target_user }}"
notify: Restart mpDris2
- name: Enable user mpDris2 service
diff --git a/installer/ansible/roles/mpd/tasks/mympd.yml b/installer/ansible/roles/mpd/tasks/mympd.yml
index 53cf8a93..9cc97f86 100644
--- a/installer/ansible/roles/mpd/tasks/mympd.yml
+++ b/installer/ansible/roles/mpd/tasks/mympd.yml
@@ -14,7 +14,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0700'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Deploy odio custom_css
ansible.builtin.copy:
@@ -23,7 +23,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0600'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart mympd
- name: Disable mympd ssl
@@ -33,12 +33,9 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0600'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart mympd
-# Pin myMPD to MPD's user socket. Writing state/mpd_host short-circuits
-# myMPD's autoconf, which would otherwise probe `${XDG_RUNTIME_DIR}/mpd/socket`
-# (different filename) and fall back to localhost:6600.
- name: Create mympd state directory
ansible.builtin.file:
path: "/home/{{ target_user }}/.config/mympd/state"
@@ -46,26 +43,95 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0700'
- become: true
+ become: "{{ become_for_target_user }}"
+
+- name: Create mympd pics directories
+ ansible.builtin.file:
+ path: "/home/{{ target_user }}/.config/mympd/{{ item }}"
+ state: directory
+ owner: "{{ target_user }}"
+ group: "{{ target_user }}"
+ mode: '0700'
+ become: "{{ become_for_target_user }}"
+ loop:
+ - pics
+ - pics/thumbs
+
+# Served by myMPD at /browse/pics/odio.html. The home_list icon points there
+# with a relative URL so the page loads on the same host the client used to
+# reach myMPD; its inline JS then redirects to :8018/ui. Avoids
+# baking the build machine's IP (ansible_default_ipv4) into home_list on Pi
+# image flows, and stays mDNS-independent (works on mobile clients).
+- name: Deploy odio UI redirect page
+ ansible.builtin.copy:
+ src: odio.html
+ dest: "/home/{{ target_user }}/.config/mympd/pics/odio.html"
+ owner: "{{ target_user }}"
+ group: "{{ target_user }}"
+ mode: '0644'
+ become: "{{ become_for_target_user }}"
+
+# Referenced by home_list's image field as "odio-logo.png" — myMPD's
+# getImageUri resolves bare names against /browse/pics/thumbs/ (cf
+# htdocs/js/images.js).
+- name: Deploy odio home icon logo
+ ansible.builtin.copy:
+ src: odio-logo.png
+ dest: "/home/{{ target_user }}/.config/mympd/pics/thumbs/odio-logo.png"
+ owner: "{{ target_user }}"
+ group: "{{ target_user }}"
+ mode: '0644'
+ become: "{{ become_for_target_user }}"
+
+# Pin myMPD to MPD's user socket via state/mpd_host. myMPD rewrites state/ on
+# shutdown, so writing while it runs gets clobbered — stop it first, but only
+# when host or widgets actually change (idempotent; never left stopped on no-op).
+- name: Preview mympd MPD host pin
+ ansible.builtin.copy:
+ content: "{{ mpd_socket_path }}"
+ dest: "/home/{{ target_user }}/.config/mympd/state/mpd_host"
+ owner: "{{ target_user }}"
+ group: "{{ target_user }}"
+ mode: '0600'
+ check_mode: true
+ register: mpd_mympd_host_preview
+ become: "{{ become_for_target_user }}"
+
+- name: Preview mympd home widgets
+ ansible.builtin.template:
+ src: home_list.j2
+ dest: "/home/{{ target_user }}/.config/mympd/state/home_list"
+ owner: "{{ target_user }}"
+ group: "{{ target_user }}"
+ mode: '0600'
+ check_mode: true
+ register: mpd_mympd_widgets_preview
+ become: "{{ become_for_target_user }}"
+
+- name: Stop mympd before rewriting state
+ ansible.builtin.include_tasks: "../../../tasks/systemd_stop_user.yml"
+ vars:
+ service_name: mympd.service
+ when: mpd_mympd_host_preview is changed or mpd_mympd_widgets_preview is changed
- name: Configure mympd MPD host (user socket)
ansible.builtin.copy:
- content: "/run/user/{{ target_user_uid }}/mpd.socket"
+ content: "{{ mpd_socket_path }}"
dest: "/home/{{ target_user }}/.config/mympd/state/mpd_host"
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0600'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart mympd
-- name: Configure mympd MPD port (0 = socket)
- ansible.builtin.copy:
- content: "0"
- dest: "/home/{{ target_user }}/.config/mympd/state/mpd_port"
+- name: Configure mympd home widgets
+ ansible.builtin.template:
+ src: home_list.j2
+ dest: "/home/{{ target_user }}/.config/mympd/state/home_list"
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0600'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart mympd
- name: Configure mympd http port
@@ -75,7 +141,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0600'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart mympd
- name: Enable user mympd service
diff --git a/installer/ansible/roles/mpd/templates/home_list.j2 b/installer/ansible/roles/mpd/templates/home_list.j2
new file mode 100644
index 00000000..77c9f827
--- /dev/null
+++ b/installer/ansible/roles/mpd/templates/home_list.j2
@@ -0,0 +1,4 @@
+{"type":"icon","name":"odio","ligature":"","bgcolor":"transparent","color":"","image":"odio-logo.png","cmd":"openExternalLink","options":["browse/pics/odio.html","true"]}
+{"type":"icon","name":"Webradios","ligature":"radio","bgcolor":"#28a745","color":"#f8f9fa","image":"","cmd":"appGoto","options":["Browse","Radio","Webradiodb","0","100","any","{\"tag\":\"Name\",\"desc\":false}","",""]}
+{"type":"icon","name":"Albums","ligature":"album","bgcolor":"#28a745","color":"#f8f9fa","image":"","cmd":"appGoto","options":["Browse","Database","AlbumList","0","100","any","{\"tag\":\"AlbumArtist\",\"desc\":false}","Album",""]}
+{"type":"icon","name":"Artists","ligature":"groups","bgcolor":"#28a745","color":"#f8f9fa","image":"","cmd":"appGoto","options":["Browse","Database","TagList","0","100","Artist","{\"tag\":\"Artist\",\"desc\":false}","Artist",""]}
diff --git a/installer/ansible/roles/mpd/templates/mpDris2.conf.j2 b/installer/ansible/roles/mpd/templates/mpDris2.conf.j2
index 2773b6f2..ef1b6f5f 100644
--- a/installer/ansible/roles/mpd/templates/mpDris2.conf.j2
+++ b/installer/ansible/roles/mpd/templates/mpDris2.conf.j2
@@ -1,4 +1,5 @@
[Connection]
-host = 127.0.0.1
-port = 6600
-music_dir = {{ mpd_music_directory }}
+host = {{ mpd_socket_path }}
+
+[Bling]
+notify = False
diff --git a/installer/ansible/roles/mpd/templates/mpd.conf.j2 b/installer/ansible/roles/mpd/templates/mpd.conf.j2
deleted file mode 100644
index c58028b7..00000000
--- a/installer/ansible/roles/mpd/templates/mpd.conf.j2
+++ /dev/null
@@ -1,71 +0,0 @@
-# MPD Configuration - Generated by Ansible
-#
-# See: /usr/share/doc/mpd/mpdconf.example
-
-# Files and directories
-music_directory "/media/USB"
-playlist_directory "~/.local/share/mpd/playlists"
-
-database {
- plugin "simple"
- path "~/.local/share/mpd/db"
- cache_directory "~/.local/share/mpd/cache"
-}
-
-state_file "~/.local/share/mpd/state"
-sticker_file "~/.local/share/mpd/sticker.sql"
-
-# General music daemon options
-#user "mpd"
-#group "audio"
-
-# Network configuration
-bind_to_address "any"
-bind_to_address "/run/user/{{ target_user_uid }}/mpd.socket"
-port "6600"
-
-#log_level "default"
-#restore_paused "no"
-#save_absolute_paths_in_playlists "no"
-#metadata_to_use "artist,album,title,track,name,genre,date,composer,performer,disc"
-auto_update "yes"
-#auto_update_depth "3"
-
-# Symbolic link behavior
-#follow_outside_symlinks "yes"
-#follow_inside_symlinks "yes"
-
-# Zeroconf / Avahi Service Discovery
-zeroconf_enabled "yes"
-zeroconf_name "Mpd-%h"
-
-# Permissions
-#default_permissions "read,add,control,admin"
-
-# Database
-#database {
-# plugin "proxy"
-# host "other.mpd.host"
-# port "6600"
-#}
-
-# Decoder plugins
-
-# Audio Output
-audio_output {
- type "pulse"
- name "pulseaudio"
- server "127.0.0.1"
-## sink "remote_server_sink" # optional
-## media_role "media_role" #optional
-}
-
-# Normalization automatic volume adjustments
-#replaygain "album"
-#replaygain_preamp "0"
-#replaygain_missing_preamp "0"
-#replaygain_limit "yes"
-#volume_normalization "no"
-
-# Character Encoding
-#filesystem_charset "UTF-8"
diff --git a/installer/ansible/roles/mpd/vars/main.yml b/installer/ansible/roles/mpd/vars/main.yml
index f75ac0dd..4ca431a2 100644
--- a/installer/ansible/roles/mpd/vars/main.yml
+++ b/installer/ansible/roles/mpd/vars/main.yml
@@ -1,4 +1,4 @@
---
-mpd_version: "2026.5.0b2"
-mpd_mpdris2_apt_version: "0.9.3"
+mpd_version: "2026.5.0b3"
+mpd_mpdris2_apt_version: "0.10.0"
mpd_mympd_apt_version: "25.0.2-1"
diff --git a/installer/ansible/roles/mpd_discplayer/handlers/main.yml b/installer/ansible/roles/mpd_discplayer/handlers/main.yml
index 0be2d206..3c2e1325 100644
--- a/installer/ansible/roles/mpd_discplayer/handlers/main.yml
+++ b/installer/ansible/roles/mpd_discplayer/handlers/main.yml
@@ -6,6 +6,6 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/mpd_discplayer/tasks/main.yml b/installer/ansible/roles/mpd_discplayer/tasks/main.yml
index a37c6532..2784b982 100644
--- a/installer/ansible/roles/mpd_discplayer/tasks/main.yml
+++ b/installer/ansible/roles/mpd_discplayer/tasks/main.yml
@@ -54,7 +54,7 @@
plugin "cdio_paranoia"
speed "12"
}
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
@@ -69,7 +69,7 @@
enabled "true"
as_directory "true"
}
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
@@ -82,7 +82,7 @@
neighbors {
plugin "udisks"
}
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart mpd
@@ -119,15 +119,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0700'
- become: true
-
-- name: Backup mpd-discplayer config before changes
- ansible.builtin.include_tasks: "../../../tasks/backup_conf_before.yml"
- vars:
- conf_path: "/home/{{ target_user }}/.config/mpd-discplayer/config.yaml"
- conf_owner: "{{ target_user }}"
- conf_group: "{{ target_user }}"
- conf_mode: '0644'
+ become: "{{ become_for_target_user }}"
- name: Deploy mpd-discplayer configuration
ansible.builtin.template:
@@ -136,17 +128,9 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0644'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart mpd-discplayer
-- name: Backup mpd-discplayer config after changes
- ansible.builtin.include_tasks: "../../../tasks/backup_conf_after.yml"
- vars:
- conf_path: "/home/{{ target_user }}/.config/mpd-discplayer/config.yaml"
- conf_owner: "{{ target_user }}"
- conf_group: "{{ target_user }}"
- conf_mode: '0644'
-
- name: Enable mpd-discplayer service
ansible.builtin.include_tasks: "../../../tasks/systemd_enable_user.yml"
vars:
diff --git a/installer/ansible/roles/mpd_discplayer/tasks/validate_external_mpd.yml b/installer/ansible/roles/mpd_discplayer/tasks/validate_external_mpd.yml
index c8a7bd82..27322b5a 100644
--- a/installer/ansible/roles/mpd_discplayer/tasks/validate_external_mpd.yml
+++ b/installer/ansible/roles/mpd_discplayer/tasks/validate_external_mpd.yml
@@ -9,6 +9,7 @@
ansible.builtin.stat:
path: "/home/{{ target_user }}/.config/mpd/mpd.conf"
register: mpd_discplayer_conf_user
+ become: "{{ become_for_target_user }}"
- name: Locate MPD config file (system)
ansible.builtin.stat:
@@ -39,7 +40,7 @@
ansible.builtin.slurp:
src: "{{ mpd_discplayer_conf_path }}"
register: mpd_discplayer_conf_raw
- become: true
+ become: "{{ become_for_target_user }}"
- name: Set MPD config content fact
ansible.builtin.set_fact:
diff --git a/installer/ansible/roles/mpd_discplayer/templates/config.yaml.j2 b/installer/ansible/roles/mpd_discplayer/templates/config.yaml.j2
index 8a59b881..833b477d 100644
--- a/installer/ansible/roles/mpd_discplayer/templates/config.yaml.j2
+++ b/installer/ansible/roles/mpd_discplayer/templates/config.yaml.j2
@@ -3,7 +3,7 @@
MPDConnection:
Type: "unix"
- Address: "/run/user/{{ target_user_uid }}/mpd.socket"
+ Address: "{{ mpd_socket_path }}"
ReconnectWait: 5
AudioBackend: "none"
diff --git a/installer/ansible/roles/mpd_discplayer/vars/main.yml b/installer/ansible/roles/mpd_discplayer/vars/main.yml
index 79dd9c9c..bd5bde0c 100644
--- a/installer/ansible/roles/mpd_discplayer/vars/main.yml
+++ b/installer/ansible/roles/mpd_discplayer/vars/main.yml
@@ -1,3 +1,3 @@
---
-mpd_discplayer_version: "2026.5.0b1"
+mpd_discplayer_version: "2026.5.0b3"
mpd_discplayer_apt_version: "0.7.3"
diff --git a/installer/ansible/roles/odio_api/handlers/main.yml b/installer/ansible/roles/odio_api/handlers/main.yml
index d06c18d1..5036a163 100644
--- a/installer/ansible/roles/odio_api/handlers/main.yml
+++ b/installer/ansible/roles/odio_api/handlers/main.yml
@@ -6,6 +6,6 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/odio_api/tasks/main.yml b/installer/ansible/roles/odio_api/tasks/main.yml
index ae854483..c6360d8e 100644
--- a/installer/ansible/roles/odio_api/tasks/main.yml
+++ b/installer/ansible/roles/odio_api/tasks/main.yml
@@ -14,7 +14,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0700'
- become: true
+ become: "{{ become_for_target_user }}"
loop:
- "odio-api"
- "odio-api/conf.d"
@@ -41,7 +41,7 @@
org.freedesktop.login1.Manager CanReboot
register: odio_api_can_reboot
changed_when: false
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
ignore_errors: true
when: install_mode == "live"
@@ -52,7 +52,7 @@
org.freedesktop.login1.Manager CanPowerOff
register: odio_api_can_poweroff
changed_when: false
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
ignore_errors: true
when: install_mode == "live"
@@ -63,13 +63,11 @@
odio_api_power_can_poweroff: "{{ '\"yes\"' in odio_api_can_poweroff.stdout }}"
when: install_mode == "live"
-- name: Backup odio-api config before changes
- ansible.builtin.include_tasks: "../../../tasks/backup_conf_before.yml"
- vars:
- conf_path: "/home/{{ target_user }}/.config/odio-api/config.yaml"
- conf_owner: "{{ target_user }}"
- conf_group: "{{ target_user }}"
- conf_mode: '0640'
+- name: Resolve snapweb URL for systemd block
+ ansible.builtin.include_tasks: snapweb_url.yml
+ when:
+ - install_mode == "live"
+ - install_snapclient
- name: Deploy odio-api configuration
ansible.builtin.template:
@@ -78,7 +76,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0640'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart odio-api
- name: Deploy odio-api systemd reference (conf.d/.default)
@@ -88,15 +86,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0640'
- become: true
-
-- name: Backup odio-api config after changes
- ansible.builtin.include_tasks: "../../../tasks/backup_conf_after.yml"
- vars:
- conf_path: "/home/{{ target_user }}/.config/odio-api/config.yaml"
- conf_owner: "{{ target_user }}"
- conf_group: "{{ target_user }}"
- conf_mode: '0640'
+ become: "{{ become_for_target_user }}"
- name: Enable odio-api service
ansible.builtin.include_tasks: "../../../tasks/systemd_enable_user.yml"
diff --git a/installer/ansible/roles/odio_api/tasks/snapweb_url.yml b/installer/ansible/roles/odio_api/tasks/snapweb_url.yml
new file mode 100644
index 00000000..d22f29f6
--- /dev/null
+++ b/installer/ansible/roles/odio_api/tasks/snapweb_url.yml
@@ -0,0 +1,32 @@
+---
+- name: Read existing odio-api config
+ ansible.builtin.slurp:
+ src: "/home/{{ target_user }}/.config/odio-api/config.yaml"
+ register: odio_api_existing_cfg
+ failed_when: false
+ become: "{{ become_for_target_user }}"
+
+- name: Reuse existing snapweb URL
+ ansible.builtin.set_fact:
+ odio_api_snapweb_url: "{{ _snapclient_entry.url }}"
+ vars:
+ _cfg: "{{ odio_api_existing_cfg.content | default('') | b64decode | from_yaml | default({}, true) }}"
+ _snapclient_entry: "{{ (_cfg.systemd.user | default([]) | selectattr('name', 'equalto', 'snapclient.service') | list | first) | default({}) }}"
+ when: _snapclient_entry.url is defined
+
+- name: Discover snapweb URL via snapclientmpris
+ ansible.builtin.command: snapclientmpris --discover
+ register: odio_api_snapclient_discover
+ changed_when: false
+ become: "{{ become_for_target_user }}"
+ become_user: "{{ target_user }}"
+ ignore_errors: true
+ when: odio_api_snapweb_url is not defined
+
+- name: Set snapweb URL fact
+ ansible.builtin.set_fact:
+ odio_api_snapweb_url: "{{ odio_api_snapclient_discover.stdout | regex_search('snapweb:\\s*(\\S+)', '\\1') | first }}"
+ when:
+ - odio_api_snapweb_url is not defined
+ - odio_api_snapclient_discover is succeeded
+ - odio_api_snapclient_discover.stdout is search('snapweb:\\s*\\S+')
diff --git a/installer/ansible/roles/odio_api/templates/_systemd_block.yml.j2 b/installer/ansible/roles/odio_api/templates/_systemd_block.yml.j2
index 1e702f25..da3c32d4 100644
--- a/installer/ansible/roles/odio_api/templates/_systemd_block.yml.j2
+++ b/installer/ansible/roles/odio_api/templates/_systemd_block.yml.j2
@@ -22,9 +22,13 @@ systemd:
{% endif %}
{% if install_snapclient %}
- name: snapclient.service
+{% if odio_api_snapweb_url is defined %}
+ url: "{{ odio_api_snapweb_url }}"
+{% endif %}
{% endif %}
{% if install_spotifyd %}
- name: spotifyd.service
+ url: "https://open.spotify.com"
{% endif %}
{% if install_upmpdcli %}
- name: upmpdcli.service
diff --git a/installer/ansible/roles/odio_api/templates/config.yaml.j2 b/installer/ansible/roles/odio_api/templates/config.yaml.j2
index 578bdf49..dc059da7 100644
--- a/installer/ansible/roles/odio_api/templates/config.yaml.j2
+++ b/installer/ansible/roles/odio_api/templates/config.yaml.j2
@@ -6,7 +6,7 @@ bind: all
port: 8018
# Logging
-log_level: info
+logLevel: info
power:
enabled: {{ (odio_api_power_can_reboot or odio_api_power_can_poweroff) | lower }}
diff --git a/installer/ansible/roles/odio_api/vars/main.yml b/installer/ansible/roles/odio_api/vars/main.yml
index 52c1a688..8af4db21 100644
--- a/installer/ansible/roles/odio_api/vars/main.yml
+++ b/installer/ansible/roles/odio_api/vars/main.yml
@@ -1,3 +1,3 @@
---
-odio_api_version: "2026.5.0b2"
-odio_api_apt_version: "0.12.0"
+odio_api_version: "2026.5.0b3"
+odio_api_apt_version: "0.13.0"
diff --git a/installer/ansible/roles/pipewire/handlers/main.yml b/installer/ansible/roles/pipewire/handlers/main.yml
index ef24d46a..2181c0b2 100644
--- a/installer/ansible/roles/pipewire/handlers/main.yml
+++ b/installer/ansible/roles/pipewire/handlers/main.yml
@@ -10,6 +10,6 @@
- pipewire.service
- pipewire-pulse.service
- wireplumber.service
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/pipewire/tasks/main.yml b/installer/ansible/roles/pipewire/tasks/main.yml
index 0ae0de8b..eef93e5c 100644
--- a/installer/ansible/roles/pipewire/tasks/main.yml
+++ b/installer/ansible/roles/pipewire/tasks/main.yml
@@ -17,7 +17,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Backup pipewire-pulse config before changes
ansible.builtin.include_tasks: ../../../tasks/backup_conf_before.yml
@@ -33,7 +33,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0644'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart pipewire-pulse
- name: Backup pipewire-pulse config after changes
@@ -50,7 +50,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
when: install_snapclient
- name: Deploy snapcast-discover config
@@ -60,17 +60,20 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0644'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart pipewire-pulse
when: install_snapclient
- name: Enable user PipeWire services
ansible.builtin.include_tasks: "../../../tasks/systemd_enable_user.yml"
vars:
- service_name: "{{ item }}"
+ service_name: "{{ item.name }}"
+ wanted_by: "{{ item.wanted_by | default('default.target') }}"
loop:
- - pipewire.socket
- - pipewire.service
- - pipewire-pulse.socket
- - pipewire-pulse.service
- - wireplumber.service
+ # Sockets ship with [Install] WantedBy=sockets.target — image-mode would
+ # otherwise symlink into default.target.wants/ (cf systemd_enable_user.yml).
+ - { name: pipewire.socket, wanted_by: sockets.target }
+ - { name: pipewire.service }
+ - { name: pipewire-pulse.socket, wanted_by: sockets.target }
+ - { name: pipewire-pulse.service }
+ - { name: wireplumber.service }
diff --git a/installer/ansible/roles/pipewire/vars/main.yml b/installer/ansible/roles/pipewire/vars/main.yml
index 99547fc6..0e2dd9fa 100644
--- a/installer/ansible/roles/pipewire/vars/main.yml
+++ b/installer/ansible/roles/pipewire/vars/main.yml
@@ -1,2 +1,2 @@
---
-pipewire_version: "2026.4.0rc6"
+pipewire_version: "2026.5.0b3"
diff --git a/installer/ansible/roles/pulseaudio/handlers/main.yml b/installer/ansible/roles/pulseaudio/handlers/main.yml
index 16ac3f1a..52d0bfd6 100644
--- a/installer/ansible/roles/pulseaudio/handlers/main.yml
+++ b/installer/ansible/roles/pulseaudio/handlers/main.yml
@@ -6,7 +6,7 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
@@ -24,7 +24,7 @@
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when:
- install_mode == "live"
diff --git a/installer/ansible/roles/pulseaudio/vars/main.yml b/installer/ansible/roles/pulseaudio/vars/main.yml
index ab9ba657..bc76fb59 100644
--- a/installer/ansible/roles/pulseaudio/vars/main.yml
+++ b/installer/ansible/roles/pulseaudio/vars/main.yml
@@ -1,2 +1,2 @@
---
-pulseaudio_version: "2026.4.2b1"
+pulseaudio_version: "2026.5.0b3"
diff --git a/installer/ansible/roles/shairport_sync/handlers/main.yml b/installer/ansible/roles/shairport_sync/handlers/main.yml
index 2884a825..4472e9e6 100644
--- a/installer/ansible/roles/shairport_sync/handlers/main.yml
+++ b/installer/ansible/roles/shairport_sync/handlers/main.yml
@@ -13,6 +13,6 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/shairport_sync/tasks/main.yml b/installer/ansible/roles/shairport_sync/tasks/main.yml
index 26af3599..2a891a78 100644
--- a/installer/ansible/roles/shairport_sync/tasks/main.yml
+++ b/installer/ansible/roles/shairport_sync/tasks/main.yml
@@ -14,7 +14,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Copy shairport-sync system config to user directory
ansible.builtin.copy:
@@ -25,7 +25,7 @@
mode: '0600'
remote_src: true
force: false
- become: true
+ become: "{{ become_for_target_user }}"
- name: Backup shairport-sync config before changes
ansible.builtin.include_tasks: "../../../tasks/backup_conf_before.yml"
@@ -40,7 +40,7 @@
path: "/home/{{ target_user }}/.config/shairport-sync/shairport-sync.conf"
regexp: '^\s*//\s*name\s*=\s*"%H"'
line: ' name = "%h";'
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart shairport-sync
@@ -49,7 +49,7 @@
path: "/home/{{ target_user }}/.config/shairport-sync/shairport-sync.conf"
regexp: '^\s*//?\s*output_backend\s*='
line: ' output_backend = "pa";'
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart shairport-sync
@@ -58,7 +58,7 @@
path: "/home/{{ target_user }}/.config/shairport-sync/shairport-sync.conf"
regexp: '^\s*//?\s*mdns_backend\s*='
line: ' mdns_backend = "avahi";'
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart shairport-sync
@@ -67,7 +67,7 @@
path: "/home/{{ target_user }}/.config/shairport-sync/shairport-sync.conf"
regexp: '^\s*server\s*=\s*"127\.0\.0\.1"'
state: absent
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart shairport-sync
@@ -76,7 +76,7 @@
path: "/home/{{ target_user }}/.config/shairport-sync/shairport-sync.conf"
regexp: '^\s*//\s*application_name\s*=.*"Applications" tab'
line: ' application_name = "Shairport Sync";'
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart shairport-sync
diff --git a/installer/ansible/roles/shairport_sync/vars/main.yml b/installer/ansible/roles/shairport_sync/vars/main.yml
index 3474c121..5d5ea7fd 100644
--- a/installer/ansible/roles/shairport_sync/vars/main.yml
+++ b/installer/ansible/roles/shairport_sync/vars/main.yml
@@ -1,2 +1,2 @@
---
-shairport_sync_version: "2026.4.1rc1"
+shairport_sync_version: "2026.5.0b3"
diff --git a/installer/ansible/roles/snapclient/handlers/main.yml b/installer/ansible/roles/snapclient/handlers/main.yml
index e3eb41cd..50df9341 100644
--- a/installer/ansible/roles/snapclient/handlers/main.yml
+++ b/installer/ansible/roles/snapclient/handlers/main.yml
@@ -6,6 +6,17 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
+ become_user: "{{ target_user }}"
+ when: install_mode == "live"
+
+- name: Restart snapclientmpris
+ ansible.builtin.systemd:
+ name: snapclientmpris.service
+ state: restarted
+ scope: user
+ environment:
+ XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/snapclient/tasks/main.yml b/installer/ansible/roles/snapclient/tasks/main.yml
index 9bc4d54d..3a02ebbb 100644
--- a/installer/ansible/roles/snapclient/tasks/main.yml
+++ b/installer/ansible/roles/snapclient/tasks/main.yml
@@ -7,6 +7,9 @@
become: true
notify: Restart snapclient
+- name: Install snapclientmpris
+ ansible.builtin.include_tasks: snapclientmpris.yml
+
- name: Disable system snapclient service
ansible.builtin.include_tasks: "../../../tasks/systemd_disable_system.yml"
vars:
diff --git a/installer/ansible/roles/snapclient/tasks/snapclientmpris.yml b/installer/ansible/roles/snapclient/tasks/snapclientmpris.yml
new file mode 100644
index 00000000..88e3fcca
--- /dev/null
+++ b/installer/ansible/roles/snapclient/tasks/snapclientmpris.yml
@@ -0,0 +1,13 @@
+---
+- name: Install snapclientmpris
+ ansible.builtin.apt:
+ name: "snapclientmpris>={{ snapclient_snapclientmpris_version }}"
+ state: present
+ install_recommends: false
+ become: true
+ notify: Restart snapclientmpris
+
+- name: Enable user snapclient service
+ ansible.builtin.include_tasks: "../../../tasks/systemd_enable_user.yml"
+ vars:
+ service_name: snapclientmpris.service
diff --git a/installer/ansible/roles/snapclient/vars/main.yml b/installer/ansible/roles/snapclient/vars/main.yml
index 46f103c1..461ff2c9 100644
--- a/installer/ansible/roles/snapclient/vars/main.yml
+++ b/installer/ansible/roles/snapclient/vars/main.yml
@@ -1,2 +1,3 @@
---
-snapclient_version: "2026.4.0rc5"
+snapclient_version: "2026.5.0b3"
+snapclient_snapclientmpris_version: "1.2.0"
diff --git a/installer/ansible/roles/spotifyd/handlers/main.yml b/installer/ansible/roles/spotifyd/handlers/main.yml
index edf4411a..bb9b31eb 100644
--- a/installer/ansible/roles/spotifyd/handlers/main.yml
+++ b/installer/ansible/roles/spotifyd/handlers/main.yml
@@ -6,6 +6,6 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/spotifyd/tasks/main.yml b/installer/ansible/roles/spotifyd/tasks/main.yml
index 61621e02..45720e9e 100644
--- a/installer/ansible/roles/spotifyd/tasks/main.yml
+++ b/installer/ansible/roles/spotifyd/tasks/main.yml
@@ -1,7 +1,7 @@
---
- name: Install spotifyd v{{ spotifyd_apt_version }}
ansible.builtin.apt:
- name: "spotifyd={{ spotifyd_apt_version }}"
+ name: "spotifyd>={{ spotifyd_apt_version }}"
state: present
become: true
notify: Restart spotifyd
@@ -13,7 +13,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Backup spotifyd config before changes
ansible.builtin.include_tasks: "../../../tasks/backup_conf_before.yml"
@@ -30,7 +30,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0644'
- become: true
+ become: "{{ become_for_target_user }}"
notify: Restart spotifyd
- name: Backup spotifyd config after changes
diff --git a/installer/ansible/roles/spotifyd/vars/main.yml b/installer/ansible/roles/spotifyd/vars/main.yml
index 53cbf4ec..285a7941 100644
--- a/installer/ansible/roles/spotifyd/vars/main.yml
+++ b/installer/ansible/roles/spotifyd/vars/main.yml
@@ -1,3 +1,3 @@
---
-spotifyd_version: "2026.5.0b1"
+spotifyd_version: "2026.5.0b3"
spotifyd_apt_version: "0.4.4"
diff --git a/installer/ansible/roles/upgrade/tasks/main.yml b/installer/ansible/roles/upgrade/tasks/main.yml
index 68103ddc..10d8ed6a 100644
--- a/installer/ansible/roles/upgrade/tasks/main.yml
+++ b/installer/ansible/roles/upgrade/tasks/main.yml
@@ -5,7 +5,7 @@
ansible.builtin.file:
path: "/home/{{ target_user }}/.local/bin/{{ item }}"
state: absent
- become: true
+ become: "{{ become_for_target_user }}"
loop:
- odio-check-upgrade
- odio-upgrade
@@ -15,12 +15,19 @@
ansible.builtin.file:
path: "/home/{{ target_user }}/.config/systemd/user/{{ item }}"
state: absent
- become: true
+ become: "{{ become_for_target_user }}"
loop:
- odio-check-upgrade.service
- odio-check-upgrade.timer
- odio-upgrade.service
+# Legacy: image-mode used to symlink the timer into default.target.wants/.
+- name: Remove legacy timer wants symlink
+ ansible.builtin.file:
+ path: "/home/{{ target_user }}/.config/systemd/user/default.target.wants/odio-check-upgrade.timer"
+ state: absent
+ become: "{{ become_for_target_user }}"
+
- name: Install odio-upgrade script
ansible.builtin.copy:
src: odio_upgrade.py
@@ -56,6 +63,7 @@
vars:
service_name: odio-check-upgrade.timer
service_unit_path: /etc/systemd/user/odio-check-upgrade.timer
+ wanted_by: timers.target
- name: Install unattended-upgrades (image mode)
ansible.builtin.apt:
diff --git a/installer/ansible/roles/upgrade/vars/main.yml b/installer/ansible/roles/upgrade/vars/main.yml
index 90cf6f33..c92d5ee2 100644
--- a/installer/ansible/roles/upgrade/vars/main.yml
+++ b/installer/ansible/roles/upgrade/vars/main.yml
@@ -1,2 +1,2 @@
---
-upgrade_version: "2026.5.0b1"
+upgrade_version: "2026.5.0b3"
diff --git a/installer/ansible/roles/upmpdcli/handlers/main.yml b/installer/ansible/roles/upmpdcli/handlers/main.yml
index 8c8e50b5..86fc641c 100644
--- a/installer/ansible/roles/upmpdcli/handlers/main.yml
+++ b/installer/ansible/roles/upmpdcli/handlers/main.yml
@@ -6,6 +6,6 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/roles/upmpdcli/tasks/deezer.yml b/installer/ansible/roles/upmpdcli/tasks/deezer.yml
index d8fabe57..fddcb86a 100644
--- a/installer/ansible/roles/upmpdcli/tasks/deezer.yml
+++ b/installer/ansible/roles/upmpdcli/tasks/deezer.yml
@@ -4,7 +4,7 @@
path: /home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf
regexp: '^#?deezeruser\s*='
line: "deezeruser = {{ deezer_user }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart upmpdcli
@@ -13,6 +13,6 @@
path: /home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf
regexp: '^#?deezerpass\s*='
line: "deezerpass = {{ deezer_pass }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart upmpdcli
diff --git a/installer/ansible/roles/upmpdcli/tasks/main.yml b/installer/ansible/roles/upmpdcli/tasks/main.yml
index d292f557..276003f6 100644
--- a/installer/ansible/roles/upmpdcli/tasks/main.yml
+++ b/installer/ansible/roles/upmpdcli/tasks/main.yml
@@ -33,7 +33,7 @@
owner: "{{ target_user }}"
group: "{{ target_user }}"
mode: '0755'
- become: true
+ become: "{{ become_for_target_user }}"
- name: Copy upmpdcli system config to user directory
ansible.builtin.copy:
@@ -44,7 +44,7 @@
mode: '0600'
remote_src: true
force: false
- become: true
+ become: "{{ become_for_target_user }}"
- name: Backup upmpdcli config before changes
ansible.builtin.include_tasks: "../../../tasks/backup_conf_before.yml"
@@ -59,7 +59,7 @@
path: "/home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf"
regexp: '^#?avfriendlyname\s*='
line: "avfriendlyname = UpMpd/AV-{{ target_hostname }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart upmpdcli
@@ -68,7 +68,7 @@
path: "/home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf"
regexp: '^#?upnpav\s*='
line: "upnpav = 1"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart upmpdcli
@@ -77,7 +77,7 @@
path: "/home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf"
regexp: '^#?openhome\s*='
line: "openhome = 1"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart upmpdcli
@@ -86,7 +86,7 @@
path: "/home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf"
regexp: '^#?mpdhost\s*='
line: "mpdhost = localhost"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart upmpdcli
@@ -95,7 +95,7 @@
path: "/home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf"
regexp: '^#?mpdport\s*='
line: "mpdport = 6600"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
notify: Restart upmpdcli
diff --git a/installer/ansible/roles/upmpdcli/tasks/tidal.yml b/installer/ansible/roles/upmpdcli/tasks/tidal.yml
index ba1dc9c3..2812ec9a 100644
--- a/installer/ansible/roles/upmpdcli/tasks/tidal.yml
+++ b/installer/ansible/roles/upmpdcli/tasks/tidal.yml
@@ -3,7 +3,7 @@
ansible.builtin.pip:
break_system_packages: true
name: tidalapi
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
- name: Install upmpdcli-tidal
diff --git a/installer/ansible/roles/upmpdcli/tasks/webradios.yml b/installer/ansible/roles/upmpdcli/tasks/webradios.yml
index 1daf6436..3edd8934 100644
--- a/installer/ansible/roles/upmpdcli/tasks/webradios.yml
+++ b/installer/ansible/roles/upmpdcli/tasks/webradios.yml
@@ -3,7 +3,7 @@
ansible.builtin.pip:
break_system_packages: true
name: pyradios
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
- name: Install upmpdcli web radio plugins
@@ -23,7 +23,7 @@
path: "/home/{{ target_user }}/.config/upmpdcli/upmpdcli.conf"
regexp: "^#?{{ item.key }}\\s*="
line: "{{ item.key }} = {{ item.value }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
loop:
- { key: "upradiosuser", value: "bugsbunny" }
diff --git a/installer/ansible/roles/upmpdcli/vars/main.yml b/installer/ansible/roles/upmpdcli/vars/main.yml
index e638818e..fc1873fe 100644
--- a/installer/ansible/roles/upmpdcli/vars/main.yml
+++ b/installer/ansible/roles/upmpdcli/vars/main.yml
@@ -1,2 +1,2 @@
---
-upmpdcli_version: "2026.4.2b2"
+upmpdcli_version: "2026.5.0b3"
diff --git a/installer/ansible/tasks/restart_odio_api.yml b/installer/ansible/tasks/restart_odio_api.yml
index 9c01a7f4..e133ac31 100644
--- a/installer/ansible/tasks/restart_odio_api.yml
+++ b/installer/ansible/tasks/restart_odio_api.yml
@@ -10,7 +10,7 @@
scope: user
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when:
- install_mode == "live"
diff --git a/installer/ansible/tasks/systemd_disable_user.yml b/installer/ansible/tasks/systemd_disable_user.yml
index 368944b9..48e6751f 100644
--- a/installer/ansible/tasks/systemd_disable_user.yml
+++ b/installer/ansible/tasks/systemd_disable_user.yml
@@ -13,24 +13,12 @@
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
-- name: Stop user service - live
- ansible.builtin.systemd:
- name: "{{ service_name }}"
- state: stopped
- scope: user
- environment:
- XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
- become_user: "{{ target_user }}"
- register: _disable_user_result
- failed_when:
- - _disable_user_result.failed | bool
- - '"Could not find" not in (_disable_user_result.msg | default(""))'
- when: install_mode == "live"
+- name: Stop user service if active
+ ansible.builtin.include_tasks: systemd_stop_user.yml
- name: Disable user service - image build
ansible.builtin.systemd:
diff --git a/installer/ansible/tasks/systemd_enable_user.yml b/installer/ansible/tasks/systemd_enable_user.yml
index e57e49d5..84837bab 100644
--- a/installer/ansible/tasks/systemd_enable_user.yml
+++ b/installer/ansible/tasks/systemd_enable_user.yml
@@ -4,6 +4,10 @@
# Variables:
# service_name: name of the service (e.g. pulseaudio.service)
# service_unit_path: (optional) override, defaults to /usr/lib/systemd/user/{{ service_name }}
+# wanted_by: (optional) target the unit is wanted by, defaults to default.target.
+# Must match the unit file's [Install] WantedBy= (e.g. mpd.socket →
+# sockets.target) — image-mode otherwise creates a non-canonical
+# symlink that live `systemctl --user disable` won't remove.
- name: Enable user service - live
ansible.builtin.systemd:
@@ -13,14 +17,14 @@
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
- name: Enable user service - image build
ansible.builtin.file:
src: "{{ service_unit_path | default('/usr/lib/systemd/user/' + service_name) }}"
- dest: "/home/{{ target_user }}/.config/systemd/user/default.target.wants/{{ service_name }}"
+ dest: "/home/{{ target_user }}/.config/systemd/user/{{ wanted_by | default('default.target') }}.wants/{{ service_name }}"
state: link
owner: "{{ target_user }}"
group: "{{ target_user }}"
diff --git a/installer/ansible/tasks/systemd_mask_user.yml b/installer/ansible/tasks/systemd_mask_user.yml
index 857cc8ae..f8674ed4 100644
--- a/installer/ansible/tasks/systemd_mask_user.yml
+++ b/installer/ansible/tasks/systemd_mask_user.yml
@@ -12,7 +12,7 @@
daemon_reload: true
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/tasks/systemd_stop_user.yml b/installer/ansible/tasks/systemd_stop_user.yml
new file mode 100644
index 00000000..7195910b
--- /dev/null
+++ b/installer/ansible/tasks/systemd_stop_user.yml
@@ -0,0 +1,29 @@
+---
+# Stop a systemd user service if it's active. Enablement symlink stays.
+# Variables:
+# service_name: name of the service (e.g. mympd.service)
+
+- name: Check user service active state - {{ service_name }}
+ ansible.builtin.systemd_service:
+ name: "{{ service_name }}"
+ scope: user
+ environment:
+ XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
+ become: "{{ become_for_target_user }}"
+ become_user: "{{ target_user }}"
+ register: _stop_user_check
+ failed_when: false
+ when: install_mode == "live"
+
+- name: Stop user service - live
+ ansible.builtin.systemd_service:
+ name: "{{ service_name }}"
+ state: stopped
+ scope: user
+ environment:
+ XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
+ become: "{{ become_for_target_user }}"
+ become_user: "{{ target_user }}"
+ when:
+ - install_mode == "live"
+ - _stop_user_check.status.ActiveState | default('') == 'active'
diff --git a/installer/ansible/tasks/verify_services.yml b/installer/ansible/tasks/verify_services.yml
index aed29c61..6ca0c94f 100644
--- a/installer/ansible/tasks/verify_services.yml
+++ b/installer/ansible/tasks/verify_services.yml
@@ -32,9 +32,9 @@
+ (['mpd-discplayer.service'] if install_mpd_discplayer | bool else [])
+ (['mympd.service'] if install_mympd | bool else [])
+ (['shairport-sync.service'] if install_shairport_sync | bool else [])
- + (['snapclient.service'] if install_snapclient | bool else [])
+ (['upmpdcli.service'] if install_upmpdcli | bool else [])
+ (['spotifyd.service'] if install_spotifyd | bool else [])
+ + (['snapclient.service', 'snapclientmpris.service'] if install_snapclient | bool else [])
+ (['odio-api.service'] if install_odio_api | bool else [])
}}
when: install_mode == "live"
@@ -69,7 +69,7 @@
loop: "{{ _odios_expected_user_services }}"
environment:
XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}"
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
when: install_mode == "live"
diff --git a/installer/ansible/tasks/write_state.yml b/installer/ansible/tasks/write_state.yml
index c7a7e1da..d320122c 100644
--- a/installer/ansible/tasks/write_state.yml
+++ b/installer/ansible/tasks/write_state.yml
@@ -89,7 +89,7 @@
ansible.builtin.slurp:
src: "/home/{{ target_user }}/.cache/odio/state.json"
register: _odios_existing_state_legacy
- become: true
+ become: "{{ become_for_target_user }}"
failed_when: false
when:
- _odios_existing_state_lib.content is not defined
@@ -179,7 +179,7 @@
- name: Refresh /var/cache/odio/upgrades.json
ansible.builtin.command: /usr/local/bin/odio-upgrade check
- become: true
+ become: "{{ become_for_target_user }}"
become_user: "{{ target_user }}"
changed_when: false
failed_when: false
diff --git a/scripts/check-role-drift.sh b/scripts/check-role-drift.sh
index 69e1d828..4cb8ddff 100755
--- a/scripts/check-role-drift.sh
+++ b/scripts/check-role-drift.sh
@@ -13,6 +13,7 @@ set -euo pipefail
base="${1:-$(git describe --tags --abbrev=0 --match='[0-9][0-9][0-9][0-9].*' HEAD)}"
roles_dir="installer/ansible/roles"
drift=()
+bumped=()
# A role is in drift when its files differ from but its declared
# version still matches what it was at (or matches itself
@@ -38,6 +39,8 @@ for d in "$roles_dir"/*/; do
fi
elif [[ "$cur" == "$old" ]]; then
drift+=("$role (still $cur, but files changed since $base)")
+ else
+ bumped+=("$role:$cur")
fi
done
@@ -47,4 +50,20 @@ if [[ ${#drift[@]} -gt 0 ]]; then
exit 1
fi
+# All bumped roles in this branch must share the same target version
+# (the version of the release we're cutting). Pick the max as the target
+# and flag any role bumped to a lower version.
+if [[ ${#bumped[@]} -gt 0 ]]; then
+ target=$(printf '%s\n' "${bumped[@]}" | cut -d: -f2 | sort -V | tail -1)
+ misaligned=()
+ for entry in "${bumped[@]}"; do
+ [[ "${entry#*:}" == "$target" ]] || misaligned+=("${entry%:*} (${entry#*:}, expected $target)")
+ done
+ if [[ ${#misaligned[@]} -gt 0 ]]; then
+ echo "Roles bumped to a version below the branch target ($target):" >&2
+ printf ' - %s\n' "${misaligned[@]}" >&2
+ exit 1
+ fi
+fi
+
echo "✓ All modified roles have bumped versions"
diff --git a/tests/test.sh b/tests/test.sh
index ada60a94..6c06ee92 100755
--- a/tests/test.sh
+++ b/tests/test.sh
@@ -110,6 +110,18 @@ run_odio_upgrade_embedded() {
/usr/local/bin/odio-upgrade --version "${tag}" --force
}
+# Create a non-target_user with NOPASSWD sudo. Shared by the install/
+# upgrade variants that exercise the path where the invoker is not the
+# target_user.
+setup_other_user() {
+ local user="$1"
+ echo "=== Setting up ${user} (NOPASSWD sudo) ==="
+ docker exec "${CONTAINER_NAME}" bash -c "
+ useradd -m -s /bin/bash ${user} &&
+ echo '${user} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${user}
+ "
+}
+
# Validates that a non-target_user member of the `users` + `odio` groups
# can trigger an upgrade by running odio-upgrade directly (no sudo prefix).
# The odio group grants read on /var/lib/odio/state.json, install.sh's
@@ -123,12 +135,11 @@ run_odio_upgrade_fetch_as_other_user() {
local url
url=$(odio_upgrade_url "$tag")
- echo "=== Setting up ${user} (users + odio groups, NOPASSWD sudo) ==="
+ setup_other_user "${user}"
+ echo "=== Adding ${user} to users + odio groups ==="
docker exec "${CONTAINER_NAME}" bash -c "
groupadd -f odio &&
- useradd -m -s /bin/bash ${user} &&
- usermod -aG users,odio ${user} &&
- echo '${user} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${user}
+ usermod -aG users,odio ${user}
"
echo "=== curl odio-upgrade (${url}) → run as ${user} (target=${tag}, mode=${install_mode}) ==="
@@ -139,6 +150,17 @@ run_odio_upgrade_fetch_as_other_user() {
"
}
+# Validates that a non-target_user with NOPASSWD sudo can run install.sh
+# for TARGET_USER=odio. install.sh's internal `sudo apt-get` and the
+# playbook's `become` both rely on the invoker's sudo; the playbook
+# creates the odio user/group itself.
+run_install_as_other_user() {
+ local tag="$1"
+ local user="bob"
+ setup_other_user "${user}"
+ run_install "${tag}" "${user}" image "${@:2}"
+}
+
# Real-release path: odio.love/manifest.json drives the target via
# `odio-upgrade check`, then `systemctl --user start odio-upgrade.service`
# runs the unit (no --version arg). Only valid when the published manifest
@@ -204,9 +226,11 @@ while [[ "${1:-}" == --* ]]; do
echo " shell - Shell into running container"
echo " rerun - Re-run playbook without restart"
echo " clean - Remove container"
- echo " install [TAG] - Test install.sh as user odio (sudo)"
- echo " install-root [TAG] - Test install.sh as root, TARGET_USER=odio"
- echo " TAG examples: latest, pr-2, 2026.3.0"
+ echo " install [TAG] - Test install.sh as user odio (sudo)"
+ echo " install-root [TAG] - Test install.sh as root, TARGET_USER=odio"
+ echo " install-as-other-user [TAG] - Test install.sh as bob (NOPASSWD sudoer), TARGET_USER=odio"
+ echo " test-as-other-user - Run playbook directly as bob (live mode) → exercises become_for_target_user paths"
+ echo " TAG examples: latest, pr-2, 2026.3.0"
echo " upgrade B T - Upgrade from baseline tag B to target tag T (INSTALL_MODE=live)"
echo " upgrade-from-image-fetch T - Upgrade to T on REMOTE_IMAGE — curls odio-upgrade from the T release first"
echo " upgrade-from-image-embedded T - Same, but uses the baseline's /usr/local/bin/odio-upgrade"
@@ -219,7 +243,7 @@ while [[ "${1:-}" == --* ]]; do
done
case "${1:-}" in
- shell|rerun|clean|install|install-root|upgrade|upgrade-from-image-fetch|upgrade-from-image-embedded|upgrade-from-image-systemctl|upgrade-from-image-fetch-as-other-user)
+ shell|rerun|rerun-as-other-user|clean|install|install-root|install-as-other-user|test|test-as-other-user|upgrade|upgrade-from-image-fetch|upgrade-from-image-embedded|upgrade-from-image-systemctl|upgrade-from-image-fetch-as-other-user)
ACTION="$1"
shift
;;
@@ -278,6 +302,16 @@ case "${ACTION}" in
"$@"
;;
+ rerun-as-other-user)
+ echo "=== Re-running playbook as bob ==="
+ docker exec -u bob "${CONTAINER_NAME}" \
+ ansible-playbook -i inventory/localhost.yml /opt/odios/ansible/playbook.yml \
+ -e target_user=odio \
+ -e install_mode=live \
+ -e "mpd_discplayer_gnu_email=test@example.com" \
+ "$@"
+ ;;
+
clean)
docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true
echo "Cleaned."
@@ -299,6 +333,31 @@ case "${ACTION}" in
echo "=== Done ==="
;;
+ install-as-other-user)
+ BASELINE="${1:-latest}"
+ shift || true
+ start_container
+ run_install_as_other_user "${BASELINE}" "$@"
+ echo "=== Done ==="
+ ;;
+
+ test-as-other-user)
+ start_container
+ install_ansible
+ setup_other_user bob
+
+ echo "=== Running playbook as bob (target_user=odio, install_mode=live) ==="
+ docker exec -u bob "${CONTAINER_NAME}" \
+ ansible-playbook -v -i inventory/localhost.yml \
+ /opt/odios/ansible/playbook.yml \
+ -e target_user=odio \
+ -e install_mode=live \
+ -e "mpd_discplayer_gnu_email=test@example.com" \
+ "$@"
+
+ echo "=== Done ==="
+ ;;
+
upgrade)
BASELINE="${1:?baseline tag required (e.g. 2026.4.0rc3)}"
TARGET="${2:?target tag required (e.g. pr-42 or 2026.4.1rc2)}"