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)}"