Skip to content

eetu/scribe

Repository files navigation

scribe

Self-hosted Audible library mirror. Polls your Audible accounts, downloads purchased books, strips DRM (within your rights as the purchaser), converts to OSS M4B with chapters + cover, and writes them to a NAS share where audiobookshelf picks them up. Original AAXC files are stored separately as cold-storage archive (untouched downloads from Audible).

Sibling product to halo and chat. Same design tokens, fonts, accent. Two visual divergences from the family: the wordmark reads the path of the righteous scribe. (Pulp Fiction's Ezekiel 25:17 speech with "man" swapped for "scribe", collapsing to scribe. on narrow screens — same pattern as chat's royale with chat.chat.), and the glyph is a closed-book outline with a warm orange dot + audio ripples.

Why it exists

OpenAudible works but is not OSS, opinionated about file layout, and ships as a Java/SWT desktop app. scribe runs headless on the home Pi, hands the ffmpeg work to a small press worker, integrates with audiobookshelf for playback, and matches the home dashboard family visually.

The ffmpeg step is a lossless remux (-c copy — strip DRM, repackage to fragmented M4B), not a re-encode, so it's cheap: press can run on a separate LAN box for headroom or right on the Pi itself. Where it runs is an operational choice, not a hard requirement.

Architecture

┌───────────────────── scribe host (e.g. Pi) ──────────┐
│                                                      │
│  scribe (Rust, axum, :3003)                          │
│   ├── React UI (Vite + Emotion + TanStack Router)    │
│   ├── SQLite (accounts, books, jobs)                 │
│   ├── OIDC (kanidm) + DEV_AUTH fallback              │
│   ├── Job orchestrator + polling loop                │
│   └── NAS writer (two trees: library/ + original/)   │
│        │                                             │
│        ▼ loopback HTTP                               │
│  shim (Python, FastAPI, :3004)                       │
│   └── wraps mkb79/audible — auth, library, voucher   │
│                                                      │
│  shelf (Rust, axum, :3006) — optional                │
│   └── read-only ABS-compatible API over scribe.db    │
│       for Listen This / other ABS clients            │
│                                                      │
└──────────────────────────────────────────────────────┘
                       │ HTTPS + bearer
                       ▼
┌──────────── press worker (any LAN host, incl. the Pi) ┐
│  scribe-press (Rust, axum, :3005)                    │
│   └── ffmpeg remux (`-c copy`), holds tmp AAXC + M4B,│
│       streams both back to scribe on request         │
└──────────────────────────────────────────────────────┘
                       │
                       ▼
   NAS share (CIFS, mounted on the scribe host)
     ├── library/Author/Title/Title.m4b   ← audiobookshelf reads this
     └── original/Author/Title/Title.aaxc   ← cold original, ABS never sees

Layout

scribe/
├── Cargo.toml             workspace (backend, press, shared, shelf)
├── backend/               Pi-side Rust service (UI + DB + orchestration)
├── press/                 Rust ffmpeg worker (runs on any LAN host)
├── shelf/                 optional read-only ABS-compat API over scribe.db
├── shared/                shared types (JobSpec, BookMeta, etc.)
├── shim/                  Python sidecar wrapping mkb79/audible
├── frontend/              React + Vite + Emotion + TanStack Router
└── .claude/skills/scribe-design/  design system

Job pipeline

1. polling loop hits shim → library list
2. diff against SQLite → new books queued
3. for each queued job:
     GET shim /accounts/{id}/books/{asin}/voucher
       → content_url + key + iv + chapters + cover_url
     POST press /jobs { content_url, key, iv, … }
       → job_id (press starts download + ffmpeg)
     wait for press SSE progress, surface to UI
     on completion:
       GET press /jobs/{id}/aaxc  → stream to original/Author/Title/Title.aaxc
       GET press /jobs/{id}/m4b   → stream to library/Author/Title/Title.m4b
       DELETE press /jobs/{id}    → press cleans tmp
       POST {ABS_URL}/api/libraries/{id}/scan  → audiobookshelf reindexes

scribe never holds either full file in RAM. The press worker holds both briefly on local disk.

Quick start

# scribe core
cd backend && cargo run

# press worker (same host or a separate LAN box)
cd press && cargo run

# Shim
cd shim && uv sync && uv run shim

# Frontend
cd frontend && yarn install && yarn dev

# Shelf (optional — read-only ABS-compat sidecar for external clients)
cd shelf && cargo run

Deployment requirements (raspi)

scribe ships via the raspi IaC tasks scribe.py + scribe_shim.py. The container itself is straightforward; a few env vars + one firewall hole have surprised redeploys when they were missing.

Environment

group_data/all.pySCRIBE.env:

Var Required Notes
SCRIBE_BIND for remote-press reconvert Only when press runs on a separate host: must be 0.0.0.0:3003, not the loopback default, so press can fetch /internal/aaxc/{token} over the LAN. Co-located press (loopback) doesn't need this.
SCRIBE_PRESS_URL yes URL of the press worker — http://127.0.0.1:3005 if co-located, http://<press-host>:3005 if on another LAN box.
SCRIBE_INTERNAL_URL for remote-press reconvert LAN URL of this scribe instance from press's POV (http://<scribe-lan-ip>:3003). Used to mint one-shot /internal/aaxc/{token} URLs. Unset = reconvert disabled, downloads still work. Not needed when press is co-located.
SCRIBE_LIBRARY_DIR yes Canonical M4B output root. ABS scans this.
SCRIBE_ORIGINAL_DIR yes Encrypted AAXC + sidecar JSON tree. ABS does not scan this.
SCRIBE_AUTO_ENQUEUE optional 1 auto-queues new purchases on poll; 0 is manual-only.
OIDC_* for kanidm Discovery + client secret + redirect URL. DEV_AUTH=1 short-circuits for local. OIDC discovery is lazy + self-heals (kanidm can boot after scribe); /status reports oidc_configured + oidc_ready.
SESSION_KEY required if DEV_AUTH=0 64-byte hex (openssl rand -hex 64). Signs the session cookie; backend refuses to boot without it in prod (a predictable key would let anyone forge a session). Under DEV_AUTH=1 a random per-boot key is used and sessions drop on restart.

secret_env:

  • SCRIBE_PRESS_TOKEN — bearer shared with the scribe-press worker (api_key field).
  • ABS_TOKEN — audiobookshelf API token, lets scribe trigger a rescan after each job.

Firewall

Only relevant when press runs on a separate host. If press is co-located with scribe it talks over loopback and no hole is needed.

UFW on the scribe host defaults to deny incoming. Remote-press reconvert requires the press host to reach scribe directly on port 3003 (the /internal/aaxc/{token} route). Open it scoped to the press host IP:

sudo ufw allow from <press-host-ip> to any port 3003 proto tcp comment 'scribe reconvert from press'

The tasks/hardening.py IaC doesn't ship this rule yet — add it there when you fold the press host into the checklist.

NAS layout

SCRIBE_LIBRARY_DIR and SCRIBE_ORIGINAL_DIR should be separate trees:

  • …/audible/books/ — canonical Author/Series/#N - Title/Title.m4b, ABS root.
  • …/audible/originals/Author/Series/Title-ASIN.aaxc + Title-ASIN.aaxc.scribe.json.

The sidecar JSON is the source of truth that survives a DB wipe. It also carries the AAXC voucher key + iv (since the reconvert feature) so a future re-conversion can happen entirely from local files, even after Audible revokes the title's license.

Reconvert plumbing

When an m4b on the NAS goes missing (manual delete, ABS purge), the library page flips the chip to missing and surfaces a re-convert button:

  1. Backend mints a one-shot token, registers (token → aaxc_path).
  2. Submits a normal press job with content_url = ${SCRIBE_INTERNAL_URL}/internal/aaxc/<token>.
  3. Press fetches the AAXC like any other content_url, runs ffmpeg, stages M4B.
  4. Backend streams the M4B back into the canonical path, atomic-renames .partial → final, revokes the token.

The flow has no extra press-side dependency — press treats the scribe URL as just another content_url. When press is co-located with scribe this is loopback and needs no extra config; when press is on a separate host the only deployment requirements are SCRIBE_BIND, SCRIBE_INTERNAL_URL, and the UFW rule above.

Shelf — external ABS-compatible read API

scribe-shelf is a separate, optional service that exposes a slice of the Audiobookshelf REST API over scribe's database. iOS / desktop clients that already speak ABS (Listen This, ABS web/iOS, etc.) can browse and stream scribe's library directly through shelf without needing to run the real Audiobookshelf alongside.

It is built on the same principles as press and shim:

  • Read-only by construction. Opens scribe.db with SQLITE_OPEN_READ_ONLY. No writes, no schema migrations, no listening- progress state on the server side (CloudKit / clients own that).
  • Static bearer key auth. SHELF_API_KEY env value, compared in constant time. Rotate by changing env + restart, no DB row to maintain.
  • Optional. Unset SCRIBE_SHELF_URL on the backend side = shelf status is just not surfaced in the UI. The backend doesn't depend on shelf for any of its own work.

Endpoints implemented (the subset that Listen This consumes):

Path Notes
GET /ping unauthenticated liveness, what scribe pings for shelf_healthy
GET /api/me identity stub + library access permissions
GET /api/libraries single library entry derived from SHELF_LIBRARY_NAME
GET /api/libraries/{id}/items paginated, supports ?search=
GET /api/items/{id}?expanded=1 metadata + single synthesized track
GET /api/items/{id}/file/{ino} Range-aware m4b stream from SHELF_LIBRARY_DIR
GET /api/items/{id}/cover proxies the Audible CDN cover URL, 24h cache

item_id is <account_id>:<asin> so US + UK editions of the same book stay distinct. ino is a stable hash of the ASIN — opaque to clients.

scribe's settings page surfaces the URL + API key as copy-able fields when both SCRIBE_SHELF_URL and SCRIBE_SHELF_API_KEY are set on the backend. A logged-in user can paste them into Listen This (or any ABS-compat client) directly.

Local dev:

cd shelf && bacon       # listens on 127.0.0.1:3006 by default

then in Listen This: server URL http://<host>:3006, api key from shelf/.env.

Status

Reconvert end-to-end works. OA-style file importer (drop a local AAX into scribe and have it appear in the library) is the next big gap.

About

Self-hosted Audible library mirror — downloads owned books, remuxes to M4B with chapters for audiobookshelf. Sibling to halo and chat

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors