Skip to content

Add Emby support via a media-server backend abstraction (Plex unchanged)#1

Merged
mstrhakr merged 13 commits into
mstrhakr:masterfrom
jesposito:feat/emby-backend
May 9, 2026
Merged

Add Emby support via a media-server backend abstraction (Plex unchanged)#1
mstrhakr merged 13 commits into
mstrhakr:masterfrom
jesposito:feat/emby-backend

Conversation

@jesposito

@jesposito jesposito commented May 9, 2026

Copy link
Copy Markdown

First off, thank you for Audplexus. I've been running it for a while pointed at an Emby library (the file output is already great for Emby's audiobook scanner) and the only piece missing for me was the post-download integration: triggering a scan and managing series collections. This PR adds that for Emby while leaving Plex behavior identical.

Motivation

I run Emby for my media library and would love to use Audplexus end-to-end without manually triggering library scans or building collections by hand. The Plex pieces in the codebase do exactly the right work — I wanted to give Emby users the same experience without disturbing yours.

Approach

Rather than sprinkle Emby branches through the pipeline, I extracted the media-server side of things into a small internal/mediaserver package with a Backend interface:

type Backend interface {
    Name() string
    Configured(ctx context.Context) bool
    TriggerScanForBook(finalPath string)
    EnsureBookInSeriesCollection(series, bookTitle string)
    ReconcileLibrary(ctx context.Context, progressFn func(current, total int)) error
    LibraryItemCount(ctx context.Context) (int, error)
    TriggerLibraryScan(ctx context.Context) (int, error)
}

PlexBackend is the existing internal/library/plex_*.go code lifted verbatim into the new package — same endpoints, same headers, same retry behavior. EmbyBackend is new and uses Emby's REST API for the same operations. The DownloadManager calls into whichever backend is wired in.

Selection happens via a new MEDIA_SERVER env var (or via the Settings UI). It defaults to plex, so existing installs see no behavior change. Both backends' settings persist in the DB, so a user can flip between them without losing the other's configuration.

What changed

Backend abstraction (internal/mediaserver/)

  • Backend interface + Resolve / New factory
  • plex.go — Plex implementation (extracted from internal/library/plex_*.go)
  • emby.go — Emby implementation (new)
  • path.go — shared local-to-server path translation, with table-driven tests

Pipeline wiring

  • DownloadManager takes a mediaserver.Backend in its constructor
  • pipeline_stages.go calls dm.mediaServer.TriggerScanForBook and dm.mediaServer.EnsureBookInSeriesCollection after each book is organized
  • cmd/server/main.go resolves the active backend at startup and threads it through

Database

  • Migration 004_add_media_server_fields adds media_server_id and media_server_title to books, backfilled from the existing plex_rating_key / plex_title so reconciled Plex state is preserved
  • UpdateBookMediaServerInfo added to the Database interface; UpdateBookPlexInfo keeps writing to both columns for compatibility
  • Both SQLite and Postgres migrations included

Web UI

  • New always-visible "Media Server" panel under Settings with a backend selector (Plex / Emby)
  • Plex panel renders only when Plex is the active backend; Emby panel only when Emby is. Both keep their settings in the DB across switches.
  • New routes: POST /auth/emby/configure, POST /auth/emby/scan, POST /auth/media-server/select
  • Dashboard stats and brand subtitle pull the active backend's display name ("Plex Connected" / "Emby Library Items" / "Audible to {Backend}")
  • Diagnostics page now derives "in library" status from the per-book media_server_id populated by reconcile, so it works for any backend without re-querying the server on every request. Plex's live section query stays as a fallback for pre-reconcile state.
  • Sync phase label "Plex Sync" → "Library Scan"

Bonus

  • pipeline_stages.go now writes a folder.jpg sidecar alongside each organized book. This is Emby's preferred cover filename (priority order: folder.jpg > poster.jpg > cover.jpg) and Plex reads it too, so both servers get a sidecar image without having to extract the embedded m4b artwork on first scan. Best-effort — failure logs at debug and doesn't fail the pipeline. The existing embedded ID3 cover art is untouched.
  • The Emby team's recommended ID3 tags are already written by internal/audio/tag.go's metadata builder: ALBUMARTIST for the author (Emby's primary author-detection field) and COMPOSER for the narrator (the convention Emby's team confirmed in their forum). Calling this out so it's clear these were already correct, not changed.

Backwards compatibility

