--- # Audiobookshelf as a rootless Podman Quadlet unit. # # Everything here runs as {{ podman_user }} — the connection user. No become, no # XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's # user manager is reachable through the standard environment. - name: Ensure the Audiobookshelf data directories exist ansible.builtin.file: path: "{{ item }}" state: directory owner: "{{ podman_user }}" group: "{{ podman_user }}" mode: '0755' loop: # config/ and metadata/ are the irreplaceable 22 MB — absdatabase.sqlite, # migrations, backups, cache, logs. audiobooks/ and podcasts/ are the # library, which the app reads and podcast downloads are written into. - "{{ config_dir }}" - "{{ metadata_dir }}" - "{{ audiobooks_dir }}" - "{{ podcasts_dir }}" - name: Install the Audiobookshelf Quadlet unit ansible.builtin.template: src: audiobookshelf.container.j2 dest: "{{ quadlet_dir }}/{{ container_name }}.container" owner: "{{ podman_user }}" group: "{{ podman_user }}" mode: '0644' notify: - Reload systemd user units - Restart Audiobookshelf - name: Start Audiobookshelf block: - name: Start Audiobookshelf ansible.builtin.systemd_service: name: "{{ unit_name }}" scope: user state: started daemon_reload: true rescue: # systemd reports "control process exited with error code" and nothing # else, which is useless for the usual failures (an unattached network, an # unwritable /config, a bad volume). Pull the real reason out. - name: Collect Audiobookshelf diagnostics ansible.builtin.command: "{{ item }}" loop: - "systemctl --user status {{ unit_name }} --no-pager" - "journalctl --user -u {{ unit_name }} --no-pager -n 40" - "podman logs --tail 40 {{ container_name }}" register: audiobookshelf_diag changed_when: false failed_when: false - name: Show Audiobookshelf diagnostics ansible.builtin.debug: msg: "{{ audiobookshelf_diag.results | map(attribute='stdout') | list }}" - name: Fail ansible.builtin.fail: msg: "Audiobookshelf did not start — see the diagnostics above." # Quadlet honours [Install] WantedBy= when it generates the unit, so # `systemctl --user enable` is neither needed nor possible on a generated unit. - name: Verify Audiobookshelf is running ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" register: audiobookshelf_active changed_when: false failed_when: audiobookshelf_active.stdout != 'active' # `is-active` says the container is running; it does not say the app can serve. # `Restart=always` means even a crash-looping container is reported active. The # image ships no HEALTHCHECK, so the unit defines one (HealthCmd=, see the # template) and this runs exactly that command once, synchronously. - name: Wait for Audiobookshelf to pass its health check ansible.builtin.command: "podman healthcheck run {{ container_name }}" register: audiobookshelf_health until: audiobookshelf_health.rc == 0 retries: 24 delay: 5 changed_when: false - name: Confirm the container sees all four mount targets ansible.builtin.command: "podman exec {{ container_name }} ls -d /config /metadata /audiobooks /podcasts" register: audiobookshelf_mounts changed_when: false failed_when: audiobookshelf_mounts.rc != 0 # The health check proves the server started — it exits 1 with EROFS if /config # is not writable — but not that /config is the *bind mount*. This does: the # database the app has just created has to appear on the host, not inside the # container's writable layer. - name: Check the database landed on the host ansible.builtin.stat: path: "{{ config_dir }}/absdatabase.sqlite" register: audiobookshelf_db - name: Report where the database landed ansible.builtin.debug: msg: >- {{ config_dir }}/absdatabase.sqlite is {{ audiobookshelf_db.stat.pw_name | default('?') }}:{{ audiobookshelf_db.stat.gr_name | default('?') }}, {{ audiobookshelf_db.stat.size | default(0) }} bytes — created by the container, visible on the host, so the bind mount is real. when: audiobookshelf_db.stat.exists # This is the check that catches a broken or missing Volume= line, which is the # one failure here that is silent: the app would run perfectly well with its # data in the container's writable layer, and lose all of it on the next # recreate. - name: Fail if the database is not on the host ansible.builtin.assert: that: audiobookshelf_db.stat.exists fail_msg: >- {{ config_dir }}/absdatabase.sqlite does not exist, so /config is not the bind mount it should be. The app's state is inside the container's writable layer and will be lost when the container is recreated — check the Volume= lines in the generated unit.