feat(nas): auto-download Audible purchases with headless Libation #362

Merged
dominik.polakovics merged 1 commit from afk/360 into main 2026-08-16 10:26:08 +02:00

Runs Libation headlessly on nas so newly purchased Audible books land in the audiobookshelf library at /var/lib/multimedia/audiobooks/Libation/<author>/<series>/<title>.

Closes #360

What changed

  • hosts/nas/modules/libation.nix (new) — libation-sync oneshot (scan then liberate) on a 30-min timer, a libation system user in the jellyfin group, tmpfiles for the books and in-progress dirs, and a root-only libation-cli wrapper that does the user/env dance for one-off ops.
  • hosts/nas/modules/auto-shutdown.nix — check 5 blocks poweroff while a sync is in flight but, unlike checks 1–4, does not reset the idle streak. A 30-min timer resetting a 60-min streak would keep the box from ever powering off.
  • hosts/nas/configuration.nix — imports the module and persists /var/lib/libation (tokens + LibationContext.db; losing them means a full re-download and a fresh browser login).

Settings.json is regenerated from a Nix template on every run. That is not belt-and-braces: LibationCli reads settings ephemerally and never writes them back, so re-seeding is what makes the config declarative.

One-time bootstrap (after this deploys)

  1. Let the timer fire once, or systemctl start libation-sync. It creates /var/lib/libation + Settings.json and exits 0 with no Audible account yet.
  2. From an interactive SSH session — a TTY is required — run:
    sudo libation-cli login-external -a <audible-email> -l <locale>   # locale e.g. us, uk, de
    
    It prints an Audible URL; sign in from any browser and paste the resulting URL back. If you ever need to run it without a TTY, pass --response-url \"<url>\" instead, or it hard-fails with exit 3.
  3. systemctl start libation-sync && journalctl -u libation-sync -f.
  4. In Audiobookshelf, add /var/lib/multimedia/audiobooks/Libation as a library folder — nothing in this PR configures ABS.

Caveat worth knowing: login-external writes the account row before it can fail, so a half-finished login leaves a token-less account behind and scan will then report accounts, process 0 books, and exit 0. If nothing downloads, re-run the login rather than trusting the exit code.

Corrections to the issue's research

Verified against libation 13.3.6 as actually packaged in nixos-26.05 (built it, ran it, read the tagged upstream source):

  1. "Invalid templates silently fall back to defaults" is wrong. An unrecognised tag is kept verbatim and emitted as literal text in the path — no error, no warning, no fallback. Only a JSON null falls back. The tag spellings in the issue are correct and were confirmed to round-trip via get-setting; the risk of a typo is just worse than described, hence the comment pinning them.
  2. meta.mainProgram is libation, the GUI — lib.getExe pkgs.libation would silently wire up the Avalonia app. The module uses the explicit libationcli path.
  3. AutoScan is GUI-only. Nothing in LibationCli reads it. Dropped rather than shipped as dead config; the systemd timer is what drives scanning.
  4. A second pre-login failure the issue missed: with Settings.json absent, every verb exits 255, before the "No accounts" exit-3 path is ever reached. That is why the sync writes Settings.json first and checks for the account second — the reverse order would never get past the first deploy.
  5. liberate exits 0 regardless — per-book failures are logged and swallowed, and it exits 0 even with Books unset. Its exit code is useless for monitoring.

Deliberate deviations from the issue

1. InProgress sits on the media volume, not CacheDirectory=/var/cache/libation.

This host's / is an 8 GiB tmpfs, so /var/cache is RAM. Libation holds both the encrypted download and the decrypted output under InProgress (~2× book size), and filling the root filesystem wedges the whole box rather than just failing the download. /var/lib/multimedia/.libation-inprogress is on the same ext4 as the books dir, so the finished file is renamed into place instead of copied across filesystems — ABS never sees a partial file. Reverting is a one-line change to the let binding plus CacheDirectory = \"libation\".

Two consequences:

  • Upstream never sweeps DownloadsInProgress (partials are deliberately kept as resumable), so moving it off tmpfs turns a boot-cleared scratch area into an unbounded one. The tmpfiles rule carries a 30d age to bound it. DecryptInProgress is self-limiting — Libation clears it on every invocation.
  • In-flight downloads now hold an fd under /var/lib/multimedia, so auto-shutdown check 2 also covers them and does reset the clock — correct, an active download is real activity, exactly like pyload. Check 5 still covers the scan and move phases, where no such fd is held.

2. A Serilog block was added to Settings.json (not in the issue).

Without one, Libation builds a sink-less logger and discards its own error events entirely. Combined with liberate always exiting 0, a failing download would be completely invisible. The block routes to the bundled Console sink so journald — and therefore alloy → Loki — receives it, with standardErrorFromLevel splitting diagnostics onto stderr. MinimumLevel is Warning both to keep stderr meaningful and to suppress a duplication quirk (Libation wraps Console.Out in a Serilog writer, so at Information every plain line is emitted twice).

Worth knowing before editing that value: an invalid MinimumLevel is fatal — Serilog throws inside ConfigureLogging() and libationcli dies with exit 134 before doing any work. \"Info\" would brick the unit. The accepted set is in a comment at the line.

Verification

  • scripts/test-configuration nas via the pre-commit hook: OK.
  • The template strings and the Serilog block were confirmed to round-trip through libationcli get-setting on the real 13.3.6 binary; Serilog.Sinks.Console.dll confirmed present in the nixpkgs build.
  • Both generated shell scripts pass bash -n; generated Settings.json inspected as JSON (</> survive builtins.toJSON unescaped).

Not verified

  • There is no Audible account available here, so the download/liberate path itself is unexercised — the bootstrap above is its first real run. The layout is only as good as the template tags, which are verified, but the first sync is worth watching.
  • The auto-shutdown scenarios were reasoned through against the script, not run on hardware.
Runs Libation headlessly on `nas` so newly purchased Audible books land in the audiobookshelf library at `/var/lib/multimedia/audiobooks/Libation/<author>/<series>/<title>`. Closes #360 ## What changed - **`hosts/nas/modules/libation.nix`** (new) — `libation-sync` oneshot (`scan` then `liberate`) on a 30-min timer, a `libation` system user in the `jellyfin` group, tmpfiles for the books and in-progress dirs, and a root-only `libation-cli` wrapper that does the user/env dance for one-off ops. - **`hosts/nas/modules/auto-shutdown.nix`** — check 5 blocks poweroff while a sync is in flight but, unlike checks 1–4, does **not** reset the idle streak. A 30-min timer resetting a 60-min streak would keep the box from ever powering off. - **`hosts/nas/configuration.nix`** — imports the module and persists `/var/lib/libation` (tokens + `LibationContext.db`; losing them means a full re-download and a fresh browser login). `Settings.json` is regenerated from a Nix template on every run. That is not belt-and-braces: `LibationCli` reads settings *ephemerally* and never writes them back, so re-seeding is what makes the config declarative. ## One-time bootstrap (after this deploys) 1. Let the timer fire once, or `systemctl start libation-sync`. It creates `/var/lib/libation` + `Settings.json` and exits 0 with `no Audible account yet`. 2. From an **interactive** SSH session — a TTY is required — run: ``` sudo libation-cli login-external -a <audible-email> -l <locale> # locale e.g. us, uk, de ``` It prints an Audible URL; sign in from any browser and paste the resulting URL back. If you ever need to run it without a TTY, pass `--response-url \"<url>\"` instead, or it hard-fails with exit 3. 3. `systemctl start libation-sync && journalctl -u libation-sync -f`. 4. In Audiobookshelf, add `/var/lib/multimedia/audiobooks/Libation` as a library folder — nothing in this PR configures ABS. Caveat worth knowing: `login-external` writes the account row *before* it can fail, so a half-finished login leaves a token-less account behind and `scan` will then report accounts, process 0 books, and exit 0. If nothing downloads, re-run the login rather than trusting the exit code. ## Corrections to the issue's research Verified against libation 13.3.6 as actually packaged in nixos-26.05 (built it, ran it, read the tagged upstream source): 1. **\"Invalid templates silently fall back to defaults\" is wrong.** An unrecognised tag is kept verbatim and emitted as **literal text in the path** — no error, no warning, no fallback. Only a JSON `null` falls back. The tag spellings in the issue are correct and were confirmed to round-trip via `get-setting`; the risk of a typo is just worse than described, hence the comment pinning them. 2. **`meta.mainProgram` is `libation`, the GUI** — `lib.getExe pkgs.libation` would silently wire up the Avalonia app. The module uses the explicit `libationcli` path. 3. **`AutoScan` is GUI-only.** Nothing in `LibationCli` reads it. Dropped rather than shipped as dead config; the systemd timer is what drives scanning. 4. **A second pre-login failure the issue missed:** with `Settings.json` absent, *every* verb exits **255**, before the \"No accounts\" exit-3 path is ever reached. That is why the sync writes `Settings.json` first and checks for the account second — the reverse order would never get past the first deploy. 5. **`liberate` exits 0 regardless** — per-book failures are logged and swallowed, and it exits 0 even with `Books` unset. Its exit code is useless for monitoring. ## Deliberate deviations from the issue **1. `InProgress` sits on the media volume, not `CacheDirectory=/var/cache/libation`.** This host's `/` is an **8 GiB tmpfs**, so `/var/cache` is RAM. Libation holds both the encrypted download and the decrypted output under `InProgress` (~2× book size), and filling the root filesystem wedges the whole box rather than just failing the download. `/var/lib/multimedia/.libation-inprogress` is on the same ext4 as the books dir, so the finished file is *renamed* into place instead of copied across filesystems — ABS never sees a partial file. Reverting is a one-line change to the `let` binding plus `CacheDirectory = \"libation\"`. Two consequences: - Upstream **never** sweeps `DownloadsInProgress` (partials are deliberately kept as resumable), so moving it off tmpfs turns a boot-cleared scratch area into an unbounded one. The tmpfiles rule carries a `30d` age to bound it. `DecryptInProgress` is self-limiting — Libation clears it on every invocation. - In-flight downloads now hold an fd under `/var/lib/multimedia`, so auto-shutdown **check 2** also covers them and *does* reset the clock — correct, an active download is real activity, exactly like pyload. Check 5 still covers the scan and move phases, where no such fd is held. **2. A `Serilog` block was added to `Settings.json`** (not in the issue). Without one, Libation builds a sink-less logger and discards its own error events entirely. Combined with `liberate` always exiting 0, a failing download would be *completely* invisible. The block routes to the bundled Console sink so journald — and therefore alloy → Loki — receives it, with `standardErrorFromLevel` splitting diagnostics onto stderr. `MinimumLevel` is `Warning` both to keep stderr meaningful and to suppress a duplication quirk (Libation wraps `Console.Out` in a Serilog writer, so at `Information` every plain line is emitted twice). Worth knowing before editing that value: an **invalid** `MinimumLevel` is fatal — Serilog throws inside `ConfigureLogging()` and libationcli dies with exit 134 before doing any work. `\"Info\"` would brick the unit. The accepted set is in a comment at the line. ## Verification - `scripts/test-configuration nas` via the pre-commit hook: **OK**. - The template strings and the Serilog block were confirmed to round-trip through `libationcli get-setting` on the real 13.3.6 binary; `Serilog.Sinks.Console.dll` confirmed present in the nixpkgs build. - Both generated shell scripts pass `bash -n`; generated `Settings.json` inspected as JSON (`<`/`>` survive `builtins.toJSON` unescaped). ## Not verified - There is no Audible account available here, so the **download/liberate path itself is unexercised** — the bootstrap above is its first real run. The layout is only as good as the template tags, which are verified, but the first sync is worth watching. - The auto-shutdown scenarios were reasoned through against the script, not run on hardware.
Adds hosts/nas/modules/libation.nix: a libation-sync oneshot (scan then
liberate) on a 30-min timer, writing into
/var/lib/multimedia/audiobooks/Libation in an audiobookshelf-friendly
<author>/<series>/<title> layout.

Settings.json is regenerated from a Nix template on every run — LibationCli
reads settings ephemerally and never persists them, so this is what makes the
config declarative. The sync exits 0 before the one-time login-external
bootstrap so the timer does not park the unit in failed.

auto-shutdown.nix gains a fifth check that blocks poweroff while a sync is in
flight but, unlike checks 1-4, does not reset the idle streak: a 30-min timer
resetting a 60-min streak would keep the box from ever powering off.

InProgress is placed on the media volume rather than /var/cache, because this
host's / is an 8 GiB tmpfs; it also makes the finished book a rename rather
than a cross-filesystem copy.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos!362
No description provided.