Add Emby support via a media-server backend abstraction (Plex unchanged)#1
Merged
Merged
Conversation
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.
There was a problem hiding this comment.
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/mediaserverwithBackendinterface 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.
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! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/mediaserverpackage with aBackendinterface:PlexBackendis the existinginternal/library/plex_*.gocode lifted verbatim into the new package — same endpoints, same headers, same retry behavior.EmbyBackendis new and uses Emby's REST API for the same operations. TheDownloadManagercalls into whichever backend is wired in.Selection happens via a new
MEDIA_SERVERenv var (or via the Settings UI). It defaults toplex, 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/)Backendinterface +Resolve/Newfactoryplex.go— Plex implementation (extracted frominternal/library/plex_*.go)emby.go— Emby implementation (new)path.go— shared local-to-server path translation, with table-driven testsPipeline wiring
DownloadManagertakes amediaserver.Backendin its constructorpipeline_stages.gocallsdm.mediaServer.TriggerScanForBookanddm.mediaServer.EnsureBookInSeriesCollectionafter each book is organizedcmd/server/main.goresolves the active backend at startup and threads it throughDatabase
004_add_media_server_fieldsaddsmedia_server_idandmedia_server_titletobooks, backfilled from the existingplex_rating_key/plex_titleso reconciled Plex state is preservedUpdateBookMediaServerInfoadded to theDatabaseinterface;UpdateBookPlexInfokeeps writing to both columns for compatibilityWeb UI
POST /auth/emby/configure,POST /auth/emby/scan,POST /auth/media-server/selectmedia_server_idpopulated 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.Bonus
pipeline_stages.gonow writes afolder.jpgsidecar 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.internal/audio/tag.go's metadata builder:ALBUMARTISTfor the author (Emby's primary author-detection field) andCOMPOSERfor 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_SERVERdefaults toplexif unset — every existing install behaves identically.plex_rating_key/plex_titlecolumns are kept and still populated byUpdateBookPlexInfoso anything reading them keeps working.waitForPlexScanCompletion,plexGetActivities, etc.) is preserved — when Plex is the active backend, the sync callback uses that flow rather than the simpler interface method..plexmatchfiles keep being written for Plex; the help text now notes they're Plex-specific but harmless under Emby (the file is ignored).Testing
go test ./...passes; new table-driven tests cover the path-translation helper.Open questions for you
A few things I made judgment calls on that I'd love your read on:
PhasePlexSyncis 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./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.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.