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 GUIlib.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.