This is the part I worked hardest on. Specifically:

  • MEDIA_SERVER defaults to plex if unset — every existing install behaves identically.
  • The legacy plex_rating_key / plex_title columns are kept and still populated by UpdateBookPlexInfo so anything reading them keeps working.
  • Plex's existing scan-and-wait completion polling (waitForPlexScanCompletion, plexGetActivities, etc.) is preserved — when Plex is the active backend, the sync callback uses that flow rather than the simpler interface method.
  • .plexmatch files keep being written for Plex; the help text now notes they're Plex-specific but harmless under Emby (the file is ignored).
  • All existing routes, env vars, and DB columns are unchanged.

Testing

  • go test ./... passes; new table-driven tests cover the path-translation helper.
  • I ran the full image against a live Emby 4.x server with a 340-book library: migration ran cleanly, the reconcile matched 337 books, 109 series BoxSet collections were created, and per-download triggers produced the expected log line and Emby-side refresh.
  • Plex flow was not retested live (I don't have a Plex install handy) — happy to add a Plex-side smoke test if you'd like, or revise based on anything you spot.

Open questions for you

A few things I made judgment calls on that I'd love your read on:

  1. Plex sync phase label: I changed the user-visible label from "Plex Sync" to "Library Scan" so it reads naturally for both backends. The internal phase identifier PhasePlexSync is unchanged so URLs/JS keep working. If you'd rather keep the historical label for Plex users, I can swap it back behind a backend check.
  2. Diagnostics endpoint /api/diagnostics/plex-items — currently unused by the UI after my changes. I left it in place for any external consumers, but happy to remove it if you prefer.
  3. Settings panel layout: I made the Plex/Emby panels mutually exclusive based on the active backend, with the type selector always visible. An alternative is to show both panels at once and tag them with status badges. Easy to switch if you have a preference.

Happy to revise anything — just let me know what you'd like changed. And thanks again for building this; it's been a great quality-of-life upgrade for managing my Audible library.

Jed Esposito added 7 commits May 9, 2026 17:14
…ction

Introduces an `internal/mediaserver` package with a `Backend` interface
implemented by `PlexBackend` (extracted from `internal/library/plex_*.go`)
and a new `EmbyBackend`. The download pipeline now calls into the active
backend after each book finishes; both backends trigger library scans and
manage series collections (Plex collections / Emby BoxSets).

Backend selection is via the `MEDIA_SERVER` env var or the new "Media Server"
panel under Settings. Plex remains the default — existing installs see no
behavior change. Both backends' settings persist in the DB so switching back
is non-destructive.

DB migration 004 adds `media_server_id` / `media_server_title` columns and
backfills them from the existing `plex_rating_key` / `plex_title` so Plex
users keep their reconciled state. The legacy columns stay populated for
backwards compatibility.

UI changes:
- Settings: always-visible "Media Server" type selector; Plex and Emby
  panels render conditionally on the active backend.
- Dashboard stat cards and brand subtitle pull the active backend's label.
- Diagnostics now derives "in library" from the per-book `media_server_id`
  column written during reconcile, so it works for any backend without
  requerying the server on each request.
- Sync phase label "Plex Sync" -> "Library Scan".

Tested end-to-end against a live Emby 4.x server: 109 BoxSet collections
created from 231 series-tagged books, 337/340 books matched and tagged.
Emby's documented cover priority is `folder.jpg > poster.jpg > cover.jpg`,
and Plex also reads `folder.jpg`. After organizing each book, copy the
already-downloaded cover from the temp dir into the book folder as
`folder.jpg` so both servers get a sidecar image without having to extract
the embedded m4b artwork on first scan.

Best-effort: a copy failure logs at debug level and does not fail the
pipeline. Existing embedded ID3 cover art is unaffected.

The sister recommendations from Emby's audiobook conventions
(`ALBUMARTIST`=author for grouping, `COMPOSER`=narrator for narrator
attribution) are already written by `internal/audio/tag.go`'s metadata
builder.
Emby returns each audiobook as both an `Audio` (the m4b file) and a
`MusicAlbum` (the album wrapper) item under an audiobook library, so the
combined `Audio,AudioBook,MusicAlbum` filter we were using counted every
book twice — producing dashboard coverage above 100% (observed 201% on a
340-book library where Emby reported 684 items).

Using `MusicAlbum` alone gives one record per book and matches what users
see in Emby's library UI. Reconcile is also tighter as a result: each
local book maps to exactly one server-side ID instead of being overwritten
between two representations.

Also caps dashboard coverage at 100% defensively — a media server can
reasonably report more items than we have books on disk (manual additions,
alternate formats), and the dashboard should read sensibly when that
happens.
…n title matching

Real-world reconcile produced false "missing from library" results for
books where the title source-of-truth in Audplexus' DB and the title
returned by the media server differed in cosmetic ways:

  - "Sense & Respond" (DB, HTML-encoded) vs "Sense & Respond" (server)
  - "Wise Woman" (DB, article stripped) vs "The Wise Woman" (server)
  - "Jonathan Strange & Mr Norrell" (DB) vs the same with `&` (server)
  - Smart quotes vs straight quotes from Audible's metadata.

Replaces the per-backend ad-hoc comparisons with a shared
`normalizeTitle` helper in the mediaserver package that:

  - HTML-decodes entities
  - Lowercases
  - Normalizes smart quotes/dashes to ASCII
  - Treats "&" and "and" as equivalent
  - Strips a single leading article ("the", "a", "an")
  - Drops cosmetic punctuation
  - Collapses whitespace

Used by both the Plex and Emby reconcile pass and their per-book
"find item by title" search. Backed by a table-driven test covering each
of the wild-data cases above.
Audplexus already creates a BoxSet collection per series, but BoxSets live
under Emby's separate "Collections" menu and are easy to miss when the user
is browsing the audiobook library directly. This adds a per-item tag write
so series (and franchise, when one is implied by the naming convention)
appear as a filter facet inside the audiobook library view itself.

Behavior:

  - When a book is added to its series collection (both the post-download
    flow and the periodic reconcile), Audplexus also tags the book with
    `[series, franchise]`.
  - "Franchise" is extracted from a series name like
    "Star Wars: The Ascendancy Trilogy" or "Star Wars (Zahn)" — the part
    before the colon/parenthesis when it differs from the full series and
    looks like a real label (multi-word, 3+ chars). Single-word series
    like "Bobiverse" do not double as franchises.
  - Tags are written via Emby's standard item-update endpoint
    (`POST /Items/{id}` with TagItems) and the `Tags` field is added to
    `LockedFields` so a metadata refresh doesn't strip them.
  - Plex backend is a no-op: Plex's collections already cover the same
    grouping need and Plex has no comparable per-item tag concept.

Adds a `TagItem(ctx, serverItemID, tags)` method on the Backend interface,
plus `franchiseFromSeries` with table-driven tests covering the Star Wars,
Discworld, Bobiverse, and parenthesis-suffix patterns.
…om Audnexus

Empty placeholder squares for collections and author cards in Emby —
this fills them in.

For each series collection Audplexus creates, the seed book's cover URL
is uploaded as the BoxSet's primary image, so the Collections grid shows
real artwork instead of an "X items" tile. Skips when an image already
exists, so re-running reconcile is a no-op.

For each unique author in the local library (book.AuthorASIN populated
from Audible's metadata), Audnexus' /authors/{asin} endpoint is called
for an image URL, which is then uploaded to the matching Emby
MusicArtist. Author matching tolerates the same cosmetic differences
(case, smart quotes, leading "The") as the title matcher.

Both uploads use Emby's standard `POST /Items/{id}/Images/Primary`
with base64 body and the source Content-Type. Best-effort: failures log
at debug and don't fail reconcile.

Threads the audnexus client into the Emby backend constructor (Plex
backend ignores it). The signature change ripples to `mediaserver.New`
and its two callers (cmd/server/main.go, web/emby_settings.go).
… display

Two title-display issues observed in real libraries:

  1. Some books arrive from Audible's API with literal HTML entities in
     their title (e.g. "Sense & Respond", "The Children of Húrin")
     and are persisted that way. Reconcile already tolerates this for
     matching, but the UI shows the raw entity.

  2. When a book row is removed (sync prune, manual delete) but its
     historical download_queue entry survives, the downloads UI falls back
     to displaying the bare ASIN, which reads as gibberish.

Fixes:

  - `convertBook` runs every textual field through `cleanText`
    (`html.UnescapeString` + trim) so future syncs persist clean Unicode.
  - New `library.CleanupTextFields` pass at startup decodes entities in
    existing book rows. Idempotent: rows that are already clean are
    skipped without writes.
  - `getDownloadTitles` now returns "Removed: <ASIN>" for queue rows whose
    book row has been deleted, so the column reads as something deliberate
    rather than as a missing label.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a media-server backend abstraction to add full Emby support (scan triggers + series/collection management) while preserving existing Plex behavior as the default. It wires the new backend into the download pipeline, extends the DB schema to store per-book media-server IDs/titles, and updates the web UI to configure/select the active backend.

Changes:

  • Added internal/mediaserver with Backend interface plus Plex (extracted) and new Emby implementations (incl. path translation + matching helpers + tests).
  • Wired the download pipeline + sync/reconcile flows to call the selected backend, and updated UI/settings/routes to configure/select Plex vs Emby.
  • Added DB fields (media_server_id, media_server_title) with migrations and updated DB implementations; also added a startup cleanup pass to decode HTML entities in stored text fields.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
README.md Documents Emby support and new MEDIA_SERVER/Emby env vars.
internal/web/templates/settings.html Adds Media Server selector and Emby configuration panel; updates copy text to be backend-agnostic.
internal/web/templates/diagnostics.html Makes diagnostics wording backend-agnostic and updates labels.
internal/web/templates/dashboard.html Updates full-sync tooltip to be backend-agnostic.
internal/web/templates/dashboard_summary.html Replaces Plex-specific dashboard stats with backend-agnostic equivalents.
internal/web/templates/base.html Updates branding/title and makes subtitle backend-aware.
internal/web/server.go Routes + dashboard + diagnostics updated for backend abstraction; Plex callback dispatch preserved.
internal/web/emby_settings.go Adds handlers for Emby configure/scan and backend selection.
internal/mediaserver/mediaserver.go Defines backend interface, type resolution, and factory.
internal/mediaserver/plex.go Plex backend implementation extracted into mediaserver package.
internal/mediaserver/emby.go New Emby backend implementation (scan, reconcile, collections, tags, images).
internal/mediaserver/path.go Shared local→server path translation helper.
internal/mediaserver/path_test.go Table-driven tests for path translation.
internal/mediaserver/match.go Title normalization helper for cross-source matching.
internal/mediaserver/match_test.go Tests for title normalization.
internal/mediaserver/franchise.go Franchise extraction helper for tag enrichment.
internal/mediaserver/franchise_test.go Tests for franchise extraction.
internal/library/pipeline_stages.go Adds folder.jpg sidecar write and replaces Plex calls with backend calls.
internal/library/download.go DownloadManager now accepts/exposes a mediaserver.Backend.
internal/library/sync.go Adds HTML-entity decoding helper and uses it when converting Audible metadata; sync label updated.
internal/library/title_cleanup.go Adds startup backfill pass to decode HTML entities in existing DB rows.
internal/library/library_reconcile_test.go Updates mock DB to satisfy new DB interface.
internal/library/plex_scan.go Removed (Plex logic moved into mediaserver backend).
internal/library/plex_reconcile.go Removed (Plex logic moved into mediaserver backend).
internal/library/plex_collections.go Removed (Plex logic moved into mediaserver backend).
internal/database/interface.go Adds UpdateBookMediaServerInfo to DB interface.
internal/database/models.go Adds media_server_id/media_server_title to Book model.
internal/database/sqlite.go Reads/writes new media-server columns; UpdateBookPlexInfo populates both legacy + new columns.
internal/database/postgres.go Reads/writes new media-server columns; UpdateBookPlexInfo populates both legacy + new columns.
internal/database/migrations/004_add_media_server_fields.up.sql SQLite migration adds and backfills new columns.
internal/database/migrations/004_add_media_server_fields.down.sql SQLite down migration drops new columns.
internal/database/migrations_postgres/004_add_media_server_fields.up.sql Postgres migration adds and backfills new columns.
internal/database/migrations_postgres/004_add_media_server_fields.down.sql Postgres down migration drops new columns.
cmd/server/main.go Resolves backend at startup, injects into DownloadManager, seeds Emby env settings, runs cleanup pass.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md
Comment thread internal/web/templates/settings.html
Comment thread internal/library/pipeline_stages.go
Comment thread internal/web/server.go
Comment thread internal/web/server.go
Comment thread internal/mediaserver/emby.go Outdated
Comment thread internal/mediaserver/emby.go
Comment thread internal/mediaserver/emby.go
@mstrhakr

mstrhakr commented May 9, 2026

Copy link
Copy Markdown
Owner

I love this. I'm happy to bring support to Emby users and later we can now add Jellyfin support thanks to your groundwork. Thanks for your contribution!

@mstrhakr
mstrhakr merged commit 821af9e into mstrhakr:master May 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants