Audplexus pushes each downloaded audiobook to one or more library destinations simultaneously. This doc covers the data model, runtime contract, and code path so a reviewer can grok the multi-destination feature without reading every commit.
Audible ┌──────────────────────────────────────────────┐
│ │ Audplexus │
│ aax/aaxc │ │
▼ │ Download ──▶ Decrypt ──▶ Organize │
┌──────┐ │ │ │ │ │
│ APIs │ │ ▼ ▼ ▼ │
└──────┘ │ audible.db /downloads/ /audiobooks/│
│ │ │
│ ▼ │
│ DestinationManager.FanOut
│ │ │ │ │
│ ┌──────┘ │ └──────┐ │
│ ▼ ▼ ▼ │
│ TriggerScan TriggerScan TriggerScan
│ ItemMatch ItemMatch ItemMatch
│ SeriesGroup SeriesGroup SeriesGroup
│ FranchiseTag(if Cap) │
└──────┬─────────────┬──────────┬──────────┬────────┘
▼ ▼ ▼ ▼
Plex API Emby API Jellyfin Audiobookshelf
(token) (api key) (MediaBrwsr)(Bearer)
Two tables, both under 005_library_destinations migration.
One row per configured destination. Multiple rows of the same type are allowed.
| Column | Notes |
|---|---|
id |
UUIDv4 generated by the application. Stable across edits. |
display_name |
Free-form, shown on cards (e.g. "Living Room Plex"). |
type |
plex | emby | jellyfin | abs |
enabled |
Soft toggle. Disabled rows are skipped by fan-out and reconcile. |
url, api_key, plex_token, plex_section_id, library_id |
Per-type config columns (some unused per type). CHECK constraint enforces the required subset. |
audiobook_path, destination_path |
Per-destination path translation source/target. Optional — falls back to global libraryDir + cached server-side path when unset. |
last_health_check_at / last_health_check_ok / last_health_check_err |
Populated by Test Connection button + every successful or failed scan/reconcile. |
api_key and plex_token are sensitive; they have json:"-" for marshal redaction AND a custom String() Stringer so fmt.Printf("%v", row) shows <redacted> not the raw token.
One row per (book, destination) pair. Carries the destination-side identity and a state machine.
| Column | Notes |
|---|---|
book_id, destination_id |
Composite PK. ON DELETE CASCADE both ways. |
server_item_id, server_item_title |
Destination's internal handle to the book (Plex ratingKey, Emby/Jellyfin ItemId, ABS UUID). |
sync_state |
pending | syncing | synced | failed | orphaned | removed_from_destination |
last_attempted_at, last_succeeded_at, last_error, attempt_count |
Independent of sync_state so an operator can see "tried recently but failed" cleanly. |
disabled_reason |
Set when admin chose to stop retrying despite failed. |
per_op_outcomes |
JSON {operation: {status, at, detail, duration_ms}}. Open-set per backend (the mediaserver.Outcome.Operation field). |
mediaserver.Backend.OnBookOrganized is the per-book post-organize entry point. MUST be synchronous, context-bound, idempotent, and return one Outcome per logical operation (scan trigger, item match, series grouping, etc.).
type Outcome struct {
Operation string // "scan_trigger", "item_match", "series_grouping", "franchise_tag", ...
Status OutcomeStatus // succeeded | skipped_existing | unsupported | failed | deferred | skipped_not_configured
Detail string // human-readable
Err error // populated only on Failed
ServerItemID string // populated when item-match succeeded
DurationMs int64
}Critical rule: backends never silently no-op. A backend that doesn't support an operation returns OutcomeUnsupported (with errors.Is(o.Err, ErrUnsupported)); a backend that's mid-config returns OutcomeSkippedNotConfigured. Capabilities are advertised via Backend.Capabilities() for UI affordance hiding (e.g. don't show "Franchise tagging" toggle on Plex destinations) but the runtime contract is the typed Outcome.
This was the central fix in PR-0: the old fire-and-forget pattern had TriggerScanForBook / EnsureBookInSeriesCollection spawning goroutines and discarding errors, which let the pipeline mark downloads Complete before the media-server work even finished — and silently when it failed.
DestinationManager.FanOut(ctx, OrganizedBook) iterates library_destinations WHERE enabled=1, builds a Backend per row (with WithDestination(row) binding so per-destination URL/api_key/library_id wins), and runs each OnBookOrganized concurrently:
- Bounded by
maxConcurrency(default 3, configurable). Prevents N parallel image uploads against slow home servers. - Per-destination
context.WithTimeout(ctx, 2*time.Minute). One stuck destination doesn't hold up the others. - Each destination's outcomes are recorded into
book_library_destinations.per_op_outcomesviarecordOutcomes. The recording uses the OUTER ctx (not the per-destination ctx) on purpose — we want to persist the failure outcome even if the per-destination timeout fired.
Failure isolation: one destination's error never affects the others. The pipeline blocks on wg.Wait() so the download isn't marked Complete until every destination's OnBookOrganized has returned (or its 2-min timeout fired).
DestinationManager.TriggerScanAll and ReconcileAll are the multi-dest analogs of the legacy single-backend TriggerLibraryScan and ReconcileLibrary. Same fan-out shape, same bounded concurrency, same per-destination timeouts (60s for scans, 10min for reconcile).
TriggerScanAll returns max of the per-destination item counts, NOT sum — in single-source-of-truth setups (3 destinations pointing at the same /audiobooks mount) summing would 3x the count and read as bogus on the dashboard. Per-destination truth lives on the dashboard cards.
Each backend declares its Capabilities():
| Capability | Plex | Emby | Jellyfin | ABS |
|---|---|---|---|---|
trigger_scan |
✔ | ✔ | ✔ | ✔ |
per_item_refresh |
✔ | ✔ | ✔ | ✔ |
series_grouping |
✔ | ✔ | ✔ | ✔ (implicit via tags) |
franchise_tag |
✔ | ✔ | ||
image_upload |
✔ | ✔ | ||
item_count |
✔ | ✔ | ✔ | ✔ |
author_images |
✔ | ✔ | ||
boxset_covers |
✔ | ✔ |
UI uses these to hide affordances that don't apply per-destination. The runtime contract still reports OutcomeUnsupported if a missing-capability operation is invoked — never a silent no-op.
- Auth: token via
X-Plex-Tokenquery param. - Section scan:
POST /library/sections/{id}/refresh. With?path=for targeted folder. - Section path comes from
/library/sections/{id}Locations array, cached in settings.
- Auth:
api_key=...query param ORX-Emby-Tokenheader. - Library refresh:
POST /Library/Refresh. Per-folder viaPOST /Items/{id}/Refresh?Recursive=true. - BoxSet collections:
POST /Collections+POST /Collections/{id}/Items. - Tag write:
POST /Items/{id}with fullBaseItemDtoandLockedFields: ["Tags"].
- Auth:
Authorization: MediaBrowser Token="...", Client="Audplexus", Device=.... LegacyX-Emby-Tokenworks on 10.x but dies in Jellyfin 12.0 (PR jellyfin#13306). New code uses the proper header from day one. - Item filter:
IncludeItemTypes=AudioBook. Jellyfin has first-classBaseItemKind.AudioBook; Emby returns books as bothAudioandMusicAlbum(the filter in this code differs accordingly). - Image upload: concrete
Content-Type: image/jpegrequired — Jellyfin'sImageControllerrejectsimage/*andimage/jpgwith 400. - LockedFields: wire-compatible
["Tags"]but the server side is an enum, so unknown strings silently no-op.
- Auth:
Authorization: Bearer <api_key>. Admin-scope key required for scan endpoints (post-v2.26.0 JWT rewrite, 2025-07). - Scan:
POST /api/libraries/{id}/scan. - Item match:
GET /api/libraries/{id}/search?q=<ASIN>. ABS has no native ASIN filter; we verifymedia.metadata.asinclient-side. - Series: ABS auto-detects from embedded
seriesandseries-partID3-style atoms (the Audiobook-rich tag profile writes these). Falls back toPATCH /api/items/{id}/mediawithmetadata.series=[{name,sequence}]if tags are absent. - Folder watcher (chokidar) is on by default in ABS; the scan trigger is belt-and-suspenders — idempotent so calling both is safe.
POST /destinations/test (new row) and POST /destinations/:id/test (saved row, secrets carried over) build a Backend with the form values, call LibraryItemCount with a 10s ctx timeout, and return an HTML fragment for HTMX swap. On success or failure the row's last_health_check_* columns are updated so the dashboard's "Healthy/Failed" badge reflects the most recent test.
- No retry queue. Failed operations on a destination get recorded; the next reconcile or download triggers them again. Caller decides retry policy.
- No transactional consistency across destinations. Each destination is independent; a partial-success state is captured in
book_library_destinationsand surfaced in Diagnostics. - No real-time WebSocket updates from destination servers. Health is poll-based (Test Connection on demand, scan/reconcile during sync).
- No load shedding under heavy queue. The bounded-concurrency budget prevents pile-up at the destination side; if Audplexus itself is overloaded that's the download pipeline's problem, not the destination layer's.
internal/mediaserver/
mediaserver.go Backend interface, Type constants, factory
outcome.go Outcome, OutcomeStatus, helper constructors,
Capability constants
plex.go PlexBackend (token + section scan + collections)
emby.go EmbyBackend (api key + BoxSet + tags + images)
jellyfin.go JellyfinBackend (MediaBrowser auth + AudioBook
filter + Content-Type strict)
abs.go ABSBackend (Bearer + ASIN match + native series)
match.go normalizeTitle (HTML-decode, smart-quote norm,
"&"/"and" equiv, leading-article strip)
franchise.go franchiseFromSeries ("Star Wars: Foundation
Trilogy" -> "Star Wars")
path.go translateScanPath (local -> server-visible)
internal/library/
destinations.go DestinationManager: ListEnabled, FanOut,
TriggerScanAll, ReconcileAll, BuildBackend
firstboot_destinations.go SynthesizeLibraryDestinationsIfEmpty,
hasPlexLegacyConfig, hasEmbyLegacyConfig
(handles upgrade from v0.2.x without explicit
MEDIA_SERVER set)
pipeline_stages.go handleProcessStage calls FanOut BEFORE
marking download Complete
internal/database/
migrations/005_library_destinations.up.sql schema (sqlite)
migrations_postgres/005_library_destinations.up.sql schema (postgres)
library_destinations_sqlite.go CRUD (sqlite)
library_destinations_postgres.go CRUD (postgres)
models.go LibraryDestination, BookDestination structs
with secret-redacting Stringer
internal/web/
destinations_handlers.go Settings UI handlers + Test Connection
server.go Routes + DestinationSummaries view-model
+ recordDestinationHealth helper
templates/destinations_*.html Type picker, type-specific edit form,
delete confirm
templates/dashboard_summary.html Per-destination cards (replaces the
single-active-backend stat-cards)
- WCAG-aware UI patterns documented inline in templates (cards as
<ul role="list">,aria-live="polite"on the live dashboard list,aria-busyon the Test button,<details>collapsible help text per field). - Codex pre-PR review captured 3 real bugs — default-Plex upgrade synthesis (P1),
buildDecryptArgsmuxer flag (P2), per-destination scan paths (P2). All fixed; regression tests infirstboot_destinations_test.goandtag_test.go.