Ansible-based "curl | bash" installer to set up a complete audio/multimedia system on Debian/Ubuntu.
- PulseAudio - Audio server with network streaming (TCP + Zeroconf, wired only)
- Bluetooth Audio - Authentication agent and automatic connection
- MPD - Music Player Daemon with USB, CD/DVD and network support
- Odio API - REST control interface
- Shairport Sync - AirPlay receiver
- Spotifyd - Spotify Connect receiver
- Snapcast - Multi-room audio client
- myMPD - Web UI for MPD (default port 8080, override with
MPD_MYMPD_HTTP_PORT) — also exposes web radio playback - UPnP/DLNA - Renderer for UPnP application control, with optional Qobuz, Tidal, and web-radio plugins (packages only — Qobuz/Tidal credentials configured manually post-install in
~/.config/upmpdcli/upmpdcli.conf). - MPD DiscPlayer - CD/USB support for MPD
- Branding - odio login banner (
odio-motd,.hushlogin,.profilehook)
All components are enabled by default. Pass INSTALL_<NAME>=N to skip any of them — see the env-vars table below.
The installer is fully idempotent — it can be run on a fresh system or an existing one, and re-run safely at any time to update or repair the installation.
If you are installing on a system that already has a configured user, it is strongly recommended to target a dedicated user for odios. The playbook creates it automatically if it doesn't exist — the installer will confirm this at startup:
Target user [pi]: odio
✓ User 'odio' will be created.
If you install for an existing user, the installer warns you upfront:
Target user [pi]:
⚠ Installing for current user 'pi' — existing config files will be backed up before modification.
When the installer modifies a configuration file that already exists, it automatically creates a backup before applying changes:
- If the file is modified: a backup is saved as
<config>.bak(e.g./etc/shairport-sync.conf.bak) - If the file ends up identical: no backup is kept
This applies to: /etc/bluetooth/main.conf, /etc/shairport-sync.conf, /etc/default/snapclient, ~/.config/upmpdcli/upmpdcli.conf, and ~/.config/mpd/mpd.conf (modified by both the mpd and mpd_discplayer roles).
- OS: Debian 13, Ubuntu 22.04+, or Raspberry Pi OS (Trixie)
- Architecture: ARM (armv6l, armv7l, aarch64) or x86_64
- Python 3.10+
python3-cryptography(present by default on Debian/Ubuntu)python3-jinjacurl- Sudo access (or root)
- Internet connection
- 50 MB free disk space in
/tmp
curl -fsSL https://github.com/b0bbywan/odios/releases/latest/download/install.sh | bashThe installer interactively prompts for the target user and optional components.
TARGET_USER=pi curl -fsSL https://github.com/b0bbywan/odios/releases/latest/download/install.sh | sudo bashAll configuration variables can be passed as environment variables — if set, prompts are skipped. Defaults are Y for every component, so opting out is the common case:
TARGET_USER=pi \
INSTALL_BLUETOOTH=N \
INSTALL_SPOTIFYD=N \
INSTALL_BRANDING=N \
curl -fsSL https://github.com/b0bbywan/odios/releases/latest/download/install.sh | bash| Variable | Default | Description |
|---|---|---|
TARGET_USER |
$USER |
System user for the services |
TARGET_HOSTNAME |
(unchanged) | Hostname (optional) |
MPD_MUSIC_DIRECTORY |
/media/USB |
MPD music library path |
MPD_MYMPD_HTTP_PORT |
8080 |
myMPD HTTP listen port |
MPD_CONF_PATH |
(detected) | Path to external mpd.conf (when INSTALL_MPD=n + INSTALL_MPD_DISCPLAYER=y) ⚠ experimental |
INSTALL_PULSEAUDIO |
Y |
PulseAudio + network streaming (wired only) |
INSTALL_BLUETOOTH |
Y |
Bluetooth A2DP sink |
INSTALL_MPD |
Y |
Music Player Daemon |
INSTALL_ODIO_API |
Y |
REST control API |
INSTALL_SHAIRPORT_SYNC |
Y |
AirPlay receiver |
INSTALL_SNAPCLIENT |
Y |
Snapcast client |
INSTALL_UPMPDCLI |
Y |
UPnP/DLNA renderer |
INSTALL_MYMPD |
Y |
myMPD web UI (skipped if INSTALL_MPD=N) |
INSTALL_MPD_DISCPLAYER |
Y |
CD/DVD support |
INSTALL_SPOTIFYD |
Y |
Spotify Connect |
INSTALL_QOBUZ |
Y |
upmpdcli Qobuz plugin (credentials: manual, see upmpdcli.conf) |
INSTALL_TIDAL |
Y |
upmpdcli Tidal plugin (credentials: manual, see upmpdcli.conf) |
INSTALL_UPNPWEBRADIOS |
Y |
upmpdcli web radio plugins (Radio Browser, Radio Paradise, …) |
INSTALL_BRANDING |
Y |
odio login banner (odio-motd, .hushlogin) |
ODIOS_VERSION |
latest |
Version to install (pr-2, 2026.3.0, …) |
Releases follow the YYYY.M.patch format (e.g. 2026.3.0). Pre-releases use suffixes: 2026.3.0a1 (alpha), 2026.3.0b1 (beta), 2026.3.0rc1 (release candidate).
# Stable version
ODIOS_VERSION=2026.3.0 curl -fsSL https://github.com/b0bbywan/odios/releases/download/2026.3.0/install.sh | bash
# Beta
ODIOS_VERSION=2026.3.0b1 curl -fsSL https://github.com/b0bbywan/odios/releases/download/2026.3.0b1/install.sh | bash
# PR pre-release
ODIOS_VERSION=pr-5 curl -fsSL https://github.com/b0bbywan/odios/releases/download/pr-5/install.sh | bashEach install ships /usr/local/bin/odio-upgrade with the following subcommands:
odio-upgrade check— compares the local state against the published manifest and refreshes/var/cache/odio/upgrades.json. Wired to a systemd user timer (daily, random delay) so the login banner / PWA can surface the result.odio-upgrade apply— re-invokesinstall.shfor the target version with theINSTALL_*flags derived from the saved state. No argument = upgrade to whateverupgrades.jsonreports as latest.odio-upgrade verify— readsstate.jsonand runs schema sanity checks (used in CI / for inspecting an install; exits 0 valid, 1 invalid, 2 missing).odio-upgrade pwa-url— printshttps://pwa.odio.love/#/i/<ip>using the source IP of the default route (falls back tohttps://pwa.odio.loveif no IP is detectable; handy for the SSH login banner).
odio-upgrade # alias of `apply` — upgrade to the latest published version
odio-upgrade apply --version 2026.5.0 # target a specific release
odio-upgrade apply --dry-run --force # print what would be invoked, do nothing
odio-upgrade apply --reinstall # re-run every role in full (repair a broken install)
odio-upgrade apply --progress # stream JSON progress events to odio-apiapply fetches the target release's manifest.json and skips roles whose installed version already matches — only the roles that actually bumped re-run. On top of that, each role that does run skips its first-install scaffolding (config-directory creation, service enablement, version-gated migrations) since the prior state already records it. The amount of time saved scales with how few roles changed in the target release.
--reinstall bypasses both skip layers: every selected role runs, and read_state.yml blanks the prior-state facts so each role re-applies its full first-install scaffold. Use it to repair an install whose config or services were removed out of band. It implies --force, so it also runs when no upgrade is reported.
--progress enables the odio_progress callback, which emits one event per step (a begin listing the planned roles, a progress event as each role starts, and an end with the changed count) two ways: an ODIO_PROGRESS=<json> line to stdout (captured by journald) and the same JSON, unprefixed, over the unix socket odio-api listens on ($XDG_RUNTIME_DIR/odio-api/upgrade.sock). The socket is the live channel odio-api relays to drive a progress bar; the normal Ansible output is left intact, and with no listener (a run outside odio-api) only stdout is written. Progress is auto-enabled when that socket exists (a real instance, even for a hand-run odio-upgrade apply), so the systemd/sudoers paths keep --progress explicit only because under sudo the socket isn't resolvable; pass --no-progress to suppress it. In CI there's no odio-api, so it stays off.
apply refuses to target a release older than state.odios. If you really need to roll back, reflash from the SD image — the live install path doesn't carry the assets to step backwards safely.
odio_upgrade.py is also published as a standalone asset on every release, so installs that predate it (≤ rc2, no helper in /usr/local/bin) can run it directly:
curl -fsSL https://github.com/b0bbywan/odios/releases/latest/download/odio_upgrade.py -o /tmp/odio-upgrade
chmod +x /tmp/odio-upgrade
/tmp/odio-upgrade # reconstructs state from disk, then upgrades to latestThe subsequent upgrade installs the helper, so this bootstrap is needed only once.
Upgrades honor the previous feature selection by reading ~/.cache/odio/state.json:
| Field | Meaning |
|---|---|
roles |
Role → version of every role that was installed |
roles_excluded |
Roles the user opted out of (kept off on upgrade) |
features |
Opt-in sub-flags (e.g. tidal, qobuz, upnpwebradios) |
features_excluded |
Sub-flags the user opted out of (kept off on upgrade) |
release_history |
Ordered list of every odios version installed (dedup-consecutive) |
Only entries in roles_excluded / features_excluded map to INSTALL_*=N. Everything else — whether listed in roles/features or absent from both (new release, schema gap, malformed state.json) — maps to INSTALL_*=Y. Upgrades are pure opt-out: install.sh's built-in defaults don't apply, only the explicit exclusions do.
odio-upgrade transparently backfills the newer fields for installs that predate them (rc1/rc2 state, or pre-rc3 installs with no state at all), using filesystem / dpkg introspection. Run with --dry-run to inspect.
Ordered chronological list of every odios version that ran write_state.yml,
deduplicated against the immediately-previous entry. The current version is
always at release_history[-1] (and equals state.odios). The playbook
checks /var/lib/odio/state.json (canonical), then
/var/cache/odio/state.json (2026.4.2b1+), and the legacy
~<target_user>/.cache/odio/state.json before deciding what to do:
- Fresh install (no state.json on either path) — seeded with
[<current>]. - Upgrade from a state.json predating
release_history— backfilled from the existingodiosfield, then the current version is appended:[<previous>, <current>]. - Upgrade from a pre-state.json install (very early rc's, no state.json
anywhere) — no source to recover the prior version from, so the history
starts at the upgrade target:
[<current>]. The legacy version is irretrievably lost on this one upgrade — subsequent upgrades grow the history normally.
Re-running the same install (e.g. odio-upgrade --force against the version
already installed) does not duplicate the entry: the dedup-consecutive rule
keeps the history a record of version transitions, not invocations.
To keep a role or sub-flag off on the next upgrade, open ~/.cache/odio/state.json in your editor and add its name to the matching _excluded list. Example — skipping the branding role and upnpwebradios feature:
{
...
"roles_excluded": [
-
+ "branding"
],
"features_excluded": [
-
+ "upnpwebradios"
]
}Then:
odio-upgrade --dry-run --force # verify the derived INSTALL_* flags
odio-upgrade # applyRemoving an entry from the list opts back in — the next upgrade sees it as unlisted and re-installs it.
install.shis downloaded and executed (curl | bash)- It checks prerequisites (OS, arch, Python 3.10+, cryptography, curl, sudo, disk space, systemd)
- It downloads the release archive from GitHub into
/tmp - It runs vendored ansible-core from the archive (no Ansible installation required)
- The playbook configures the system and starts the services
- Temporary files are cleaned up
The archive (odio-{version}.tar.gz) contains:
ansible/ — playbooks and roles
vendor/ — vendored ansible-core (pure Python, no native extensions)
licenses/ — licenses (GPLv3 ansible-core, BSD-2 odios)
VERSION
python3-cryptography is the only native dependency not included — it is provided by the system.
installer/
├── install.sh # curl|bash entry point (published as-is)
└── ansible/
├── playbook.yml # Main playbook
├── inventory/localhost.yml
├── group_vars/all.yml # Default variables
├── tasks/
│ ├── backup_conf_before.yml # Shared: snapshot config before changes
│ ├── backup_conf_after.yml # Shared: promote/discard backup after changes
│ ├── systemd_enable_user.yml # Shared: enable a user service (live + image_build)
│ ├── systemd_disable_system.yml # Shared: disable + stop a system service
│ └── systemd_enable_system.yml # Shared: enable + start a system service
└── roles/
├── common/ # System prerequisites + linger
├── upgrade/ # /usr/local/bin/odio-upgrade + systemd user timer (smart upgrade)
├── branding/ # odio-motd login banner (optional)
├── pulseaudio/ # PulseAudio + network streaming (wired only, PipeWire conflict handling)
├── pipewire/ # PipeWire + pipewire-pulse (experimental, not yet exposed)
├── bluetooth/ # Bluetooth audio (A2DP)
├── mpd/ # Music Player Daemon (incl. myMPD web UI sub-feature)
├── odio_api/ # REST control API
├── shairport_sync/ # AirPlay (optional)
├── snapclient/ # Snapcast (optional)
├── upmpdcli/ # UPnP/DLNA (optional)
├── mpd_discplayer/ # CD/DVD player (optional)
│ └── tasks/
│ └── validate_external_mpd.yml # Fail-fast validation for external MPD
└── spotifyd/ # Spotify Connect (optional)
By default, mpd_discplayer is designed to work alongside MPD managed by odios. It is also possible to install it against a pre-existing MPD installation (INSTALL_MPD=n + INSTALL_MPD_DISCPLAYER=y), but this mode is experimental.
In this case the installer will:
- Auto-detect your
mpd.conf(~/.config/mpd/mpd.confthen/etc/mpd.conf), or useMPD_CONF_PATHif provided - Extract
music_directoryfrom it to configurempd-discplayer - Append the required blocks (
cdio_paranoia,playlist_plugin,neighbors) to your existing config — your original file is backed up asmpd.conf.bakbeforehand
Requirements for external MPD:
- Must use the
database { plugin "simple" }block format — legacydb_filedirective is not supported and will cause a fail-fast error with a migration guide
This mode has not been extensively tested across MPD configurations. Feedback welcome.
Fast, no container needed. CI runs the same commands in .github/workflows/checks.yml.
python3 -m unittest discover tests # unit tests for odio-upgrade
ruff check # lint (config: pyproject.toml)
mypy # type-check (config: pyproject.toml)./tests/test.sh # pull image from GHCR + run playbook (ansible installed via pip)
./tests/test.sh rerun # re-run playbook without restarting the container
./tests/test.sh shell # shell into the container
./tests/test.sh clean # remove the container
./tests/test.sh --build # force local image build instead of pulling from GHCRTests install.sh from a GitHub release inside a systemd container:
# As user odio (sudo) — standard user case
./tests/test.sh install pr-5
# 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 latestThe --build flag works with all actions:
./tests/test.sh --build install pr-5systemctl --user status pulseaudio pulse-tcp mpd
pactl list modules | grep -E "tcp|zeroconf"
mpc statusInstall one version in a fresh container, then upgrade it to another. First arg = the version to start from (acts as the existing odios install), second arg = the version to upgrade to:
# install 2026.4.2b1, then run odio-upgrade to bring it to pr-X
./tests/test.sh upgrade 2026.4.2b1 pr-XThe test-upgrade job in release.yml validates that an existing odios install can be upgraded to the PR's version via curl | bash. To match the real-world semantic (an odios system is already running on a Pi when the user upgrades), the test uses pre-provisioned baseline Docker images rather than a fresh install of the baseline.
The matrix exercises four paths against several baseline tags:
upgrade-from-image-fetch— curlsodio_upgrade.pyfrom the PR release first, then runs it (smart-upgrade exercised against the baseline's old runtime).upgrade-from-image-embedded— runs the baseline's own/usr/local/bin/odio-upgrade(validates the in-place helper).upgrade-from-image-systemctl—systemctl --user start odio-upgrade.service(real-release path, target driven byodio.love/manifest.json).upgrade-from-image-fetch-as-other-user— same asfetch, but invoked by a non-target_usersudoer (member ofusers+odio). Validates that the group permissions on/var/lib/odio/state.jsonactually let a second admin trigger the upgrade.
Baseline tags + runners are listed inline in release.yml's matrix (no repo variable). Each entry consumes ghcr.io/b0bbywan/odios/test-baseline:<TAG>-<arch>.
arm64 baselines (built from the published SD image):
# Requires: docker, sudo, xz-utils, util-linux, jq
docker login ghcr.io -u <your-github-user> # needs a PAT with write:packages
./scripts/img-to-docker.sh 2026.4.2b1 arm64The script downloads the SD image directly from the matching release, mounts its rootfs partition, tars it, and docker imports it with a systemd entrypoint matching Dockerfile.test — tagged as ghcr.io/<repo>/test-baseline:<TAG>-<ARCH>. Approach inspired by vascoguita/raspios-docker.
amd64 baselines (no SD image source — layered onto Dockerfile.test):
docker login ghcr.io -u <your-github-user>
./scripts/build-baseline-amd64.sh 2026.4.2b1
# variant without mpd / mpd-discplayer / upmpdcli — pushed as
# test-baseline:<TAG>-amd64-no-mpd-stack (used by the matrix to exercise
# smart-upgrade against a state.roles that legitimately omits those):
./scripts/build-baseline-amd64.sh --no-mpd-stack 2026.4.2b1Runs install.sh in image mode inside a clean test container, then docker commit + push as test-baseline:<TAG>-amd64[-<variant>]. Used by the native amd64 entries in the matrix (the only path that needs a real systemd-logind quickly enough — qemu-arm64 emulation is too slow for that handshake).
Both arches are also available in Actions → "Build test-baseline image" → Run workflow (pick arch and, for amd64, variant).
sudo apt install python3-cryptographyjournalctl --user -u pulseaudio -u mpd -f
systemctl --user restart pulseaudio pulse-tcp mpdloginctl show-user $USER | grep Linger
sudo loginctl enable-linger $USERWhen MPD is managed by odios (INSTALL_MPD=y), the music directory defaults to /media/USB. Override it with:
MPD_MUSIC_DIRECTORY=/mnt/music curl -fsSL ... | bashWhen using an external MPD (INSTALL_MPD=n + INSTALL_MPD_DISCPLAYER=y), the path is auto-detected from your existing mpd.conf — no override needed.
To verify the directory exists with correct permissions:
ls -la /media/USB
# Should be: drwxrwxr-x user audio