Skip to content

Restore Plex sign-in + library auto-populate for all 4 destination backends#7

Merged
mstrhakr merged 10 commits into
mstrhakr:masterfrom
Wutname1:feat/destination-discovery
May 11, 2026
Merged

Restore Plex sign-in + library auto-populate for all 4 destination backends#7
mstrhakr merged 10 commits into
mstrhakr:masterfrom
Wutname1:feat/destination-discovery

Conversation

@Wutname1

@Wutname1 Wutname1 commented May 11, 2026

Copy link
Copy Markdown
Contributor

The multi-destination refactor (3fa9ebe) → (4c5c8ee) dropped the Plex.tv PIN sign-in, the Plex server picker, and the Plex section picker — and Emby/Jellyfin never got the auto-populate treatment that ABS got in (96eda9b). Users were left hand-crafting curl commands and pasting UUIDs the app already knows how to fetch.

This PR brings discovery back, on the per-destination form, for every backend.

What changes

  • Plex — "Sign in with Plex" (plex.tv PIN flow in a popup that polls every 2s and autofills the token), "Discover servers" (plex.tv /api/v2/resources), "Discover sections" (/library/sections on the chosen server).
  • Emby — "Discover libraries" via /emby/Library/MediaFolders, filtered to CollectionType=audiobooks with a fallback to all libraries when none match.
  • Jellyfin — "Discover libraries" via /Library/VirtualFolders using the canonical Authorization: MediaBrowser Token="…" header (not the deprecated X-Emby-Token).
  • ABS — already shipped in (96eda9b); this PR fixes the broken edit-form path (button always posted to the create-only route, never the :id variant) so the saved api_key carries over correctly.

Manual paste still works on every backend — the discovery buttons sit alongside the existing inputs, and the "How to find your token / section ID / library ID" details blocks remain for users who already have credentials in hand.

Display name disambiguation

Adding two destinations of the same type used to give you two cards both labeled "Plex". Now:

  • The Plex server picker autofills display_name from the discovered server name (e.g. "Living Room Plex") when the field is empty — typical sign-in flow never hits the fallback.
  • Server-side uniqueDisplayName() appends (2), (3), … on Create and Update when another row already has the same name (case-insensitive). Renaming a destination back to its current value is a no-op via excludeID.

Cleanup

