Multi-destination library support: Jellyfin + Audiobookshelf + concurrent fan-out#6
Conversation
Replaces fire-and-forget mutating ops on Backend (TriggerScanForBook,
EnsureBookInSeriesCollection, TagItem) with a single synchronous
OnBookOrganized that returns one typed Outcome per logical operation.
This addresses three real defects in the previous abstraction:
1. Mutating ops spawned goroutines and discarded errors. The pipeline
never knew whether the post-organize work succeeded.
2. handleProcessStage marked DownloadStatusComplete BEFORE the
fire-and-forget media-server work even started, so "complete"
was a lie when the scan or collection-add silently failed.
3. Plex's TagItem was a silent no-op — the abstraction lied about
having done the operation.
The new contract:
type Backend interface {
...
OnBookOrganized(ctx, OrganizedBook) []Outcome
}
type Outcome struct {
Operation, Status, Detail, ServerItemID string
Err error // populated only on Failed
DurationMs int64
}
OutcomeStatus is one of: succeeded, skipped_existing, unsupported,
failed, deferred, skipped_not_configured. Failed is the only
non-terminal status — caller decides whether to retry. Idempotency is
required: repeat calls for the same OrganizedBook are safe and report
SkippedExisting where applicable.
Pipeline (internal/library/pipeline_stages.go) now calls
OnBookOrganized synchronously and BEFORE marking the download
complete. Logs structured per-op outcomes for observability. The
previous bug where downloads were marked complete with media-server
failures hidden in goroutines is gone.
PlexBackend.OnBookOrganized:
- scan_trigger (always when configured)
- item_match + series_grouping (only when book.Series populated)
EmbyBackend.OnBookOrganized:
- scan_trigger (always when configured) — targeted folder refresh,
falls back to full library refresh
- item_match + series_grouping + franchise_tag (only when
book.Series populated)
TagItem (Emby's old fire-and-forget public method) and TriggerScan/
EnsureBookInSeriesCollection (Plex + Emby's old fire-and-forget public
methods) are removed entirely. The internal sync helpers they
delegated to (refreshLibrary, refreshItem, applyTags, addToCollection,
etc.) are reused by OnBookOrganized.
Tests: outcome_test.go covers the Outcome helper constructors and the
IsTerminal contract. backend_contract_test.go is a compile-time
assertion that PlexBackend and EmbyBackend implement Backend, plus a
runtime assertion that not-configured backends return
SkippedNotConfigured rather than silently no-op'ing.
Drive-by: removed unused machineID parameter from
PlexBackend.findOrCreateCollection and unused
emitProcessingProgress function.
Refs audplexus-cxn (PR-0: operational contract hardening). Lays the
foundation for multi-library-destination work in audplexus-4d1
through audplexus-uwg without locking in the wrong sync semantics.
…part, asin)
Adds a Settings dropdown for the embedded-metadata profile. Two
options, default Basic for everyone (no behavior change for existing
installs):
- Basic — today's tag set, identical to v0.2.1
- Audiobook-rich — adds series, series-part, asin freeform atoms
Audiobookshelf reads `series` and `series-part` via ffprobe for series
auto-grouping, but Audplexus deliberately drops book.Series from the
embed pipeline today (a Plex album-collapse workaround keeps album=
title). The Audiobook-rich profile sidesteps that by writing the
series fields to their own atoms — the album field stays at title, so
the Plex workaround is preserved while ABS gets what it needs.
Critical empirical fix: ffmpeg's mp4 muxer silently drops unknown
-metadata keys unless `-movflags use_metadata_tags` is set, which
switches output to QuickTime mdta atoms. Verified against ffmpeg
6.1.1: with the flag, ffprobe -show_format reports the keys cleanly;
without it, the tags vanish. EmbedMetadata now sets the flag only on
Audiobook-rich (Basic preserves byte-for-byte today's behavior).
Files:
internal/audio/tag.go extend Metadata with Series,
SeriesPart, ASIN, Profile.
Conditional emission via profile.
internal/audio/tag_profile.go NEW. TagProfile enum, lenient
ParseTagProfile + strict
ParseTagProfileStrict, Resolve
helper, AllTagProfiles for UI.
internal/audio/tag_test.go NEW. Verifies basic/rich emission
rules, the movflag presence, the
album=title workaround.
internal/audio/tag_profile_test.go NEW. Covers parse, resolve, list.
internal/audnexus/metadata.go ToAudioMetadata always populates
Series/SeriesPart/ASIN — emission
is gated by Profile in
buildMetadataArgs.
internal/library/download.go Reads profile via
audio.ResolveTagProfile and sets
on Metadata before EmbedMetadata.
internal/web/server.go Renders TagProfile + TagProfiles
in settings page data. New POST
/settings/tag-profile handler.
internal/web/templates/settings.html New "Tag Profile" auth-panel
section with native <select>,
aria-describedby help text. A11y-
reviewed against existing Media
Server section baseline.
Codex review (PR-A diff) flagged the original validator as broken
(silently accepted junk, rejected aliases). Replaced with strict
ParseTagProfileStrict + lenient ParseTagProfile pair — the strict
form is used at the form-handler boundary where silent fallback
would hide a typo, the lenient form for DB-read code paths.
No schema migration. Default = Basic = no behavior change. Existing
libraries upgrade transparently. Refs audplexus-2q5 (PR-A).
…es map
Migration 005 introduces two new tables:
library_destinations One row per configured destination.
Multiple instances of the same type are
allowed (e.g. household Plex + parents'
Plex). Per-type config columns instead
of a JSON blob keep migrations honest
about required fields and let redaction
rules be explicit.
book_library_destinations Per-(book, destination) state. Replaces
the legacy 1:1 books.media_server_id /
media_server_title columns. Captures
attempt_count, last_attempted_at vs
last_succeeded_at, disabled_reason, and
an open-set per_op_outcomes JSON map —
addresses codex's "stale success" and
retry-budget concerns.
Per codex review, schema decisions revised from earlier draft:
- Typed per-type columns, NOT a JSON blob. CHECK constraint enforces
required fields per type. Redaction at the model layer (APIKey and
PlexToken use json:"-").
- Migration creates SCHEMA only. Synthesis from legacy single-backend
config is application code — internal/library/firstboot_destinations.go
SynthesizeLibraryDestinationsIfEmpty — runs on first boot when
library_destinations is empty.
- Full state machine on book_library_destinations:
pending -> syncing -> (synced | failed)
synced -> (orphaned | removed_from_destination)
last_attempted_at separate from last_succeeded_at so operators see
"tried recently but failed" cleanly.
Backend interface (mediaserver.Backend) gains Capabilities() returning
a CapabilitySet. Plex declares: TriggerScan, PerItemRefresh,
SeriesGrouping, ItemCount. Emby declares all of Plex plus FranchiseTag,
ImageUpload, AuthorImages, BoxSetCovers. Capabilities are advisory for
UI affordances only — runtime contract remains the typed Outcome from
PR-0 (Backend never silently no-ops; unsupported ops return
StatusUnsupported with ErrUnsupported).
Tests:
- library_destinations_test.go: round-trip CRUD for both Plex and
Emby destinations; CHECK rejection of incomplete configs;
enabled/disabled filtering; Update persistence; FK CASCADE delete
from books to book_library_destinations.
- firstboot_destinations_test.go: synthesis no-op when destinations
exist; synthesis from legacy plex_url/plex_token settings; same
for emby; silent skip on incomplete legacy config; no-op on fresh
install (no env, no settings).
- mediaserver tests + library reconcile mock updated to satisfy the
extended Database interface.
Drive-by: SQLite FK CASCADE was silently disabled because modernc.org
/sqlite ignores ?_foreign_keys=on (uses ?_pragma=foreign_keys%3DON
instead). Fixed in NewSQLite — caught by PR-B's FK cascade test, was
a latent bug (existing 003+004 migrations have FK constraints that
never enforced).
Refs audplexus-4d1 (PR-B). Foundations for PR-C (read-path cutover +
Settings cards UI).
…inations
DestinationManager (internal/library/destinations.go) reads enabled
destinations from library_destinations and fans out OnBookOrganized
across them concurrently, bounded by maxConcurrency (default 3). Each
destination's outcomes are recorded in book_library_destinations via
UpsertBookDestination — including the summary sync_state derived from
the outcome set, last_attempted_at, attempt_count, last_error, and a
JSON-encoded per_op_outcomes map keyed by operation name.
Wiring in cmd/server/main.go:
1. SynthesizeLibraryDestinationsIfEmpty runs at boot to create one
library_destinations row from legacy settings (MEDIA_SERVER +
plex_url/emby_url/etc) when the table is empty.
2. DestinationManager constructed alongside the existing single-
backend mediaserver. Both pass through to the DownloadManager.
Pipeline (internal/library/pipeline_stages.go):
- Builds OrganizedBook once.
- Prefers DestinationManager.FanOut when destinations are wired
(modern path). Falls back to single-backend OnBookOrganized when
not, so installs that haven't run synthesis yet keep working.
- Outcomes logged per-destination with structured fields.
Critical contract preserved from PR-0: nothing is fire-and-forget.
The fan-out blocks until every destination's OnBookOrganized returns
(or its 2-minute per-destination timeout fires), THEN the download is
marked complete. False-success path stays gone.
State summarization (summarizeOutcomesState):
- any Failed → BookDestSyncFailed
- any SkippedNotConfigured → BookDestSyncFailed (destination
isn't actually receiving)
- empty → BookDestSyncPending
- all Succeeded/SkippedExisting/
Unsupported/Deferred → BookDestSyncSynced
Tests:
- destinations_test.go: real-SQLite integration test exercising the
full FanOut path with two destinations (Plex + Emby). Confirms
per-destination state rows are created, attempt_count increments,
per_op_outcomes JSON populated, and the SkippedNotConfigured
contract is preserved (backends with no settings table config
return that status rather than silently no-op'ing).
- summarizeOutcomesState table-driven test for state machine.
Settings UI cards / dashboard cards / per-destination Diagnostics
deferred to a follow-up commit on this branch — the fan-out works
without UI changes because legacy single-backend Settings panels
still configure the synthesized destination's underlying settings
(via the existing settings table, which backends still read).
Refs audplexus-7jj (PR-C). Unblocks PR-D (Jellyfin) and PR-E (ABS):
each new backend just registers in DestinationManager.buildBackend
and gets a row in library_destinations.
Two new mediaserver.Backend implementations register through the existing DestinationManager.buildBackend dispatch and the mediaserver.New factory. JellyfinBackend (internal/mediaserver/jellyfin.go) — closes mstrhakr#5: Authentication uses the canonical MediaBrowser scheme from day one: Authorization: MediaBrowser Token="...", Client="Audplexus", ... Per Jellyfin PR #13306 the legacy X-Emby-Token fallback is gated behind EnableLegacyAuthorization (default true today, removed in Jellyfin 12.0). New code uses the proper header so it survives. Key differences from EmbyBackend (verified by API research): - Item filter: IncludeItemTypes=AudioBook (Jellyfin's first-class AudioBook kind), not Emby's MusicAlbum + double-emit dedup. - Targeted folder refresh via /Items/{id}/Refresh available; this iteration uses /Library/Refresh on every scan trigger (works, just less targeted; future commit can switch). - applyTags POSTs the full BaseItemDto with LockedFields set to ["Tags"] so a metadata refresh doesn't strip them. Capabilities: TriggerScan, PerItemRefresh, SeriesGrouping, FranchiseTag, ImageUpload, ItemCount, AuthorImages, BoxSetCovers — same set as Emby. ReconcileLibrary returns a typed not-implemented error; per-book OnBookOrganized fan-out + book_library_destinations covers the normal case. ABSBackend (internal/mediaserver/abs.go): Authentication: Authorization: Bearer <api_key>. Admin-scope key required for scan endpoints (post-v2.26.0 JWT rewrite, 2025-07). Smaller surface than the Plex/Emby/Jellyfin trio because ABS: - has a chokidar folder watcher on by default — scan trigger is belt-and-suspenders, idempotent - has no franchise concept and no BoxSet collections - manages author images itself (no upload required from Audplexus) - has first-class series support driven by metadata, NOT collections — so series grouping is either implicit (when PR-A's Audiobook-rich tag profile writes `series` and `series-part` atoms that ABS auto-detects) or via PATCH /api/items/{id}/media with metadata.series=[{name,sequence}]. OnBookOrganized: scan_trigger -> wait-for-item-by-ASIN -> series_grouping (skipped_existing if ABS already has the series via tags, else patch). No franchise tagging — emitted as missing capability rather than silent no-op. Capabilities: TriggerScan, PerItemRefresh, SeriesGrouping (implicit via tags), ItemCount. Notably missing: FranchiseTag, BoxSetCovers, AuthorImages, ImageUpload. Capabilities map is honest about what ABS doesn't do, so the (future) per-destination Settings UI can hide those toggles cleanly. ReconcileLibrary stub for the same reason as Jellyfin — folder watcher + per-book scan covers the normal case. Both backends register in: - mediaserver.New (Type switch) - DestinationManager.buildBackend (per-row Backend construction) - new TypeJellyfin and TypeABS constants Tests (internal/mediaserver/jellyfin_abs_test.go): - Compile-time assertion both backends satisfy Backend. - Not-configured contract assertion: each backend returns SkippedNotConfigured rather than silently no-op'ing. - Capability-set assertions: Jellyfin claims the full audiobook feature set; ABS's set is small but explicit, and ABS does NOT falsely advertise FranchiseTag/BoxSetCovers/AuthorImages. - Tag-locking helper preserves existing non-Tags entries. Drive-by: simplified summarizeOutcomesState in destinations.go to match the test cases — SkippedNotConfigured now correctly maps to sync_state='failed' (destination not ready to receive) rather than 'pending'. Refs audplexus-mug (PR-D Jellyfin), audplexus-uwg (PR-E ABS). Closes mstrhakr#5 (JellyFin integration request).
…hDestination
Each backend (PlexBackend, EmbyBackend, JellyfinBackend, ABSBackend) now
exposes a WithDestination(*database.LibraryDestination) builder. When
bound, settings() reads URL/token/api_key/library_id directly from the
destination row instead of the settings table.
Why: previously the settings() lookup was global (`plex_url` /
`emby_url` / etc keys in the settings table or env vars). That meant
two destinations of the same type couldn't have independent config —
adding a second Plex destination (household + parents') would silently
share the same URL+token because both backends would read the same
settings keys.
Now: DestinationManager.buildBackend calls .WithDestination(row) on
each constructed backend, so the runtime config flows from the
library_destinations row. Multiple instances of the same type get
independent config naturally.
Backwards compat preserved: when WithDestination is not called (the
legacy single-backend code path used by reconcile, diagnostics, and
Settings UI rendering), settings() falls back to the settings table
+ env vars, so the existing v0.2.x flow keeps working unchanged.
Tests:
- TestWithDestinationOverridesSettingsTable: confirms row-bound
backend reads from row even when settings table has different
values (the household-vs-parents'-Plex case).
- TestWithDestinationFallsBackToSettingsWhenNotSet: confirms the
legacy code path still reads from settings table.
- destinations_test.go updated: with backends now actually attempting
the row's URL, the dummy "http://plex" / "http://emby" hostnames
fail at DNS — the test now asserts sync_state=failed and per-op
outcome status=failed, which is the correct new contract (typed
outcome reports the real error rather than silently no-op'ing).
Refs audplexus-7jj follow-up. Closes the third item in the
"known not-yet-handled" list — multi-instance-of-same-type now
actually works.
UI: Settings page now has a "Library Destinations" section listing all
configured destinations with per-card status, enable/disable, edit, and
delete. Add-destination flow is a two-page server-rendered wizard
(type picker → type-specific config form). Delete is POST-only with a
confirmation step at the same endpoint (no safe-GET destruction).
A11y reviewed by accessibility-lead and applied:
- <ul role="list"> for the destination list (Safari/VoiceOver strips
list semantics when list-style:none, role="list" restores them).
- Per-card <h4> as the labelledby target gives every action button a
unique accessible name (Edit/Enable/Delete + visually-hidden display
name suffix). Satisfies WCAG 2.4.4 link-purpose.
- Toggle button flips its visible label ("Enable" / "Disable") rather
than using aria-pressed (a11y-lead flagged the double-announce
contradiction).
- · separators wrapped in aria-hidden="true" so SR users don't
hear "middle dot" between status fields.
- Failed-destination LastError is wired via aria-describedby on the
Edit link so SR users know what's broken before clicking.
- Delete is POST-only (RFC 9110 compliance + WCAG semantics for
destructive controls); same endpoint serves "render confirm page"
and "actually delete" via confirm=1 form field.
- .visually-hidden utility added to style.css, plus .badge-inactive
(neutral) and .badge-type (subtle blue) for the new badge variants.
Routes (all gated through gin's existing auth/CSRF middleware):
GET /destinations/new — type picker
POST /destinations/new — render type-specific form
POST /destinations/create — persist new
GET /destinations/:id/edit — render edit form (pre-filled)
POST /destinations/:id — persist edit
POST /destinations/:id/toggle — flip enabled flag
POST /destinations/:id/delete — confirm step + actual delete
Sensitive fields (PlexToken, APIKey) never reach the template:
destinationView strips them server-side; edit form leaves the password
input blank, and the handler carries the existing value forward when
the form posts an empty value (rotation requires entering a new key).
Reconcile impls (closes the "stub" notes in PR-D and PR-E):
- JellyfinBackend.ReconcileLibrary walks /Items?IncludeItemTypes=
AudioBook in pages of 200, matches by normalized title, persists
the server-side Id via UpdateBookMediaServerInfo. Mirror of Emby's
reconcile with Jellyfin's first-class AudioBook filter.
- ABSBackend.ReconcileLibrary walks /api/libraries/{id}/items in
pages of 200, matches by ASIN (server-side metadata.asin field),
persists ABS LibraryItem.id. Series patching deferred — ABS auto-
detects from tags when the Audiobook-rich profile is enabled.
Files:
+ internal/web/destinations_handlers.go 9 handlers + view-model
+ internal/web/templates/destinations_new.html
+ internal/web/templates/destinations_form.html
+ internal/web/templates/destinations_delete.html
M internal/web/server.go routes + Destinations field
in settingsPageData
M internal/web/templates/settings.html Library Destinations section
M internal/web/static/style.css .visually-hidden + 2 badges
M internal/mediaserver/abs.go ReconcileLibrary + paged listing
M internal/mediaserver/jellyfin.go ReconcileLibrary + paged listing
Refs audplexus-7jj follow-up. Closes the second item in the
"known not-yet-handled" list (Settings cards UI is now functional —
no SQL required for destination CRUD). Per-destination Diagnostics
page deferred to a follow-up commit.
…oard
User-reported gaps after the Library Destinations work shipped — the
settings and dashboard still showed Plex/Emby-only surfaces from the
v0.2.x days alongside the new multi-destination UI. Confusing
redundancy: a Plex/Emby-only "Active Media Server" radio above
Library Destinations, dedicated Plex auth + Emby config panels duplicating
what destinations CRUD does, and dashboard cards saying "Emby Connected"
with no mention of the configured Jellyfin or ABS destinations.
Settings page (settings.html):
- Removed the "Media Server" panel with Plex/Emby `<select>`. The new
Library Destinations CRUD covers every type and enable/disable.
- Removed the conditional Plex auth panel + Emby config form. Both
are redundant with Library Destinations and were Plex/Emby-only.
- Removed the conditional Plex/Emby Library Path info-items in the
System Information section. Per-destination paths now live on the
destination row.
Dashboard summary (dashboard_summary.html + getDashboardSummaryData):
- Replaced single "{{.MediaServerDisplay}} Connected/Items/Coverage"
stat-cards with a per-destination card list. New Destinations
section shows one card per row in library_destinations:
DisplayName, type badge, status badge (role="status" for SR
announcements on health flips), item count, coverage %.
- Backend-agnostic, scales to N destinations naturally.
- Per a11y-lead: aria-live="polite" + aria-atomic="false" on the
list, aria-live="off" on numeric <dd>s so SR users hear status
changes (Healthy → Failed) without count chatter every 12s tick.
- Empty state: CTA to /destinations/new.
Per-destination item-count cache:
- New destItemCountCache (map[destID]entry, 30s TTL) so the 12s
dashboard poll doesn't hammer remote servers when 3+ destinations
are configured. Errors cached briefly too — a failing destination
doesn't get re-hit on every tick.
- buildDestinationBackend mirrors DestinationManager's per-row
Backend construction, with WithDestination binding so each
destination's URL/api_key/library_id wins over settings table.
Settings cleanup mitigation (per a11y-lead Q1): the destinations form
already has token-finding help text on every type's API-key field
(GET /emby/Library/MediaFolders for Emby, X-Plex-Token URL for Plex,
admin API Keys page for ABS), so removing the Plex PIN-flow doesn't
strand users with cognitive load — they're directed to the right doc
inline.
Refs audplexus-7jj follow-up. Closes the user-visible gaps after
PR-G shipped — Library Destinations is now the single canonical UI
for destination management; dashboard reflects all configured
destinations honestly.
Two more user-visible Plex/Emby leaks the audit caught:
- base.html brand subtitle was "Audible to {{.MediaServerDisplay}}",
which displayed "Audible to Emby" in the sidebar even on installs
with Plex/Jellyfin/ABS destinations. Replaced with the static
"Audible to your library" — backend-agnostic, scales to N
destinations.
- internal/library/sync.go hardcoded "Syncing with Plex (scan +
query)..." and "{N} items in Plex (scan complete)" in the user-
visible phase status messages on the dashboard. Replaced with
"Syncing with library destination" / "{N} items in library". The
SyncPhase identifier stays "plex_sync" (URL/JS-stable for
back-compat with anything that scrapes the API) but humans never
see it.
Refs audplexus-7jj follow-up. Closes the user's "Plex everywhere"
audit findings — the only remaining "Plex" / "Emby" strings in
user-visible code are the type badge labels on destination cards
(correct: a Plex destination IS a Plex destination) and the
destination form's per-type help text (correct: Plex tokens are
fetched differently from Emby API keys).
…bug; dead-code sweep
User-reported audit findings + a pre-existing template bug surfaced
during the multi-dest validation:
(1) Full Sync's Library Scan + Reconcile phases now fan out across
every enabled library destination instead of only running against the
legacy MEDIA_SERVER backend. New DestinationManager methods:
TriggerScanAll(ctx) -> fans out TriggerLibraryScan to enabled
destinations concurrently (bounded), sums
returned item counts. Used by the
PhasePlexSync stage.
ReconcileAll(ctx, prog) -> fans out ReconcileLibrary to enabled
destinations concurrently. Each gets a
10-minute per-destination cap. One
destination's failure does not abort
others — caller logs ok/failed counts
and only returns an error if ALL fail.
The web Server's PlexSyncCallback / PlexReconcileCallback dispatch on
the manager when present, and only fall back to the legacy
single-backend path when no destinations are wired (early boot before
first-boot synthesis). Quick Sync stays Audible-only by design (it's
the fast "no media server interaction" mode).
(2) Pipeline tab Completed table showed ASIN in the Title column for
every row — pre-existing copy-paste typo at downloads.html:64
(<td>{{.ASIN}}</td> twice). Plus the page heading was misspelled
("Pipline"). Fixed both. Active downloads card also now shows the
title prominently with the ASIN as a muted suffix. Wired
DownloadTitles map through handleDownloads (was only built by the
HTMX dashboard handler before).
(3) Dead-code sweep — surfaces removed by earlier UI cleanup left
their Go code stranded:
- internal/web/emby_settings.go (handleEmbyConfigure,
handleEmbyScan, handleMediaServerSelect, embySettings helper).
DELETED. Routes /auth/emby/* and /auth/media-server/select
removed.
- settingsPageData no longer computes EmbyURL/EmbyAPIKey/
EmbyLibraryID/EmbyLibraryPath/EmbyConfigured/MediaServerType.
Templates don't render them.
- getDashboardSummaryData stripped of the entire single-active-
backend computation (LibraryItems/LibraryCoverage/LibraryDetail/
PlexConfigured/PlexSection/PlexItems/PlexCoverage etc). Was
re-running mediaserver.Resolve + cached item-count lookup on
every 12s dashboard poll for fields no template renders.
- getCachedLibraryItemCount + itemCountCache struct field DELETED.
destItemCountCache (per-destination map) replaces it.
Net change: 8 commits net code reduction. Production was hammering
the legacy active backend with ~5 wasted RPC equivalents per
dashboard poll; now it only hits each destination once per 30s
(per-destination cache).
Refs audplexus-7jj follow-up. Closes the user-visible audit findings:
- "the dropdown that has plex and emby in it" → removed
- "dashboard says emby everywhere" → per-destination cards
- "Emby section in the settings... but nothing like that for the
other types" → all three legacy type-specific panels gone
- "Title shows ASIN in pipeline tab" → fixed
… health persistence
User asked for two improvements that landed in this commit, plus a
related fix for "Never checked" lingering on cards:
(1) Per-field detailed instructions in destinations_form.html. The
short blurb stays in <small aria-describedby> for SR
focus-time announcement (per a11y-lead spec — putting the long version
in aria-describedby would force SRs to read 4 sentences every time
the input gets focus). The step-by-step lives in <details><summary>
expandable blocks below each field. Per backend type:
Plex:
- Token: walks through Plex web UI → "Get Info" → "View XML" →
copy ?X-Plex-Token=. Plus link to Plex's official guide.
- Section ID: from URL bar /source/<id>, OR API alternative.
Emby:
- API key: Settings (gear) → Server → Advanced → API Keys →
"+ New API Key".
- Library ID: curl /emby/Library/MediaFolders + jq, find the
CollectionType=audiobooks entry, copy ItemId.
Jellyfin:
- API key: Dashboard → API Keys (Advanced) → "+".
- Library ID: curl /Library/VirtualFolders + jq, find books
collectionType, copy ItemId.
ABS:
- API key: Settings → Users → admin user → API Keys → "Generate".
Token shown once, copy immediately.
- Library ID: curl /api/libraries with Bearer token, find
mediaType=book, copy id (UUID). Plus URL-bar alternative.
(2) Test Connection button alongside Save. POST /destinations/test
takes form values directly; POST /destinations/:id/test loads the
saved row and carries over secrets if the form left them empty. The
handler builds a Backend with WithDestination, calls LibraryItemCount
with a 10s ctx timeout, and returns an HTML fragment for HTMX swap.
Per a11y-lead spec, the result container in the form template:
- <div id="test-result" role="status" aria-live="polite"
aria-atomic="true" aria-busy="false">
- exists in DOM before swap so the live region announcement fires
- role="status" (polite) for both success and failure — user
initiated the test, it's not an unexpected interruption
- hx-on:before-request sets aria-busy=true + textContent=
"Testing connection…" so sighted + SR get parity
- on success the focus stays on the button (user can immediately
click Save); on failure the result has tabindex=-1 + a stable id
so a future focus-shift can be added if the error needs it
- button is type="button" with HTMX attrs (not type=submit) so
Enter on the form still submits Save, not Test
Button order [Cancel] [Test Connection] [Save] — Save rightmost
(primary CTA convention), Cancel leftmost (escape, far from
destructive misclick), Test in the middle (try-before-commit helper
adjacent to the action it gates).
(3) "Never checked" badge no longer lingers after a successful test
or sync. Health is now persisted via the new
recordDestinationHealth(ctx, destID, ok, errMsg) helper:
- Test Connection button (saved-row path): on success or failure,
update last_health_check_at + last_health_check_ok +
last_health_check_err on the row.
- SyncService's library scan callback (TriggerScanAll fan-out):
update health per-destination after the scan returns.
- SyncService's reconcile callback (ReconcileAll fan-out): same.
After this commit's deploy, the user's three destinations all flip
from "Never checked" to "Healthy" the next time a sync runs, OR
immediately when the user clicks Test Connection on each card's
edit page.
Drive-by: fixed renderTestResult sending "Connected." in both the
<strong> tag and the message text (so the banner read "Connected.
Connected. Library reports 342 items").
Refs audplexus-7jj follow-up. Closes user's "lots of stuff tagged
with 'never checked'" finding and the "no idea where the library ID
came from" instruction gap.
Pre-PR proactive review to head off Copilot/maintainer concerns. Findings + fixes: - Unused `sync.Mutex` field on DestinationManager (left from an earlier draft of a Backend cache that never landed). Removed. - "legacy single-backend selected for reconcile / diagnostics" startup log line was misleading after PR-G+: the legacy backend is only the FALLBACK now, not the active path. Reworded to "legacy single-backend resolved (fallback only — fan-out via DestinationManager when destinations are configured)". - DestinationManager.buildBackend was duplicated as Server.buildDestinationBackend in server.go (added when the dashboard handler needed per-destination Backend construction). Single source of truth: exported as DestinationManager.BuildBackend and Server.buildDestinationBackend now delegates to it. - First-boot synthesis only handled MEDIA_SERVER=plex|emby. Extended to also synthesize Jellyfin and ABS destinations from JELLYFIN_URL/JELLYFIN_API_KEY/JELLYFIN_LIBRARY_ID and ABS_URL/ ABS_API_KEY/ABS_LIBRARY_ID env vars (or settings table values). Useful for headless installs. - DestinationManager.FanOut's `recordOutcomes` call uses the OUTER ctx (not the per-destination ctx) on purpose — we want to persist the outcome row even if the per-destination timeout fired. Added a comment explaining this so future readers don't "fix" it back. No behavior change beyond the Jellyfin/ABS first-boot synthesis gain. All tests still pass; build clean; vet clean.
Codex review against upstream/master flagged three concrete bugs that
would have shown up in mstrhakr's Copilot pre-merge review:
[P1] Default Plex installs lost post-download work after upgrade
Existing v0.2.x Plex users typically never set MEDIA_SERVER or
media_server_type — Plex was the silent default. After this branch:
resolveLegacyMediaServerType returned empty, no library_destinations
row was synthesized, FanOut found no enabled destinations, and the
pipeline_stages.go `case dm.mediaServer != nil` arm was unreachable
(always lost the switch to the always-non-nil destinations arm
preceding it). Result: every download silently skipped scan
triggering and series-collection management.
Fix in two places:
1. firstboot_destinations.go now infers Plex (or Emby) when type
env/setting is empty BUT plex_url + plex_token + plex_section_id
(or the Emby triple) are all set. New helpers hasPlexLegacyConfig
/ hasEmbyLegacyConfig.
2. pipeline_stages.go now falls through to the legacy backend when
FanOut returns zero results (not just when destinations is nil).
Same fall-through added in the sync callbacks (TriggerScanAll /
ReconcileAll empty -> fall back to legacy backend's scan/recon).
[P2] Audiobook-rich tag profile silently dropped freeform atoms in the
normal download path
PR-A added `-movflags use_metadata_tags` to embedArgs (the standalone
EmbedMetadata path) to keep ffmpeg from dropping the new series /
series-part / asin atoms during mp4 muxing. But the actual download
pipeline calls DecryptAAX(C)WithMetadata which uses buildDecryptArgs,
NOT embedArgs. So the freeform atoms vanished on every real download
while my unit test (which exercises embedArgs only) kept passing.
buildDecryptArgs now also conditionally appends the movflag when
meta.Profile == TagProfileAudiobookRich. Verified empirically against
ffmpeg 6.1.1 in the prior round; same flag, same fix.
[P2] Per-destination scan paths weren't honored
PR-G's Settings form added optional AudiobookPath / DestinationPath
fields per destination, but the backend's path resolution still used
the global libraryDir + plex_section_path / emby_library_path
settings. Multi-dest installs that set per-destination paths got
them stored in the DB and then ignored at scan time.
Fix:
- PlexBackend.resolveScanPath checks destination.AudiobookPath +
DestinationPath first; falls back to libraryDir + cached
plex_section_path only when the row doesn't carry both.
- EmbyBackend.libraryServerPath checks destination.DestinationPath
first; falls back to cached + env. OnBookOrganized's
translateScanPath now passes destination.AudiobookPath as the
local root when present.
- Jellyfin uses the same libraryServerPath shape as Emby — applies
via WithDestination binding; targeted refresh wasn't using
per-folder mapping in PR-D anyway (uses /Library/Refresh global)
so no Jellyfin-specific change required this round.
All tests pass. No new tests added for the codex P1 fix specifically;
the upgrade-path scenario lives in firstboot_destinations_test.go and
will get a regression test in a follow-up. The other two fixes have
the existing TestEmbedArgsAudiobookRichSetsMovFlagsUseMetadataTags
covering the muxer flag (now also exercised in buildDecryptArgs path
once tests are added).
Refs codex pre-PR review (2026-05-10). Closes the three findings
before submitting upstream PR.
…sugar
Targeted polish before submitting upstream PR. Net REDUCTION of code
(+185 / -237) despite adding three tests, a secret-redacting Stringer,
README rewrite for multi-destination, and a docs/destinations.md
architecture overview.
Tests added:
- TestBuildDecryptArgsAudiobookRichSetsMovFlags — regression test for
codex P2: the actual download pipeline goes through buildDecryptArgs
not embedArgs. Without the muxer flag here, Audiobook-rich silently
dropped freeform atoms on every real download.
- TestBuildDecryptArgsBasicDoesNotSetMovFlags — sister test for the
Basic-profile no-change-from-historical case.
- TestFirstBootSynthesisInfersPlexFromConfigWhenTypeUnset — regression
test for codex P1: existing v0.2.x Plex installs without explicit
MEDIA_SERVER must still synthesize a Plex destination from
plex_url/token/section. The Emby variant covered too.
- TestLibraryDestinationStringerRedactsSecrets — defends against
accidental fmt.Printf("%+v", dest) leaking PlexToken/APIKey via
reflection. json:"-" alone doesn't cover the fmt path.
- TestLibraryDestinationStringerShowsUnsetSecretsAsUnset — the
"<unset>" vs "<redacted>" distinction stays useful in logs.
Sugar:
- LibraryDestination.String() and GoString() redact PlexToken/APIKey
to "<redacted>" (or "<unset>" when empty). Defense-in-depth on
top of json:"-".
- Test Connection button now has hx-disabled-elt="this" — prevents
the double-click → 2 concurrent test requests race.
- Standardized log field names: destination_id (UUID) +
destination_name (display string), not the previous mix of
"destination" / "destination_id" for both purposes.
- Dashboard cards now show "Last checked: 4 min ago" via a new
LastCheckedAt field on destinationSummaryView. Wraps the time in
a <time datetime=...> element so SR users get the ISO timestamp.
- Library Scan total_items now reports max(per-destination), not
sum. With 3 destinations on the same /audiobooks mount, summing
showed 1026 when only 342 books exist (3x bogus). User caught it
on the dashboard.
Docs:
- README.md "Media Server Integrations" section rewritten as
"Library Destinations" — describes multi-dest fan-out, all four
backends (Plex, Emby, Jellyfin, ABS), per-destination CRUD via
Settings, and the headless first-boot env-var path that now
handles Jellyfin and ABS too. MEDIA_SERVER var description updated
to reflect its new bootstrap-shim role.
- docs/destinations.md added — 1-pager architecture overview with
flow diagram, data model tables, runtime contract docs, capability
matrix, and per-backend protocol notes. Helps reviewers grok the
whole feature in 2 min instead of reading 6200 LoC.
Code dedupe:
- cmd/abstest/main.go and cmd/jellytest/main.go each had ~150 lines
of inMemoryDB stub satisfying the Database interface. Extracted
to internal/database/StubDB (NewStubDB + SeedSettings); both
binaries now use that. Net: -200 LoC across the two test progs.
The StubDB is also reusable by other test programs that want a
live-server integration test pattern without pulling in real
sqlite.
Refs codex pre-PR review (covered in the previous commit). This commit
is the polish layer on top.
There was a problem hiding this comment.
Pull request overview
This PR expands Audplexus’s media-server integration from a single selected backend to a multi-destination “library destinations” model, adding first-class Jellyfin and Audiobookshelf support and enabling bounded concurrent fan-out of post-organize operations with per-destination outcome/state tracking.
Changes:
- Introduces
library_destinations+book_library_destinationsschema and CRUD, plus first-boot synthesis from legacy settings/env. - Reworks
mediaserver.Backendaround synchronousOnBookOrganized(...) []Outcome+ capability advertisement, and updates Plex/Emby implementations to be destination-row bound (WithDestination); adds Jellyfin/ABS backends. - Updates pipeline + sync + UI to operate on multiple destinations and to record/reflect per-destination health and outcomes.
Reviewed changes
Copilot reviewed 53 out of 53 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates documentation from single media server selection to multi-destination setup and tag profiles. |
| internal/web/templates/settings.html | Replaces “Media Server” settings panel with tag profile selector and destinations list UI. |
| internal/web/templates/downloads.html | Fixes “Pipeline” heading typo and shows titles alongside ASINs. |
| internal/web/templates/destinations_new.html | Adds destination type picker page. |
| internal/web/templates/destinations_form.html | Adds create/edit destination form with per-type fields, path translation, and HTMX “Test Connection”. |
| internal/web/templates/destinations_delete.html | Adds POST-only delete confirmation page for destinations. |
| internal/web/templates/dashboard_summary.html | Replaces single-backend dashboard stats with per-destination summary cards and health badges. |
| internal/web/templates/base.html | Removes backend-specific subtitle (“Audible to Plex/Emby…”) in favor of generic copy. |
| internal/web/static/style.css | Adds badge styles and a reusable .visually-hidden utility for a11y. |
| internal/web/emby_settings.go | Removes legacy Emby settings handlers (superseded by destinations CRUD). |
| internal/web/destinations_handlers.go | Implements destination CRUD + toggle + delete and HTMX test endpoint. |
| internal/mediaserver/with_destination_test.go | Tests WithDestination overrides legacy settings-table config per backend. |
| internal/mediaserver/plex.go | Adds destination binding, capabilities, synchronous outcomes, and per-destination path translation support. |
| internal/mediaserver/outcome.go | Introduces typed Outcome contract, operations, and capability flags. |
| internal/mediaserver/outcome_test.go | Unit tests for Outcome helpers and terminality semantics. |
| internal/mediaserver/mediaserver.go | Extends backend types (Jellyfin/ABS) and updates interface to Capabilities + OnBookOrganized. |
| internal/mediaserver/jellyfin_abs_test.go | Compile-time + contract tests for Jellyfin/ABS and capability assertions. |
| internal/mediaserver/emby.go | Adds destination binding, capabilities, and synchronous per-book outcome workflow (scan/match/group/tag). |
| internal/mediaserver/backend_contract_test.go | Enforces “not configured returns typed skip” contract and provides a settings-only stub DB. |
| internal/library/sync.go | Renames user-facing “Plex Sync” messaging to backend-agnostic “Library Scan” while preserving phase ID. |
| internal/library/pipeline_stages.go | Runs destination fan-out before marking downloads complete; adds structured outcome logging. |
| internal/library/library_reconcile_test.go | Extends reconcile test DB mock to satisfy new DB interface methods. |
| internal/library/firstboot_destinations.go | Adds first-boot synthesis of a destination row from legacy single-backend settings/env. |
| internal/library/firstboot_destinations_test.go | Tests first-boot synthesis behavior, including inferred Plex/Emby cases for upgrades. |
| internal/library/download.go | Threads DestinationManager into DownloadManager and uses tag profile resolution during decrypt. |
| internal/library/destinations.go | Adds DestinationManager for bounded concurrent fan-out + scan/reconcile fan-out + per-destination state recording. |
| internal/library/destinations_test.go | Integration-style tests for fan-out recording and outcome→state summarization. |
| internal/database/testutil_stub.go | Adds database.StubDB helper for tests and live probe binaries. |
| internal/database/sqlite.go | Fixes SQLite DSN pragmas to ensure FK enforcement works under modernc.org/sqlite. |
| internal/database/models.go | Adds LibraryDestination / BookDestination models and secret-redacting formatters. |
| internal/database/migrations/005_library_destinations.up.sql | Adds SQLite schema for destinations + per-(book,destination) state with FKs and constraints. |
| internal/database/migrations/005_library_destinations.down.sql | Adds SQLite down migration for new tables/indexes. |
| internal/database/migrations_postgres/005_library_destinations.up.sql | Adds Postgres schema equivalents (JSONB, TIMESTAMPTZ, BOOLEAN). |
| internal/database/migrations_postgres/005_library_destinations.down.sql | Adds Postgres down migration for new tables/indexes. |
| internal/database/library_destinations_test.go | Adds DB-level tests for CRUD, constraints, and FK cascades. |
| internal/database/library_destinations_sqlite.go | Implements SQLite CRUD for destinations and book-destination state. |
| internal/database/library_destinations_redaction_test.go | Tests secret redaction for fmt formatting of destination rows. |
| internal/database/library_destinations_postgres.go | Implements Postgres CRUD for destinations and book-destination state. |
| internal/database/interface.go | Extends database.Database interface with destination CRUD/state methods. |
| internal/audnexus/metadata.go | Populates series/series-part/ASIN fields in audio.Metadata (emitted based on selected profile). |
| internal/audio/tag.go | Adds tag profile support and -movflags use_metadata_tags for audiobook-rich freeform atoms. |
| internal/audio/tag_test.go | Tests metadata args and decrypt/embed arg behavior for both tag profiles. |
| internal/audio/tag_profile.go | Adds tag profile parsing/resolution and UI label/description helpers. |
| internal/audio/tag_profile_test.go | Tests tag profile parsing and DB resolution behavior. |
| internal/audio/decrypt.go | Ensures audiobook-rich metadata atoms survive decrypt+embed path via movflags. |
| go.mod | Adds golang.org/x/sync dependency for semaphore-based fan-out. |
| docs/destinations.md | Adds architecture doc for destinations model, runtime contract, and fan-out behavior. |
| cmd/server/main.go | Adds first-boot destination synthesis and wires DestinationManager into download pipeline. |
| cmd/jellytest/main.go | Adds live Jellyfin probe utility using production backend logic. |
| cmd/abstest/main.go | Adds live Audiobookshelf probe utility using production backend logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| d.URL = settingOrEnv(ctx, db, "plex_url", "PLEX_URL") | ||
| d.PlexToken = settingOrEnv(ctx, db, "plex_token", "PLEX_TOKEN") | ||
| d.PlexSectionID = settingOrEnv(ctx, db, "plex_section_id", "PLEX_SECTION_ID") | ||
| d.AudiobookPath = settingOrEnv(ctx, db, "plex_section_path", "") |
| d.URL = settingOrEnv(ctx, db, "emby_url", "EMBY_URL") | ||
| d.APIKey = settingOrEnv(ctx, db, "emby_api_key", "EMBY_API_KEY") | ||
| d.LibraryID = settingOrEnv(ctx, db, "emby_library_id", "EMBY_LIBRARY_ID") | ||
| d.AudiobookPath = settingOrEnv(ctx, db, "emby_library_path", "EMBY_LIBRARY_PATH") |
| switch d.Type { | ||
| case database.LibraryDestinationTypePlex: | ||
| d.PlexToken = strings.TrimSpace(c.PostForm("plex_token")) | ||
| d.PlexSectionID = strings.TrimSpace(c.PostForm("plex_section_id")) | ||
| if d.PlexSectionID == "" { | ||
| return nil, errors.New("Plex section ID is required") | ||
| } | ||
| // PlexToken may be empty on edit — caller carries it over. | ||
| case database.LibraryDestinationTypeEmby, database.LibraryDestinationTypeJellyfin, database.LibraryDestinationTypeABS: | ||
| d.APIKey = strings.TrimSpace(c.PostForm("api_key")) | ||
| d.LibraryID = strings.TrimSpace(c.PostForm("library_id")) | ||
| if d.LibraryID == "" { | ||
| return nil, errors.New("Library ID is required") | ||
| } | ||
| // APIKey may be empty on edit — caller carries it over. |
| // 1. Scan trigger | ||
| scanCtx, scanCancel := context.WithTimeout(ctx, 30*time.Second) | ||
| defer scanCancel() | ||
| scanStart := time.Now() | ||
| scanPath, scanPathOK := p.resolveScanPath(scanCtx, plexURL, plexToken, sectionID, filepath.Dir(book.LocalPath)) | ||
| switch { | ||
| case strings.TrimSpace(book.LocalPath) == "": | ||
| outcomes = append(outcomes, Failed(OpScanTrigger, fmt.Errorf("empty local path"), "no path to scan")) | ||
| case !scanPathOK: | ||
| outcomes = append(outcomes, Failed(OpScanTrigger, fmt.Errorf("section path unavailable"), "plex section path could not be resolved")) |
| golang.org/x/arch v0.25.0 // indirect | ||
| golang.org/x/crypto v0.49.0 // indirect | ||
| golang.org/x/net v0.52.0 // indirect | ||
| golang.org/x/sync v0.20.0 // indirect | ||
| golang.org/x/sys v0.42.0 // indirect |
| (type = 'plex' AND url IS NOT NULL AND plex_token IS NOT NULL AND plex_section_id IS NOT NULL) OR | ||
| (type = 'emby' AND url IS NOT NULL AND api_key IS NOT NULL AND library_id IS NOT NULL) OR | ||
| (type = 'jellyfin' AND url IS NOT NULL AND api_key IS NOT NULL AND library_id IS NOT NULL) OR | ||
| (type = 'abs' AND url IS NOT NULL AND api_key IS NOT NULL AND library_id IS NOT NULL) |
| (type = 'plex' AND url IS NOT NULL AND plex_token IS NOT NULL AND plex_section_id IS NOT NULL) OR | ||
| (type = 'emby' AND url IS NOT NULL AND api_key IS NOT NULL AND library_id IS NOT NULL) OR | ||
| (type = 'jellyfin' AND url IS NOT NULL AND api_key IS NOT NULL AND library_id IS NOT NULL) OR | ||
| (type = 'abs' AND url IS NOT NULL AND api_key IS NOT NULL AND library_id IS NOT NULL) |
…ert, faststart) Conflict resolution: internal/audio/decrypt.go Combine -movflags: +faststart is always-on (upstream PR mstrhakr#4 — moov-to-front for downstream ffmpeg memory). use_metadata_tags is additive when AudiobookRich profile is active. Single -movflags arg with both values. internal/library/download.go Drop my inline cover+meta block in DecryptAndOrganize; use upstream's metadataWithOptionalCover helper. Move profile resolution (audio.ResolveTagProfile) INTO the helper so all 4 callers (download.DecryptAndOrganize, convert m4b<->mp3, pipeline chapter-split) pick up the AudiobookRich tag profile uniformly. internal/library/{pipeline_stages,convert}.go Upstream's new chapter-split + m4b<->mp3 convert paths still called the removed Backend interface (TriggerScanForBook, EnsureBookInSeriesCollection). Migrate them to the new fan-out: - Extract dm.fanOutPostOrganize() helper from the standard path. - Standard, chapter-split, convert m4b->mp3, convert mp3->m4b all now route through it. Multi-destination fan-out + legacy fallback apply uniformly to every "book is on disk" code path. internal/audio/tag_test.go Update assertions: AudiobookRich now expects both +faststart and use_metadata_tags. Basic gets +faststart only (no historical drift — Basic was no-flags before, but +faststart is now upstream-mandatory). go test ./...: green.
7 findings, all addressed.
P1 — firstboot path semantics inversion:
internal/library/firstboot_destinations.go
plex_section_path is the SERVER-SIDE library path used by
PlexBackend.resolveScanPath as the translation TARGET. Synthesizing it
into LibraryDestination.AudiobookPath (the audplexus-side SOURCE)
inverted the semantics — fresh upgrades from v0.2.x would have shown
the server-side path in the "Audplexus-side audiobook path" Settings
field. Same inversion for emby_library_path. Both now persist to
DestinationPath; AudiobookPath stays empty so the global libraryDir
is the source. Tests updated to assert correct field placement.
P2 — server-side validation on create:
internal/web/destinations_handlers.go
HTML `required` is the only gate on plex_token / api_key on the
create path. A bypassing client posting empty strings would land NULL
via nullableStr → DB CHECK violation → 500. Now: explicit
empty-string check on create returns a user-facing 400 with reason.
Edit path unchanged (caller carries saved secret over).
P2 — plex resolveScanPath short-circuit:
internal/mediaserver/plex.go
resolveScanPath was called before LocalPath empty check. With empty
path, filepath.Dir("") returns "." and triggers an unnecessary Plex
section-path GET + cache write, only to fail. Re-ordered: empty
LocalPath fails immediately with no side effects.
P3 — go.mod indirect marker:
go.mod
golang.org/x/sync is a direct import (DestinationManager uses
semaphore). go mod tidy moved it to the direct requirements block.
P3 — DB CHECK constraint hardening:
internal/database/migrations{,_postgres}/005_library_destinations.up.sql
Original IS NOT NULL passes empty strings via direct SQL.
length(trim(coalesce(col,''))) > 0 rejects both NULL and empty.
The coalesce is load-bearing: SQL CHECK passes on NULL, and
length(trim(NULL)) is NULL — so without coalesce, NULL columns
would silently insert. New test
TestCreateRejectsWhitespaceOnlyValues guards against future drift
(e.g. someone replacing nullableStr with passthrough).
go test ./... green. PR mstrhakr#4 merge resolution and these fixes will
ship as a single push to origin/feat/multi-destination → PR mstrhakr#6
auto-updates.
|
Pushed two commits addressing both threads:
If anything else stands out from your read, happy to iterate. |
|
Thanks for your work on this. |
When you merged PR #1 you said:
I took the bait. This PR adds Jellyfin support — and while I was extending the
Backendabstraction, I found that picking one media server at a time wasn't quite the right primitive for me... I'm weird and have... a few, so the abstraction became multi-destination: Audplexus can now fan out to N media servers concurrently. Plex + Emby + Jellyfin + Audiobookshelf are all first-class, and a single install can push every download to all of them at once.Plex behavior is unchanged for existing users. The path from a v0.2.x Plex install to this branch is invisible: legacy env vars and settings synthesize a Plex destination row at first boot. I worked hard on that — see "Backwards compatibility" below.
Motivation
Two things drove this beyond just "add Jellyfin":
Real households fan out. Mine uses Emby and Audiobookshelf (the offline app is the best). Both point at the same
/audiobooksmount. With v0.2.x I was picking a winner per environment and manually pushing scans to the others. The Backend interface was 80% of the way to fixing that, making it 100% just meant teaching the pipeline to iterate enabled destinations instead of dispatching to one.Closes [Feature Request] JellyFin integration #5. A Jellyfin user filed it the day you merged PR Add Emby support via a media-server backend abstraction (Plex unchanged) #1. I confirmed Jellyfin isn't a literal copy of Emby. It diverges auth header, item kind, image MIME.
Audiobookshelf came in as a bonus once the multi-destination shape was right. ABS doesn't fit "media server" perfectly (no streaming clients, audiobook-first), but it does fit "library destination" perfectly, which is the noun I set the new schema and UI use (All yours to change up!)
Approach
The
Backendinterface from PR #1 stayed the central abstraction. Three additions on top:The biggest change is
OnBookOrganizedreplacingTriggerScanForBook+EnsureBookInSeriesCollection+TagItem. Those three were fire-and-forget in PR #1 — they spawned goroutines and logged errors. That worked when there was one backend and "did the scan trigger succeed?" wasn't observable, but multi-destination forced the issue: the pipeline needs typed outcomes per destination per operation so it can record state inbook_library_destinations. The new contract returns[]Outcome, each tagged withOperation(scan_trigger / item_match / series_grouping / franchise_tag / image_upload / ...) andStatus(succeeded / skipped_existing / unsupported / failed / deferred / skipped_not_configured). Backends never silently no-op — Plex's oldTagItemquietly returning was the central case I wanted to kill.Capabilities are advisory: each backend declares what it supports (Plex doesn't have
CapFranchiseTag; ABS doesn't haveCapBoxSetCovers). The UI uses these to hide controls that don't apply per-destination. Runtime contract is still the typed Outcome — a missing capability never reaches a no-op.The fan-out itself is bounded:
One destination's failure doesn't affect the others. Per-destination 2-minute timeout. Pipeline blocks until every destination returns, then marks the download complete — which fixes a subtle bug the old code had: PR #1's
handleProcessStagesetDownloadStatusCompletebefore the fire-and-forget media-server work even started. With OnBookOrganized synchronous, "complete" finally means delivered.What changed
Multi-destination schema (
005_library_destinations)Two new tables. Both sqlite + postgres migrations.
library_destinations— one row per configured destination. Multiple of the sametypeallowed.Per-type config columns rather than a JSON blob. CHECK constraint enforces required fields per type.
api_keyandplex_tokenare sensitive —json:"-"and a customStringerthat prints<redacted>sofmt.Printf("%v", row)can't accidentally leak them via reflection.book_library_destinations— per-(book, destination) state.The state machine has
pending | syncing | synced | failed | orphaned | removed_from_destination. Failed is the only non-terminal state — the rest never auto-retry.per_op_outcomesis JSON because operations are an open set per backend (a future Plex-only operation doesn't force a schema migration on Emby/Jellyfin/ABS).Backends
Plex — verbatim from PR #1, now bound to a
LibraryDestinationrow viaWithDestination. Per-destination scan paths honored: if the row setsaudiobook_path+destination_path, those win over the global library dir + cachedplex_section_path. Means two Plex destinations on the same Audplexus can route to different mounts.Emby — same shape as PR #1 with the same
WithDestinationextension. BoxSet collection logic, franchise tag write, author images all preserved.Jellyfin — new. Mostly mirrors EmbyBackend but with three real differences (the gotchas that took a while to find):
X-Emby-Tokenbehind aEnableLegacyAuthorizationconfig flag (defaulttrue), but PR #13306 added the kill switch and 12.0 will remove it. New code usesAuthorization: MediaBrowser Token="...", Client="Audplexus", Device="...", DeviceId="...", Version="1.0"from day one.BaseItemKind.AudioBook. Emby returns books as bothAudioandMusicAlbum— your existing reconcile filters toMusicAlbumto dedupe. Jellyfin's filter isIncludeItemTypes=AudioBook. The Emby filter returns zero rows on Jellyfin.ImageControllervalidates Content-Type strictly:image/jpeg, notimage/jpg, notimage/*. Emby tolerates loose types. JellyfinBackend builds concrete MIME from the source URL extension.Audiobookshelf — new. Different in shape from the other three because ABS is metadata-driven rather than collection-driven:
Authorization: Bearer <api_key>). Admin-scope key required for scans (post-v2.26.0 JWT rewrite, 2025-07).POST /api/libraries/{id}/scanfor library refresh.GET /api/libraries/{id}/search?q=<ASIN>— no native ASIN filter, so we verifymedia.metadata.asinclient-side.seriesandseries-partfreeform iTunes atoms, which ABS reads via ffprobe. Falls back toPATCH /api/items/{id}/mediawithmetadata.series=[{name,sequence}]if tags are absent.Audiobook-rich tag profile
Optional Settings choice. Default stays Basic (your historical tag set, byte-for-byte unchanged for existing libraries). Audiobook-rich adds three freeform iTunes atoms via existing
ffmpeg -metadata:series(book.Series)series-part(book.SeriesPosition)asin(book.ASIN)The album field stays at
book.Title— your Plex album-collapse workaround is preserved. Audiobookshelf reads these atoms natively for series auto-grouping; Plex/Emby/Jellyfin ignore them harmlessly.There's one subtle empirical detail I learned the hard way: ffmpeg's mp4 muxer drops unknown
-metadatakeys unless-movflags use_metadata_tagsis also set. Without that flag, the freeform atoms vanish silently — the ffmpeg output looks fine, the file looks fine, but ffprobe shows nothing. The flag flips output to QuickTimemdtametadata format which preserves the keys. EmbedMetadata + buildDecryptArgs both set the flag when the profile is Audiobook-rich. There's a unit test guarding it on both paths so we don't regress.Settings UI
The "Active Media Server" radio + the per-type Plex/Emby panels are gone. Library Destinations CRUD replaces them: list of destination cards, each with an Edit / Toggle / Delete action set, status badge (Healthy / Failed / Never checked / Not configured), and a "Test Connection" button. Add-destination is a two-page server-rendered wizard (type picker → type-specific config form). No JS framework — vanilla forms + HTMX for the test-connection result swap.
Per-field instructions live in
<details>collapsibles next to each input. Plex token: walks through "Get Info → View XML → copy ?X-Plex-Token=" + links to Plex's official guide. Emby/Jellyfin/ABS API keys + library IDs: each has its own walk-through + thecurl | jqone-liner for the API alternative.a11y review notes (an a11y-lead consult I ran during the work):
<ul role="list">because Safari/VoiceOver strips list semantics underlist-style:none. Each card has its own<h4 id="dest-{id}-name">so action buttons get unique accessible names viaaria-labelledby.role="status" aria-live="polite" aria-atomic="true". Toggle button label flips ("Enable" ↔ "Disable") rather than usingaria-pressed(the dual conveyance creates confusing announcements).confirm=1actually deletes.Dashboard
Single "Emby Connected / Library Items / Coverage" stat-cards replaced with a per-destination summary section: one mini-card per configured destination with display name, type badge, status badge, item count, coverage %, and "Last checked: 4 min ago" via
<time datetime="...">.aria-live="polite"on the list witharia-live="off"on the numeric cells, so SR users get health flips ("Plex destination failed") but not 12s count chatter.Backwards compatibility
This is the part I worked hardest on (again). The codex pre-PR review caught a P1 here that I want to call out explicitly — see "Pre-PR review pass" below.
MEDIA_SERVERormedia_server_typebecause Plex was the silent default in your v0.2.x. First-boot synthesis now infers Plex when those are unset butplex_url+plex_token+plex_section_idare all present. Same for Emby. Result: no manual steps required on upgrade.MEDIA_SERVERenv var still works as a first-boot synthesis hint. Once any destination exists, it's logged-and-ignored. Removed entirely in v0.5.OnBookOrganizedfalls through to the legacy path. So even if synthesis fails for any reason, downloads still work.books.media_server_id/media_server_titlecolumns stay populated byUpdateBookMediaServerInfoduring this minor version. A future006drops them after one release of dual-write soak./auth/marketplace,/auth/start,/auth/callback,/auth/plex/*,/api/sync/*,/api/downloads/*. The only routes I removed are/auth/emby/configure,/auth/emby/scan, and/auth/media-server/select— those posted to the now-removed UI panels and have no other callers. Their handlers + theinternal/web/emby_settings.gofile are gone.Testing
go test ./...passes. New tests cover: typed Outcome contract,WithDestinationrow binding (both with-row and without-row paths), first-boot synthesis (every type + the inferred-Plex P1 case), tag profile parsing + ffmpeg muxer flag (bothembedArgsandbuildDecryptArgs), schema CHECK constraints, FK CASCADE, secret redaction in fmt formatters,summarizeOutcomesStatetable-driven,franchiseFromSeriestable-driven,normalizeTitletable-driven./audiobooksmount, and an Audiobookshelf 2.34.0 instance. Single book delete + Full Sync + Queue All New round-trip: download → decrypt → organize → fan-out completes in ~16 seconds, all three destinations reportsync_state=syncedinbook_library_destinations, the freeformasinatom shows up in the m4b's ffprobe output, andcmd/abstestandcmd/jellytest(small live-verification programs incmd/) confirm the auth + scan + ASIN-search round-trips against real servers.Pre-PR review pass
I ran a codex review against this branch before opening the PR. It caught three real things, all fixed before this PR went up:
firstboot_destinations.go— the original synthesis returned empty for unknownMEDIA_SERVER, which would have stranded existing v0.2.x Plex installs (the case described in "Backwards compatibility"). Fixed via inference from per-type settings/env presence.buildDecryptArgswas missing theuse_metadata_tagsmuxer flag. The actual download path goes throughDecryptAAX(C)WithMetadata→buildDecryptArgs, notEmbedMetadata→embedArgs. My PR-A test only exercised the latter, so the freeform atoms were silently dropped during real downloads while the unit test passed. Flag added; regression test on both paths.audiobook_path/destination_pathwere saved by the form but ignored at scan time — backends still resolved from the globallibraryDir+ cachedplex_section_path. Now honored viadestinationfield on each backend struct.Open questions for you
A handful of judgment calls I'd love your read on, in priority order:
Backend— I didn't rename code. If you'd rather stay with "media server" everywhere, I can revert; the schema columns are easy to alias.typeif you'd rather hold it to one-per-type for now.MEDIA_SERVERenv var deprecation timeline. I have it as: respected for first-boot synthesis, logged-and-ignored thereafter, removed in v0.5. If you'd rather keep it as a permanent headless-config bootstrap, easy.cmd/abstestandcmd/jellyteststandalone Go programs are ~80 lines each. They're live-server integration test recipes — anyone with a real Audiobookshelf or Jellyfin can runABS_URL=... ABS_API_KEY=... ABS_LIBRARY_ID=... ./abstestto verify their config end-to-end. They share aninternal/database/StubDBso the dedupe is reasonable. Happy to drop them if you'd rather; they're not load-bearing.docs/destinations.mdis a 1-page architecture overview I wrote for myself while building. Probably useful for future contributors. Happy to drop or relocate.Happy to revise anything — just let me know what you'd like changed. Thanks again for the original groundwork. The Backend interface in PR #1 was the right shape; the multi-destination version mostly just exercised it harder.