#!/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": }; 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//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