The 7 unreachable /auth/plex/* routes and ~140 lines of orphaned JS in settings.html are removed. plex_auth.go is renamed to plex_client.go — what survives is just the HTTP client for plex.tv + a Plex server, still used by reconcile, diagnostics, and the dashboard.

Commits

  1. [refactor(plex)](Wutname1@427a72d) — rename plex_auth.goplex_client.go, drop dead /auth/plex/* handlers and orphaned JS.
  2. [feat(plex)](Wutname1@fa6159f) — sign-in + server/section discovery on the destination form.
  3. [feat(emby)](Wutname1@5fa53b2) — library discovery picker. Introduces a generic mediaServerLibrary{ID,Name,Kind,Path} shape + renderEmbyLikeDiscover helper.
  4. [feat(jellyfin)](Wutname1@2f29a0d) — library discovery picker; reuses the Emby render helper. Also corrects the deprecated X-Emby-Token help text to use Authorization: MediaBrowser.
  5. [feat(destinations)](Wutname1@561ea22) — display-name disambiguation (Plex server-picker autofill + server-side auto-numbering).
  6. [fix(destinations)](Wutname1@3e518bb) — code-review findings: fix ABS edit-form Discover button, add Cache-Control: no-store to PIN/discover responses, reject user:pass@host URLs, clean up over-zealous suffix stripping, drop an unused parameter.

Implementation notes

  • The PIN poller is HTMX-driven: each 2s poll re-renders itself as another polling div (HTMX swap=outerHTML); on plex.tv approval the response is a small <script> that fills #plex_token and clicks the Discover servers button to chain into URL discovery.
  • The PIN-success response inlines the auth token verbatim, so all token-bearing and discovery responses go through a writeSensitiveHTML helper that sets Cache-Control: no-store + Pragma: no-cache — defense against any cache (CDN, reverse proxy, browser bfcache) storing tokens minted for one user and serving them to another.
  • jsString uses Go's json.Marshal for safe JS-literal embedding; SetEscapeHTML(true) (the default) handles <>& so a token containing </script> becomes \u003c/script\u003e and cannot close the script tag.
  • All discover-result fragments render with role="status" aria-live="polite" for SR parity. The Plex sign-in popup opens via target="_blank" rel="noopener noreferrer".
  • Emby/Jellyfin pickers filter to the audiobook collection type but fall back to showing all libraries (with a warning) when none match — a misconfigured server shouldn't strand the user. The Plex section picker shows all sections (not filtered to type=artist) for the same reason.
  • Saved-row carry-over for sensitive fields on the edit path: resolvePlexTokenFromForm / resolveAPIKeyFromForm mirror the existing destinationForTest pattern so Discover works without re-pasting the token.
  • validateRemoteURL is reused across every new discover endpoint — http(s) schemes only, non-empty host, no user:pass@ userinfo. RFC1918 / loopback is explicitly allowed (LAN media servers are the use case).

Wutname1 added 10 commits May 10, 2026 23:34
Replaces the paste-the-UUID UX with a button. The destination form's
ABS arm gets a "Discover libraries" button next to the Library ID input;
clicking it POSTs the URL + token to /destinations/discover/abs, which
calls GET /api/libraries and returns an HTML fragment with a <select>.
Picking an entry fills the existing library_id input via an inline
onchange handler.

The existing curl/UI manual instructions stay below the button as a
<details>, so the documented fallback path keeps working.

Adds an exported mediaserver.ListLibraries(ctx, url, token) helper
that handles both the {libraries:[...]} (older ABS) and bare-array
response shapes via a first-byte sniff, and treats an empty body as
an explicit error rather than letting the JSON decoder surface
"unexpected end of JSON input".

The discover handler validates the URL up front: scheme must be http
or https, host must be non-empty. RFC1918 / loopback addresses are
intentionally NOT blocked — Audplexus is built to talk to LAN media
servers and the entire point of the picker is to make that easier.
The scheme allowlist keeps the endpoint from being turned into a
generic fetcher for file:// / gopher:// / etc.

Non-book libraries (podcasts) are hidden from the picker, with a
count surfaced in the success line. If the token can see the server
but no book libraries are visible, the response is a warning rather
than a "Connected." with an empty picker.

Tested with httptest covering both response shapes, empty/whitespace
bodies, non-2xx responses, and malformed JSON.
ABS reads series/series-part/asin freeform atoms via ffprobe to
auto-group books into series. With the global tag-profile setting
defaulting to Basic, ABS users get downloads without those atoms
until they discover the setting — bad UX for an opt-in profile that
ABS objectively needs.

Override at runtime in metadataWithOptionalCover: if any enabled
library_destinations row is type=abs, force the profile to
Audiobook-rich regardless of the resolved default. The user-set
default still applies for Plex-only setups, preserving the historical
album-collapse workaround. DB errors fall back to the resolved default
rather than failing the download.

Extracted to resolveTagProfileForDownload + a destinationLister
interface so the override is unit-testable without faking the whole
DownloadManager. Eight table-driven subtests cover: no destinations,
plex-only, abs-present, mixed, already-rich, DB error (twice), and
the "all abs disabled at DB layer" case that documents reliance on
ListEnabledLibraryDestinations's filter.
…h/plex/* handlers

The Plex PIN sign-in UI was removed by 4c5c8ee but the backend code was
left in place: 7 unreachable POST /auth/plex/* routes, their handlers
in plex_auth.go, and an orphaned ~140-line JS block in settings.html
that targeted DOM elements that no longer exist (#plex-auth-state,
#plex-start-btn, #plex_section_title).

This commit deletes the dead surfaces ahead of re-introducing Plex
sign-in as a per-destination affordance in a follow-up:

- Routes removed from server.go (handlePlexStart/Poll/Complete/Select/
  SectionSelect/Scan/Check).
- Handler functions deleted along with completePlexLogin, wantsJSON,
  mustPlexToken, and plexSearchCount — none of these have non-handler
  callers.
- File renamed plex_auth.go -> plex_client.go to reflect that it's now
  just an HTTP client for plex.tv + a Plex server, not auth-page glue.
- Settings.html: dead Plex-auth IIFE removed (~140 lines). The Plex
  sign-in flow will return on the destination form, not the global
  settings page.

Helpers kept (still called by live code paths in server.go for
reconcile / diagnostics / dashboard): plexClientID, plexAuthURL,
plexCreatePin, plexGetPin, plexListServerOptions, plexListSections,
plexTriggerSectionScan, plexSectionItemCount, plexSectionLocation,
plexGetActivities, plexListSectionItems, addPlexHeaders, buildPlexURL,
getPlexSettings, and the response struct types.

No functional change — every helper that survives kept its signature.
go build ./... passes.
Restores the auto-population UX that the multi-destination refactor
(commit 4c5c8ee) inadvertently regressed. Plex destinations now have
three new affordances on the per-destination form, mirroring the ABS
"Discover libraries" pattern (commit 96eda9b):

1. Sign in with Plex — POST /destinations/plex/pin/start mints a PIN
   on plex.tv and returns an HTML fragment with an "Open plex.tv"
   button (target=_blank) plus a hidden HTMX poller. The poller hits
   POST /destinations/plex/pin/poll every 2s; on approval the response
   is a <script> fragment that fills the plex_token input and clicks
   the Discover servers button to chain into URL discovery.

2. Discover servers — POST /destinations/plex/discover/servers calls
   plex.tv /api/v2/resources for the user's owned/shared servers and
   renders a <select> of connection URIs. Picking one fills the URL
   field. LAN connections are flagged in the label.

3. Discover sections — POST /destinations/plex/discover/sections calls
   the configured Plex server's /library/sections and lists all
   sections (not filtered to type=artist — some audiobook libraries
   are misconfigured as type=movie, hiding them would strand users).
   Picking one fills the section_id field.

Manual paste still works — token and section_id inputs remain editable,
and the existing "How to find your token / section ID" details blocks
are kept (re-labelled "Manual:") for users who already have credentials.

All discover endpoints have :id variants (POST
/destinations/:id/plex/discover/...) that resolve the saved row's
plex_token when the edit form leaves it blank, mirroring the
secret-carry-over trick destinationForTest uses.

All fragments render with role="status" aria-live="polite" so SR
users hear discovery outcomes. The PIN-success <script> uses
encoding/json for safe inline embedding plus < > & replacements
to close the </script> injection path.

Reuses the surviving helpers in plex_client.go (plexCreatePin,
plexGetPin, plexListServerOptions, plexListSections, plexAuthURL,
addPlexHeaders, buildPlexURL) — no new HTTP client code.

go build ./... + existing tests pass.
Adds EmbyListLibraries(ctx, baseURL, apiKey) — exported helper that hits
GET /emby/Library/MediaFolders and returns every library visible to the
credentials. Mirrors abs.ListLibraries.

New handler handleDestinationsDiscoverEmby renders a <select> picker:
  POST /destinations/discover/emby           — form values
  POST /destinations/:id/discover/emby       — saved row, api_key carried

The picker filters to CollectionType="audiobooks", but falls back to
showing all libraries (with a warning banner) when none match — a
misconfigured server shouldn't strand the user.

Render logic is factored into renderEmbyLikeDiscover() + a generic
mediaServerLibrary{ID, Name, Kind, Path} row shape so the Jellyfin
slice can share it without copy-paste. Picker fills #library_id via
inline onchange, matching the ABS pattern.

Saved-row api_key carry-over via new resolveAPIKeyFromForm() helper
(mirror of resolvePlexTokenFromForm) lets discover work from the edit
form without re-pasting the key.

Template: Emby destinations now have a "Discover libraries" button +
result div, parallel to the existing ABS one. role="status"
aria-live="polite" for SR parity.

go build ./... passes.
Adds JellyfinListLibraries(ctx, baseURL, apiKey) — exported helper that
hits GET /Library/VirtualFolders with the canonical
`Authorization: MediaBrowser Token="..."` header (not the deprecated
X-Emby-Token) and returns every library visible to the credentials.
Mirror of EmbyListLibraries.

New handler handleDestinationsDiscoverJellyfin reuses the
renderEmbyLikeDiscover() helper from the Emby slice — same mediaServer-
Library shape, same picker UI, filter is CollectionType="books"
(Jellyfin's audiobook collection type) instead of "audiobooks".

  POST /destinations/discover/jellyfin           — form values
  POST /destinations/:id/discover/jellyfin       — saved row, api_key carried

Template: Jellyfin destinations now have a "Discover libraries" button
parallel to Emby and ABS. Help text for the manual library-ID lookup
also corrected to use the modern Authorization: MediaBrowser header
(the previous X-Emby-Token instructions were deprecated and will stop
working on Jellyfin 12.0).

go build ./... + go test ./... pass.
Two-pronged fix for the "two cards both labeled Plex" problem when a
user adds a second destination of the same type:

1. Plex server picker now autofills display_name with the discovered
   server name (e.g. "Living Room Plex") on selection, but only when
   the display_name field is empty — so a user who typed their own
   name doesn't get it overwritten every time they change the picker.
   The server name is carried on data-server-name and is stripped of
   the trailing " (Plex Media Server)" product suffix that
   plexListServerOptions adds for the visible disambiguation label.

2. Server-side uniqueDisplayName() appends " (2)", " (3)" … to the
   candidate when another destination already uses the same name
   (case-insensitive). Runs on both Create and Update, with excludeID
   on Update so renaming a destination back to its current value is
   a no-op instead of bumping it. Bounded at 999 attempts with a
   uuid-suffix fallback so a pathological collision storm can't
   infinite-loop; on a ListLibraryDestinations error we return the
   candidate as-is rather than block creation.

Result: a user adding two Plex servers via the sign-in flow gets
"Living Room Plex" + "Bedroom Plex" automatically. Falling back to
manual configuration on the same type yields "Plex" + "Plex (2)"
instead of two identical labels.

Emby/Jellyfin/ABS only get the auto-numbering fallback today. Per-
backend server-info discovery (e.g. Emby /System/Info → ServerName)
is a follow-up if it matters.

go build ./... + go test ./... pass.
Independent review of feat/destination-discovery surfaced one broken
edit-path, one leak vector, and a few correctness/cleanup issues.

High:
- ABS Discover button was broken on /destinations/:id/edit — the button
  always posted to /destinations/discover/abs (no :id variant) and the
  handler read api_key directly via c.PostForm with no saved-row fallback.
  Result: edit form started with api_key blank (sensitive fields are
  never prefilled) and Discover surfaced "Enter both URL and API token"
  unless the user re-pasted the key. Fix: register
  POST /destinations/:id/discover/abs, switch handler to
  resolveAPIKeyFromForm, and add {{if $isEdit}} URL switch in the
  template. Emby/Jellyfin/Plex already did this correctly; ABS was the
  outlier.

- Plex PIN-poll success response inlines the auth token verbatim in a
  <script> with no cache headers. Any cache (CDN, reverse proxy, browser
  bfcache) sitting in front of the response would store the token and
  potentially serve it to the wrong user. Added writeSensitiveHTML
  helper that sets Cache-Control: no-store + Pragma: no-cache, used on
  every Plex PIN response and every discover-result fragment for
  symmetry.

- jsString() comment promised <, >, & escaping on top of JSON.
  Misleading: Go's json.Marshal defaults to SetEscapeHTML(true), which
  already escapes those three to </>/& — the manual
  ReplaceAll calls were operating on chars that no longer existed.
  Output was safe, code was confusing. Dropped the dead replace calls
  and rewrote the comment to explain what's actually defending here.

Medium:
- validateRemoteURL accepted http://user:pass@host. The userinfo
  serves no legitimate purpose (the user is about to type the API key
  into a dedicated field below) and confuses naive readers about which
  host is contacted. Reject with a clear error.

Low:
- stripPlexProductSuffix was a regex-style hack that over-stripped
  legitimately parenthesized server names (a server literally named
  "Plex (Living Room)" would become "Plex"). Replaced with a clean
  carry-through: plexServerOption now has a separate DeviceName field
  populated from dev.Name pre-concatenation, used directly for the
  display_name autofill. The visible picker label keeps the product
  suffix for disambiguation.

- renderEmbyLikeDiscover had a `_ /*unused*/ string` trailing parameter
  from earlier scaffolding. Dropped, callers updated.

- Inlined a single-use `token := pin.AuthToken` variable in
  handleDestinationsPlexPinPoll.

Build + tests pass.
… click

Previously the "Sign in with Plex" button returned a fragment containing
an "Open plex.tv sign-in" link the user had to click. Two clicks for
one intent is the kind of friction this PR was supposed to remove.

Now the response includes an inline <script> that calls window.open
directly. Browsers allow popups without a blocker prompt when they
happen inside an active user gesture; the user's click on
"Sign in with Plex" is still considered active when the HTMX response
is parsed (Chrome/Edge/Firefox honor this; Safari is stricter about
network-response popups).

A visible "If no window opened, open plex.tv sign-in" fallback link
covers the case where a popup-blocker intervenes anyway.

Push and pop in noopener mode so the popup can't navigate this tab.
http://abs.lan:13378/ and http://abs.lan:13378 used to land in the DB
as different strings, even though every backend's buildURL strips the
trailing slash before issuing requests. Stored values are now canonical
(no trailing slash on the path) so dedup checks and reconcile diffs
can rely on string equality.

normalizeURL is applied at the five user-facing entry points:
destinationFromForm (the save path) and the four discover handlers
(Plex sections, ABS, Emby, Jellyfin). Uses url.Parse so a path like
/api/ is preserved as /api but trailing slashes alone are stripped.

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 reintroduces “discovery” UX on the per-destination form across all supported media-server backends (Plex/Emby/Jellyfin/ABS), restoring Plex PIN sign-in and adding library/section pickers so users don’t have to manually extract IDs/tokens. It also improves destination naming behavior for multi-destination setups and adds a download-time tag-profile override to ensure ABS users get series-friendly tagging.

Changes:

  • Add Plex PIN sign-in (plex.tv) plus server and section discovery directly in the destination form (HTMX fragments).
  • Add Emby/Jellyfin “Discover libraries” pickers and fix ABS discover behavior on edit forms.
  • Make destination display names optional with automatic disambiguation, and override tag profile to “audiobook-rich” when any ABS destination is enabled.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/web/templates/settings.html Removes legacy Plex auth JS that was tied to deleted /auth/plex/* routes.
internal/web/templates/destinations_form.html Adds per-backend discovery/sign-in UI elements (HTMX) and makes display name optional.
internal/web/server.go Removes legacy /auth/plex/* routing; adds per-destination discover/sign-in endpoints.
internal/web/plex_client.go Strips legacy Gin handlers; keeps Plex client utilities and expands server option metadata for UI.
internal/web/destinations_handlers.go Implements discovery/sign-in handlers, URL normalization/validation helpers, and display-name deduping.
internal/mediaserver/abs.go Adds ListLibraries helper + ABSLibrary projection for ABS library discovery.
internal/mediaserver/abs_list_libraries_test.go Unit tests for ABS ListLibraries supporting multiple response shapes and error cases.
internal/mediaserver/emby.go Adds EmbyListLibraries + EmbyLibrary for Emby library discovery.
internal/mediaserver/jellyfin.go Adds JellyfinListLibraries + JellyfinLibrary using the MediaBrowser Authorization header.
internal/library/tag_profile_override.go Adds helper to force audiobook-rich tags when any ABS destination is enabled.
internal/library/tag_profile_override_test.go Unit tests covering override behavior and DB error fallback.
internal/library/download.go Uses the new tag-profile override during download metadata generation.
Comments suppressed due to low confidence (1)

internal/web/destinations_handlers.go:1092

  • destinationFromForm normalizes and accepts any non-empty URL, but it doesn’t validate scheme/host or reject user:pass@host even though validateRemoteURL exists and the new discover endpoints rely on it. This allows saving malformed/unsupported URLs (or URLs with userinfo) that will later break discover/reconcile paths with less clear errors; consider calling validateRemoteURL here (before persisting) so create/update enforce the same URL constraints.
	d := &database.LibraryDestination{
		Type:            database.LibraryDestinationType(t),
		DisplayName:     displayName,
		URL:             normalizeURL(c.PostForm("url")),
		AudiobookPath:   strings.TrimSpace(c.PostForm("audiobook_path")),
		DestinationPath: strings.TrimSpace(c.PostForm("destination_path")),
	}
	if d.URL == "" {
		return nil, errors.New("URL is required")
	}

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

Comment on lines +787 to +792
func renderDiscoverResult(c *gin.Context, libs []mediaserver.ABSLibrary, errMsg string) {
c.Header("Content-Type", "text/html; charset=utf-8")
if errMsg != "" {
c.String(http.StatusOK,
`<div class="info-box" style="border-color:var(--error);margin:.5rem 0" role="status" aria-live="polite">`+
`<strong>Failed.</strong> `+htmlEscape(errMsg)+`</div>`)
Comment on lines +572 to +574
// SetEscapeHTML(true) for json.Marshal, which already escapes <, >,
// and & as < / > / & — so a token containing "</script>"
// becomes "</script>" and cannot close the script tag.
@mstrhakr
mstrhakr merged commit a62dbea into mstrhakr:master May 11, 2026
8 checks passed
@mstrhakr

Copy link
Copy Markdown
Owner

Thanks for this btw, I was working on it but you beat me to it lmao.

@Wutname1
Wutname1 deleted the feat/destination-discovery branch May 14, 2026 01:34
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