From 52d4adf81f0d39feb100d416cc10eee2ab032b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Sun, 24 May 2026 17:46:54 +0200 Subject: [PATCH 01/14] ci: bump github action version --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72f26f86..48728148 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 }}" @@ -540,7 +540,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 +581,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 From f9bd97321a4025ea6a30415733ed187f298bf31b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Sun, 24 May 2026 02:22:50 +0200 Subject: [PATCH 02/14] fix: check_drift check bump matches next version --- scripts/check-role-drift.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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" From 3403b56ac630bc27830f76a89446581aa0142356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Sun, 24 May 2026 23:36:19 +0200 Subject: [PATCH 03/14] fix: proper target folder for timers and sockets --- installer/ansible/roles/common/tasks/main.yml | 2 ++ installer/ansible/roles/pipewire/tasks/main.yml | 15 +++++++++------ installer/ansible/roles/upgrade/tasks/main.yml | 8 ++++++++ installer/ansible/tasks/systemd_enable_user.yml | 6 +++++- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/installer/ansible/roles/common/tasks/main.yml b/installer/ansible/roles/common/tasks/main.yml index 7551f4f8..3a5b2038 100644 --- a/installer/ansible/roles/common/tasks/main.yml +++ b/installer/ansible/roles/common/tasks/main.yml @@ -95,6 +95,8 @@ 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/pipewire/tasks/main.yml b/installer/ansible/roles/pipewire/tasks/main.yml index 0ae0de8b..3c4f46e0 100644 --- a/installer/ansible/roles/pipewire/tasks/main.yml +++ b/installer/ansible/roles/pipewire/tasks/main.yml @@ -67,10 +67,13 @@ - 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/upgrade/tasks/main.yml b/installer/ansible/roles/upgrade/tasks/main.yml index 68103ddc..82eb63d1 100644 --- a/installer/ansible/roles/upgrade/tasks/main.yml +++ b/installer/ansible/roles/upgrade/tasks/main.yml @@ -21,6 +21,13 @@ - 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/tasks/systemd_enable_user.yml b/installer/ansible/tasks/systemd_enable_user.yml index e57e49d5..932611f3 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: @@ -20,7 +24,7 @@ - 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 }}" From 8ae3a9faad6439c91714963f7978cc42c89171ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Sun, 24 May 2026 22:19:55 +0200 Subject: [PATCH 04/14] image-builder: add yq dependency --- image-builder/config.sh | 8 ++++++++ image-builder/lib/provision.sh | 3 +++ 2 files changed, 11 insertions(+) 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/lib/provision.sh b/image-builder/lib/provision.sh index 5dd0a2fa..593eb5e4 100644 --- a/image-builder/lib/provision.sh +++ b/image-builder/lib/provision.sh @@ -68,6 +68,9 @@ 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" From 01de20cab751de84a0f2fdff0d799b25b8b896d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Sun, 24 May 2026 13:03:35 +0200 Subject: [PATCH 05/14] test: add install as other user tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors upgrade-from-image-fetch-as-other-user for the install path — runs install.sh as bob (NOPASSWD sudoer) with TARGET_USER=odio to cover the second-admin install case. Factored out setup_other_user so both flows share the bob/sudoers setup. Wired into the test-install matrix and documented alongside install / install-root. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 16 ++++++-- installer/README.md | 3 ++ tests/test.sh | 75 +++++++++++++++++++++++++++++++---- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48728148..34e4dd82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 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/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)}" From b7b3b0354b438dffd9c51e1f298dfd863fb25675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 21 May 2026 14:51:37 +0200 Subject: [PATCH 06/14] refactor: use standard mpd socket --- installer/ansible/group_vars/all/main.yml | 3 + installer/ansible/roles/mpd/tasks/main.yml | 22 +++--- installer/ansible/roles/mpd/tasks/mympd.yml | 41 ++++++----- .../roles/mpd/templates/mpDris2.conf.j2 | 7 +- .../ansible/roles/mpd/templates/mpd.conf.j2 | 71 ------------------- .../mpd_discplayer/templates/config.yaml.j2 | 2 +- .../roles/mpd_discplayer/vars/main.yml | 2 +- installer/ansible/tasks/systemd_stop_user.yml | 21 ++++++ 8 files changed, 67 insertions(+), 102 deletions(-) delete mode 100644 installer/ansible/roles/mpd/templates/mpd.conf.j2 create mode 100644 installer/ansible/tasks/systemd_stop_user.yml 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/roles/mpd/tasks/main.yml b/installer/ansible/roles/mpd/tasks/main.yml index 8ce44a6a..72e41ffa 100644 --- a/installer/ansible/roles/mpd/tasks/main.yml +++ b/installer/ansible/roles/mpd/tasks/main.yml @@ -76,19 +76,21 @@ } state_file "~/.local/share/mpd/state" sticker_file "~/.local/share/mpd/sticker.sql" + auto_update "yes" become: true 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" + state: absent become: true become_user: "{{ target_user }}" notify: Restart mpd @@ -158,7 +160,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/mympd.yml b/installer/ansible/roles/mpd/tasks/mympd.yml index 53cf8a93..2ed76e03 100644 --- a/installer/ansible/roles/mpd/tasks/mympd.yml +++ b/installer/ansible/roles/mpd/tasks/mympd.yml @@ -36,9 +36,6 @@ become: true 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" @@ -48,24 +45,32 @@ mode: '0700' become: true -- name: Configure mympd MPD host (user socket) - ansible.builtin.copy: - content: "/run/user/{{ target_user_uid }}/mpd.socket" - dest: "/home/{{ target_user }}/.config/mympd/state/mpd_host" - owner: "{{ target_user }}" - group: "{{ target_user }}" - mode: '0600' +# myMPD autoconfigures its MPD connection by probing ${XDG_RUNTIME_DIR}/mpd/socket +# (the standard user socket now created by the mpd.socket unit). Clear any stale +# host/port left by older installs that pinned the legacy /run/user/UID/mpd.socket +# path, so autoconf re-runs on next start. myMPD must be stopped first — it +# rewrites state/ on shutdown, so a plain restart would just re-pin the old +# value. Only act when a legacy pin is actually present. +- name: Check for stale mympd MPD host pin + ansible.builtin.stat: + path: "/home/{{ target_user }}/.config/mympd/state/mpd_host" + register: mpd_mympd_stale_host become: true - notify: Restart mympd -- name: Configure mympd MPD port (0 = socket) - ansible.builtin.copy: - content: "0" - dest: "/home/{{ target_user }}/.config/mympd/state/mpd_port" - owner: "{{ target_user }}" - group: "{{ target_user }}" - mode: '0600' +- name: Stop mympd before clearing stale MPD pin + ansible.builtin.include_tasks: "../../../tasks/systemd_stop_user.yml" + vars: + service_name: mympd.service + when: mpd_mympd_stale_host.stat.exists + +- name: Remove stale mympd MPD host/port + ansible.builtin.file: + path: "/home/{{ target_user }}/.config/mympd/state/{{ item }}" + state: absent become: true + loop: + - mpd_host + - mpd_port notify: Restart mympd - name: Configure mympd http port 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_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/tasks/systemd_stop_user.yml b/installer/ansible/tasks/systemd_stop_user.yml new file mode 100644 index 00000000..4e2c490a --- /dev/null +++ b/installer/ansible/tasks/systemd_stop_user.yml @@ -0,0 +1,21 @@ +--- +# Stop a systemd user service without disabling it (the enablement symlink +# stays, so it comes back on next boot / via a Restart handler). +# Only acts in live mode; ignores "Could not find" (service may be absent). +# Variables: +# service_name: name of the service (e.g. mympd.service) + +- 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: _stop_user_result + failed_when: + - _stop_user_result.failed | bool + - '"Could not find" not in (_stop_user_result.msg | default(""))' + when: install_mode == "live" From b3708d30e6bcb59272bc7b559881e109ef89da06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 21 May 2026 23:52:31 +0200 Subject: [PATCH 07/14] refactor: conditionnal become for odio user tasks --- installer/ansible/playbook.yml | 8 ++++ .../ansible/roles/branding/tasks/main.yml | 8 ++-- .../ansible/roles/branding/vars/main.yml | 2 +- installer/ansible/roles/common/tasks/main.yml | 8 ++-- installer/ansible/roles/common/vars/main.yml | 2 +- installer/ansible/roles/mpd/handlers/main.yml | 6 +-- installer/ansible/roles/mpd/tasks/main.yml | 18 ++++---- installer/ansible/roles/mpd/tasks/mpdris2.yml | 6 +-- installer/ansible/roles/mpd/tasks/mympd.yml | 44 ++++++------------- .../roles/mpd_discplayer/handlers/main.yml | 2 +- .../roles/mpd_discplayer/tasks/main.yml | 10 ++--- .../tasks/validate_external_mpd.yml | 3 +- .../ansible/roles/odio_api/handlers/main.yml | 2 +- .../ansible/roles/odio_api/tasks/main.yml | 10 ++--- .../ansible/roles/pipewire/handlers/main.yml | 2 +- .../ansible/roles/pipewire/tasks/main.yml | 8 ++-- .../ansible/roles/pipewire/vars/main.yml | 2 +- .../roles/pulseaudio/handlers/main.yml | 4 +- .../ansible/roles/pulseaudio/vars/main.yml | 2 +- .../roles/shairport_sync/handlers/main.yml | 2 +- .../roles/shairport_sync/tasks/main.yml | 14 +++--- .../roles/shairport_sync/vars/main.yml | 2 +- .../roles/snapclient/handlers/main.yml | 2 +- .../ansible/roles/spotifyd/handlers/main.yml | 2 +- .../ansible/roles/spotifyd/tasks/main.yml | 6 +-- .../ansible/roles/spotifyd/vars/main.yml | 2 +- .../ansible/roles/upgrade/tasks/main.yml | 4 +- installer/ansible/roles/upgrade/vars/main.yml | 2 +- .../ansible/roles/upmpdcli/handlers/main.yml | 2 +- .../ansible/roles/upmpdcli/tasks/deezer.yml | 4 +- .../ansible/roles/upmpdcli/tasks/main.yml | 14 +++--- .../ansible/roles/upmpdcli/tasks/tidal.yml | 2 +- .../roles/upmpdcli/tasks/webradios.yml | 4 +- .../ansible/roles/upmpdcli/vars/main.yml | 2 +- installer/ansible/tasks/restart_odio_api.yml | 2 +- .../ansible/tasks/systemd_disable_user.yml | 4 +- .../ansible/tasks/systemd_enable_user.yml | 2 +- installer/ansible/tasks/systemd_mask_user.yml | 2 +- installer/ansible/tasks/systemd_stop_user.yml | 2 +- installer/ansible/tasks/verify_services.yml | 2 +- installer/ansible/tasks/write_state.yml | 4 +- 41 files changed, 110 insertions(+), 119 deletions(-) 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 3a5b2038..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,7 +91,7 @@ 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" 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/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 72e41ffa..9fac9357 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: @@ -77,7 +77,7 @@ state_file "~/.local/share/mpd/state" sticker_file "~/.local/share/mpd/sticker.sql" auto_update "yes" - become: true + become: "{{ become_for_target_user }}" become_user: "{{ target_user }}" notify: Restart mpd @@ -91,7 +91,7 @@ path: "/home/{{ target_user }}/.config/mpd/mpd.conf" marker: "# {mark} ANSIBLE MANAGED BLOCK - network" state: absent - become: true + become: "{{ become_for_target_user }}" become_user: "{{ target_user }}" notify: Restart mpd @@ -102,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 @@ -116,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 @@ -129,7 +129,7 @@ type "pulse" name "pulseaudio" } - become: true + become: "{{ become_for_target_user }}" become_user: "{{ target_user }}" notify: Restart mpd @@ -149,7 +149,7 @@ group: "{{ target_user }}" mode: '0644' force: false - become: true + become: "{{ become_for_target_user }}" - name: Create music directory ansible.builtin.file: diff --git a/installer/ansible/roles/mpd/tasks/mpdris2.yml b/installer/ansible/roles/mpd/tasks/mpdris2.yml index 39d2d3f6..d7baccaa 100644 --- a/installer/ansible/roles/mpd/tasks/mpdris2.yml +++ b/installer/ansible/roles/mpd/tasks/mpdris2.yml @@ -28,7 +28,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 +36,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 2ed76e03..5d3311cc 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,7 +33,7 @@ owner: "{{ target_user }}" group: "{{ target_user }}" mode: '0600' - become: true + become: "{{ become_for_target_user }}" notify: Restart mympd - name: Create mympd state directory @@ -43,34 +43,16 @@ owner: "{{ target_user }}" group: "{{ target_user }}" mode: '0700' - become: true - -# myMPD autoconfigures its MPD connection by probing ${XDG_RUNTIME_DIR}/mpd/socket -# (the standard user socket now created by the mpd.socket unit). Clear any stale -# host/port left by older installs that pinned the legacy /run/user/UID/mpd.socket -# path, so autoconf re-runs on next start. myMPD must be stopped first — it -# rewrites state/ on shutdown, so a plain restart would just re-pin the old -# value. Only act when a legacy pin is actually present. -- name: Check for stale mympd MPD host pin - ansible.builtin.stat: - path: "/home/{{ target_user }}/.config/mympd/state/mpd_host" - register: mpd_mympd_stale_host - become: true - -- name: Stop mympd before clearing stale MPD pin - ansible.builtin.include_tasks: "../../../tasks/systemd_stop_user.yml" - vars: - service_name: mympd.service - when: mpd_mympd_stale_host.stat.exists + become: "{{ become_for_target_user }}" -- name: Remove stale mympd MPD host/port - ansible.builtin.file: - path: "/home/{{ target_user }}/.config/mympd/state/{{ item }}" - state: absent - become: true - loop: - - mpd_host - - mpd_port +- name: Configure mympd MPD host (user socket) + ansible.builtin.copy: + content: "{{ mpd_socket_path }}" + dest: "/home/{{ target_user }}/.config/mympd/state/mpd_host" + owner: "{{ target_user }}" + group: "{{ target_user }}" + mode: '0600' + become: "{{ become_for_target_user }}" notify: Restart mympd - name: Configure mympd http port @@ -80,7 +62,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_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..d80c06f0 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,7 +119,7 @@ owner: "{{ target_user }}" group: "{{ target_user }}" mode: '0700' - become: true + become: "{{ become_for_target_user }}" - name: Backup mpd-discplayer config before changes ansible.builtin.include_tasks: "../../../tasks/backup_conf_before.yml" @@ -136,7 +136,7 @@ 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 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/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..165460e8 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" @@ -78,7 +78,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,7 +88,7 @@ owner: "{{ target_user }}" group: "{{ target_user }}" mode: '0640' - become: true + become: "{{ become_for_target_user }}" - name: Backup odio-api config after changes ansible.builtin.include_tasks: "../../../tasks/backup_conf_after.yml" 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 3c4f46e0..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,7 +60,7 @@ owner: "{{ target_user }}" group: "{{ target_user }}" mode: '0644' - become: true + become: "{{ become_for_target_user }}" notify: Restart pipewire-pulse when: install_snapclient 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..fd44c4ee 100644 --- a/installer/ansible/roles/snapclient/handlers/main.yml +++ b/installer/ansible/roles/snapclient/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/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 82eb63d1..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,7 +15,7 @@ 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 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..86474140 100644 --- a/installer/ansible/tasks/systemd_disable_user.yml +++ b/installer/ansible/tasks/systemd_disable_user.yml @@ -13,7 +13,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" @@ -24,7 +24,7 @@ scope: user environment: XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}" - become: true + become: "{{ become_for_target_user }}" become_user: "{{ target_user }}" register: _disable_user_result failed_when: diff --git a/installer/ansible/tasks/systemd_enable_user.yml b/installer/ansible/tasks/systemd_enable_user.yml index 932611f3..84837bab 100644 --- a/installer/ansible/tasks/systemd_enable_user.yml +++ b/installer/ansible/tasks/systemd_enable_user.yml @@ -17,7 +17,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_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 index 4e2c490a..c25e1619 100644 --- a/installer/ansible/tasks/systemd_stop_user.yml +++ b/installer/ansible/tasks/systemd_stop_user.yml @@ -12,7 +12,7 @@ scope: user environment: XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}" - become: true + become: "{{ become_for_target_user }}" become_user: "{{ target_user }}" register: _stop_user_result failed_when: diff --git a/installer/ansible/tasks/verify_services.yml b/installer/ansible/tasks/verify_services.yml index aed29c61..cbb4c29d 100644 --- a/installer/ansible/tasks/verify_services.yml +++ b/installer/ansible/tasks/verify_services.yml @@ -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 From 9768c7ad035befbc363b6a1a67d77fd43c2765ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 14 May 2026 15:26:25 +0200 Subject: [PATCH 08/14] snapclient: add mpris plugin --- .../ansible/roles/snapclient/handlers/main.yml | 11 +++++++++++ installer/ansible/roles/snapclient/tasks/main.yml | 3 +++ .../roles/snapclient/tasks/snapclientmpris.yml | 13 +++++++++++++ installer/ansible/roles/snapclient/vars/main.yml | 3 ++- installer/ansible/tasks/verify_services.yml | 2 +- 5 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 installer/ansible/roles/snapclient/tasks/snapclientmpris.yml diff --git a/installer/ansible/roles/snapclient/handlers/main.yml b/installer/ansible/roles/snapclient/handlers/main.yml index fd44c4ee..50df9341 100644 --- a/installer/ansible/roles/snapclient/handlers/main.yml +++ b/installer/ansible/roles/snapclient/handlers/main.yml @@ -9,3 +9,14 @@ 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/tasks/verify_services.yml b/installer/ansible/tasks/verify_services.yml index cbb4c29d..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" From b053678749183946ee085e21cfd3469a740c86b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 14 May 2026 15:21:43 +0200 Subject: [PATCH 09/14] odio-api: bump version to 0.13.0 --- installer/ansible/roles/odio_api/tasks/main.yml | 16 ---------------- .../roles/odio_api/templates/config.yaml.j2 | 2 +- installer/ansible/roles/odio_api/vars/main.yml | 4 ++-- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/installer/ansible/roles/odio_api/tasks/main.yml b/installer/ansible/roles/odio_api/tasks/main.yml index 165460e8..6cad4120 100644 --- a/installer/ansible/roles/odio_api/tasks/main.yml +++ b/installer/ansible/roles/odio_api/tasks/main.yml @@ -63,14 +63,6 @@ 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: Deploy odio-api configuration ansible.builtin.template: src: config.yaml.j2 @@ -90,14 +82,6 @@ mode: '0640' become: "{{ become_for_target_user }}" -- 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' - - name: Enable odio-api service ansible.builtin.include_tasks: "../../../tasks/systemd_enable_user.yml" vars: 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" From f8abec65a8f419f8f22bd62466d2265ad88bee3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Sun, 24 May 2026 01:30:12 +0200 Subject: [PATCH 10/14] odio-api: add snapweb url in config live/upgrade with ansible image with firstboot-network script and service snapboot --- .../files/odios-firstboot-network.service | 15 ++++++ .../files/odios-firstboot-network.sh | 52 +++++++++++++++++++ image-builder/lib/provision.sh | 7 +++ .../ansible/roles/odio_api/tasks/main.yml | 6 +++ .../roles/odio_api/tasks/snapweb_url.yml | 32 ++++++++++++ .../odio_api/templates/_systemd_block.yml.j2 | 4 ++ 6 files changed, 116 insertions(+) create mode 100644 image-builder/files/odios-firstboot-network.service create mode 100644 image-builder/files/odios-firstboot-network.sh create mode 100644 installer/ansible/roles/odio_api/tasks/snapweb_url.yml 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 593eb5e4..ef12562c 100644 --- a/image-builder/lib/provision.sh +++ b/image-builder/lib/provision.sh @@ -76,6 +76,13 @@ PROVISION 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/ansible/roles/odio_api/tasks/main.yml b/installer/ansible/roles/odio_api/tasks/main.yml index 6cad4120..c6360d8e 100644 --- a/installer/ansible/roles/odio_api/tasks/main.yml +++ b/installer/ansible/roles/odio_api/tasks/main.yml @@ -63,6 +63,12 @@ odio_api_power_can_poweroff: "{{ '\"yes\"' in odio_api_can_poweroff.stdout }}" when: install_mode == "live" +- 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: src: config.yaml.j2 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 From e619c4637db96bbd9da59966e08ed6fecd112acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Thu, 14 May 2026 15:27:35 +0200 Subject: [PATCH 11/14] mpd: add shortcuts on mympd home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redirect odio shortcut via /browse/pics page Resolve the host client-side: deploy odio.html into ~/.config/mympd/pics (served by myMPD at /browse/pics/) with inline JS that does location.replace("http://" + location.hostname + ":8018/ui"). The home_list icon points at the RELATIVE URL "browse/pics/odio.html" so window.open lands on the same origin myMPD was reached on — works on any client, mDNS-independent. Webradio, Artists, and Albums shortcuts Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ansible/roles/mpd/files/odio-logo.png | Bin 0 -> 41110 bytes installer/ansible/roles/mpd/files/odio.html | 4 + installer/ansible/roles/mpd/tasks/mympd.yml | 79 ++++++++++++++++++ .../ansible/roles/mpd/templates/home_list.j2 | 4 + .../ansible/tasks/systemd_disable_user.yml | 16 +--- installer/ansible/tasks/systemd_stop_user.yml | 26 ++++-- 6 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 installer/ansible/roles/mpd/files/odio-logo.png create mode 100644 installer/ansible/roles/mpd/files/odio.html create mode 100644 installer/ansible/roles/mpd/templates/home_list.j2 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 0000000000000000000000000000000000000000..eb2a2947aeb6e3b66de32cd346d42880a19b638c GIT binary patch literal 41110 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 + +odio + diff --git a/installer/ansible/roles/mpd/tasks/mympd.yml b/installer/ansible/roles/mpd/tasks/mympd.yml index 5d3311cc..9cc97f86 100644 --- a/installer/ansible/roles/mpd/tasks/mympd.yml +++ b/installer/ansible/roles/mpd/tasks/mympd.yml @@ -45,6 +45,75 @@ mode: '0700' 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: "{{ mpd_socket_path }}" @@ -55,6 +124,16 @@ become: "{{ become_for_target_user }}" notify: Restart mympd +- 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: "{{ become_for_target_user }}" + notify: Restart mympd + - name: Configure mympd http port ansible.builtin.copy: content: "{{ mpd_mympd_http_port }}" 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/tasks/systemd_disable_user.yml b/installer/ansible/tasks/systemd_disable_user.yml index 86474140..48e6751f 100644 --- a/installer/ansible/tasks/systemd_disable_user.yml +++ b/installer/ansible/tasks/systemd_disable_user.yml @@ -17,20 +17,8 @@ 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: "{{ become_for_target_user }}" - 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_stop_user.yml b/installer/ansible/tasks/systemd_stop_user.yml index c25e1619..7195910b 100644 --- a/installer/ansible/tasks/systemd_stop_user.yml +++ b/installer/ansible/tasks/systemd_stop_user.yml @@ -1,12 +1,22 @@ --- -# Stop a systemd user service without disabling it (the enablement symlink -# stays, so it comes back on next boot / via a Restart handler). -# Only acts in live mode; ignores "Could not find" (service may be absent). +# 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: + ansible.builtin.systemd_service: name: "{{ service_name }}" state: stopped scope: user @@ -14,8 +24,6 @@ XDG_RUNTIME_DIR: "/run/user/{{ target_user_uid }}" become: "{{ become_for_target_user }}" become_user: "{{ target_user }}" - register: _stop_user_result - failed_when: - - _stop_user_result.failed | bool - - '"Could not find" not in (_stop_user_result.msg | default(""))' - when: install_mode == "live" + when: + - install_mode == "live" + - _stop_user_check.status.ActiveState | default('') == 'active' From 77f1a535a52e9ba20ccec53e499fa66dfedfb2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Fri, 22 May 2026 02:04:25 +0200 Subject: [PATCH 12/14] mpd: add optionnal user conf --- installer/ansible/roles/mpd/tasks/main.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/installer/ansible/roles/mpd/tasks/main.yml b/installer/ansible/roles/mpd/tasks/main.yml index 9fac9357..b4142a82 100644 --- a/installer/ansible/roles/mpd/tasks/main.yml +++ b/installer/ansible/roles/mpd/tasks/main.yml @@ -133,6 +133,17 @@ 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 + - name: Backup MPD config after changes ansible.builtin.include_tasks: "../../../tasks/backup_conf_after.yml" vars: From 69a36c239cee7312475f1e1ca6acd40acdc93f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Wed, 20 May 2026 17:59:18 +0200 Subject: [PATCH 13/14] mpd: mpDris2 bump --- installer/ansible/roles/mpd/tasks/mpdris2.yml | 1 - installer/ansible/roles/mpd/vars/main.yml | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/installer/ansible/roles/mpd/tasks/mpdris2.yml b/installer/ansible/roles/mpd/tasks/mpdris2.yml index d7baccaa..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 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" From 632bf7f560c34d9f98fecd535626173d324fe606 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathieu=20R=C3=A9quillart?= Date: Fri, 22 May 2026 02:03:59 +0200 Subject: [PATCH 14/14] mpd-discplayer: remove useless config backup --- .../ansible/roles/mpd_discplayer/tasks/main.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/installer/ansible/roles/mpd_discplayer/tasks/main.yml b/installer/ansible/roles/mpd_discplayer/tasks/main.yml index d80c06f0..2784b982 100644 --- a/installer/ansible/roles/mpd_discplayer/tasks/main.yml +++ b/installer/ansible/roles/mpd_discplayer/tasks/main.yml @@ -121,14 +121,6 @@ mode: '0700' become: "{{ become_for_target_user }}" -- 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' - - name: Deploy mpd-discplayer configuration ansible.builtin.template: src: config.yaml.j2 @@ -139,14 +131,6 @@ 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: