A freshly deployed Audiobookshelf answers /healthcheck and is otherwise dead: no
account, no library, no scan. The UI registration step is gone by design — the
doors are POST /init (200 with plain text "OK", not JSON) and /status (isInit),
and there is no /setup route. That is an API-only bootstrap, so it must be code.
scripts/audiobookshelf-init.sh is idempotent: /status -> /init when unregistered,
POST /login for the bearer, POST /api/libraries for the Audiobooks library on
/audiobooks, then POST /api/libraries/{id}/scan. The deployer runs it only after
the health gate has answered.
Credentials live in ~/.config/secrets/audiobookshelf (mode 600): the bearer rides
an Authorization header and the password only a POST body — never a URL or argv,
where any ps on the host would print them. The seam drives an inline stub server
that logs every request line, and asserts: fresh bootstrap creates account,
library and scan; a second run creates nothing new and still fires the
incremental scan; no secret appears in any logged request line; a server that
refuses is reported as "refused: <url>" with exit 1 rather than success; ABS_SCAN
outside 0|1 fails before a single request; the deployer orders init after health.
Measured on Dankserver: run against the already-bootstrapped host it is a no-op
that re-fires the scan; the library then scans (1,500+ books, every item with
audio tracks, none invalid).
189 lines
8.3 KiB
Bash
Executable File
189 lines
8.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# audiobookshelf-init.sh — make an Audiobookshelf server usable, from nothing.
|
|
#
|
|
# Why this is a script and not a browser session (live 2026-11-02, Dankserver):
|
|
# a freshly deployed Audiobookshelf answers /healthcheck with 200 while it has no
|
|
# user, no library and no scan — "healthy" says nothing about usefulness, and a
|
|
# service that only works after someone remembers to click through a first-run
|
|
# wizard is not deployed, it is installed. Everything below is the API the React
|
|
# client itself calls, so the same procedure runs unattended and lands in git.
|
|
#
|
|
# The API facts this script depends on, read off the pinned image rather than
|
|
# guessed (dist-server/server/Server.js inside the digest this repo pins):
|
|
# * the creation route is POST /init with {"newRoot":{"username","password"}}.
|
|
# There is NO /setup route in 2.37.x — POST /setup answers "Unauthorized",
|
|
# which reads exactly like an auth fault and is instead a missing route.
|
|
# * /status (no /api prefix) reports {"isInit": <has a root user>}; every route
|
|
# under /api needs the bearer token that POST /login returns.
|
|
# * the library count is NOT a field on the library DTO; the door is
|
|
# GET /api/libraries/{id}/items -> {"total": N}.
|
|
#
|
|
# The credential is generated once into a mode-600 host file and never enters
|
|
# the repo, the URL, or a process argument list. Same pattern as calibre-server.
|
|
#
|
|
# usage: audiobookshelf-init.sh # create what is missing, then scan
|
|
# ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan
|
|
#
|
|
# environment (defaults are the deployed shape):
|
|
# ABS_URL server base URL (http://127.0.0.1:8000)
|
|
# ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf)
|
|
# ABS_LIBRARY library name to ensure (Audiobooks)
|
|
# ABS_FOLDER folder to bind inside the server (/audiobooks)
|
|
# ABS_SCAN 1 triggers a scan (default), 0 skips it
|
|
#
|
|
# exit 0 every step is satisfied (created or already present)
|
|
# exit 1 a step was refused or the server misbehaved (named on stderr)
|
|
# exit 2 usage / unreadable credential file
|
|
set -euo pipefail
|
|
|
|
ABS_URL=${ABS_URL:-http://127.0.0.1:8000}
|
|
ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf}
|
|
ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks}
|
|
ABS_FOLDER=${ABS_FOLDER:-/audiobooks}
|
|
ABS_SCAN=${ABS_SCAN:-1}
|
|
|
|
die() { printf 'refused: %s\n' "$*" >&2; exit 1; }
|
|
|
|
case "$ABS_SCAN" in
|
|
0 | 1) ;;
|
|
*) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;;
|
|
esac
|
|
|
|
# ---- credential -------------------------------------------------------------
|
|
umask 077
|
|
if [ ! -f "$ABS_SECRET" ]; then
|
|
mkdir -p "$(dirname "$ABS_SECRET")"
|
|
user=${ABS_ADMIN_USER:-frank}
|
|
# `tr … | head -c 24` is the obvious line and it is a trap under
|
|
# `set -o pipefail`: head exits at 24 bytes, tr takes SIGPIPE, the pipeline
|
|
# status is 141 and set -e kills the script before the credential is ever
|
|
# written (measured 2026-11-02). od reads a FIXED byte count and exits on its
|
|
# own, so no consumer closes a pipe early; bash slices the length.
|
|
pass=$(od -An -tx1 -N 32 /dev/urandom | tr -d ' \n')
|
|
pass=${pass:0:24}
|
|
[ "${#pass}" -eq 24 ] || die "could not generate a password (no /dev/urandom)"
|
|
printf 'ABS_ADMIN_USER=%s\nABS_ADMIN_PASSWORD=%s\n' "$user" "$pass" > "$ABS_SECRET"
|
|
printf 'credential: created %s\n' "$ABS_SECRET"
|
|
fi
|
|
[ -r "$ABS_SECRET" ] || die "$ABS_SECRET exists but is not readable by $(id -un)"
|
|
chmod 600 "$ABS_SECRET"
|
|
|
|
# ---- everything else, over HTTP ---------------------------------------------
|
|
# python3 rather than curl so the password and the bearer token never appear in
|
|
# any argv (they would be world-readable in /proc/<pid>/cmdline otherwise).
|
|
ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \
|
|
ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" python3 - <<'PY'
|
|
import json, os, sys, urllib.error, urllib.request
|
|
|
|
BASE = os.environ["ABS_URL"].rstrip("/")
|
|
LIB = os.environ["ABS_LIBRARY"]
|
|
FOLDER = os.environ["ABS_FOLDER"]
|
|
SCAN = os.environ["ABS_SCAN"] == "1"
|
|
cred = {}
|
|
for line in open(os.environ["ABS_SECRET"]):
|
|
if "=" in line:
|
|
k, _, v = line.strip().partition("=")
|
|
cred[k] = v
|
|
user = cred.get("ABS_ADMIN_USER") or "frank"
|
|
password = cred.get("ABS_ADMIN_PASSWORD") or ""
|
|
if not password:
|
|
sys.exit("refused: %s has no ABS_ADMIN_PASSWORD" % os.environ["ABS_SECRET"])
|
|
|
|
|
|
def call(method, path, body=None, token=None, want_json=True):
|
|
data = None
|
|
headers = {"X-Requested-With": "audiobookshelf-init"}
|
|
if body is not None:
|
|
data = json.dumps(body).encode()
|
|
headers["Content-Type"] = "application/json"
|
|
if token:
|
|
headers["Authorization"] = "Bearer " + token
|
|
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
raw = r.read()
|
|
if not raw or not want_json:
|
|
return r.status, raw
|
|
try:
|
|
return r.status, json.loads(raw)
|
|
except ValueError:
|
|
# Express answers sendStatus() with the STATUS TEXT as the body:
|
|
# POST /init and the scan route both reply "OK" (measured on the
|
|
# pinned 2.37.2 image). A successful call must never be judged by
|
|
# whether its body happens to parse as JSON.
|
|
return r.status, raw.decode("utf-8", "replace")[:200]
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read()
|
|
try:
|
|
payload = json.loads(raw)
|
|
except Exception:
|
|
payload = raw.decode("utf-8", "replace")[:200]
|
|
return e.code, payload
|
|
except urllib.error.URLError as e:
|
|
# Not an HTTP answer at all: nothing listening, DNS dead, connection
|
|
# refused. HTTPError is a URLError subclass, so this arm must come after.
|
|
return 0, "unreachable (%s)" % e.reason
|
|
|
|
|
|
status, st = call("GET", "/status")
|
|
if status != 200 or not isinstance(st, dict):
|
|
sys.exit("refused: %s/status answered %s (%s) — is the service up?" % (BASE, status, st))
|
|
if st.get("isInit"):
|
|
print("account: already registered")
|
|
else:
|
|
code, body = call("POST", "/init", {"newRoot": {"username": user, "password": password}})
|
|
if code != 200:
|
|
sys.exit("refused: POST /init answered %s (%s)" % (code, body))
|
|
print("account: created (%s)" % user)
|
|
|
|
code, login = call("POST", "/login", {"username": user, "password": password})
|
|
if code != 200 or not isinstance(login, dict) or not login.get("user", {}).get("token"):
|
|
sys.exit("refused: login answered %s (%s) — the credential file does not match the server"
|
|
% (code, login))
|
|
token = login["user"]["token"]
|
|
print("token: obtained (%s)" % login["user"].get("type", "user"))
|
|
|
|
code, libs = call("GET", "/api/libraries", token=token)
|
|
if code != 200 or not isinstance(libs, dict) or "libraries" not in libs:
|
|
sys.exit("refused: GET /api/libraries answered %s (%s)" % (code, libs))
|
|
lib = next((l for l in libs["libraries"] if l.get("name") == LIB), None)
|
|
if lib:
|
|
print("library: exists %s (%s)" % (LIB, lib["id"]))
|
|
else:
|
|
body = {
|
|
"name": LIB,
|
|
"provider": "audiobook",
|
|
"icon": "book",
|
|
"folders": [{"fullPath": FOLDER}],
|
|
"settings": {
|
|
"coverEncoding": "jpg",
|
|
"skipMatchingMediaWithAsin": False,
|
|
"skipMatchingMediaWithIsbn": False,
|
|
"autoDetectCrossoverAudiobooks": False,
|
|
"ignoreAudiobookMetadata": False,
|
|
"useAudiobookFileMetadata": False,
|
|
},
|
|
"providerMetadata": {},
|
|
}
|
|
code, made = call("POST", "/api/libraries", body, token=token)
|
|
if code != 200:
|
|
sys.exit("refused: POST /api/libraries answered %s (%s)" % (code, made))
|
|
lib = made
|
|
print("library: created %s (%s) folder %s" % (LIB, lib["id"], FOLDER))
|
|
|
|
if SCAN:
|
|
code, body = call("POST", "/api/libraries/%s/scan" % lib["id"], {}, token=token,
|
|
want_json=False)
|
|
if code != 200:
|
|
sys.exit("refused: scan answered %s (%s)" % (code, body))
|
|
print("scan: accepted")
|
|
else:
|
|
print("scan: skipped (ABS_SCAN=0)")
|
|
|
|
code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=token)
|
|
if code == 200 and isinstance(items, dict):
|
|
print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)"
|
|
% items.get("total"))
|
|
PY
|
|
exit 0
|