Skip to content

Add multi-server support for Jellyfin and AudiobookShelf#1491

Merged
GianniCarlo merged 14 commits into
TortugaPower:developfrom
matalvernaz:feat/multi-server
May 30, 2026
Merged

Add multi-server support for Jellyfin and AudiobookShelf#1491
GianniCarlo merged 14 commits into
TortugaPower:developfrom
matalvernaz:feat/multi-server

Conversation

@matalvernaz
Copy link
Copy Markdown
Contributor

@matalvernaz matalvernaz commented Mar 24, 2026

Problem

Both integrations only support a single saved server. Users with multiple Jellyfin or AudiobookShelf instances (e.g. home + work, personal + shared) have to sign out and re-enter credentials every time they want to switch.

Solution

Store any number of server connections and expose a unified "Media Servers" experience that combines both Jellyfin and AudiobookShelf into one entry point.

"Media Servers" (the import sheet, replaces the separate "Download from…" buttons):

  • 0 servers saved → type picker (Jellyfin / AudiobookShelf) → sign-in form
  • 1+ servers saved → unified list showing all servers from both integrations with type icons; tap to browse
  • "Add Server" button → type picker → connection form (handled in-place when servers of that type already exist)

Settings screen (connection details, accessed from within each library browser):

  • Shows all saved servers with an active indicator (✓ checkmark)
  • Per-server sign-out button
  • "Add Server" button — shows the sign-in form; a Cancel button returns to the server list without saving

Changes

Data model (both integrations)

  • Add id: String (UUID) to JellyfinConnectionData / AudiobookShelfConnectionData
  • Custom Decodable init generates a UUID for any existing saved data that lacks the field — zero-friction migration

Connection services (both integrations)

  • Keychain storage: single object → [ConnectionData] array
  • reloadConnections(): tries array format first, migrates old single-object format on first launch
  • signIn(): appends new connection, deduplicates on url + userID
  • activateConnection(id:): switch active server
  • deleteConnection(id:): remove specific server; deleteConnection() is kept for backward compatibility
  • activeConnectionID persisted in UserDefaults

New views

  • MediaServersView — unified server list combining Jellyfin and AudiobookShelf servers, with type icons, add-server flow, and in-place add sheets
  • IntegrationServerPickerView — per-integration server picker (used internally by root views when skipServerPicker is false)

Updated views

  • ItemListView — replaced separate "Download from Jellyfin" / "Download from AudiobookShelf" menu buttons with a single "Media Servers" button
  • MainView — handles the new .mediaServers sheet case; passes skipServerPicker: true to root views when launched from the unified list
  • ListStateManager — added .mediaServers case to IntegrationSheet enum
  • JellyfinRootView / AudiobookShelfRootView — added skipServerPicker parameter to bypass the per-integration server picker when the caller has already activated the desired server
  • IntegrationConnectedView — renders all saved servers as a list (used in Settings)
  • IntegrationConnectionView — routes to picker vs. form based on server count and view mode; Cancel toolbar button when adding a second server from Settings

View models (both integrations)

  • handleSignOutAction(id:) — remove a specific server
  • handleActivateAction(id:) — switch active server and navigate to its library
  • handleAddServerAction() / handleCancelAddServerAction() — Settings-only add flow

Testing

No servers (fresh start):

  • Tap "Media Servers" → type picker shows Jellyfin and AudiobookShelf options
  • Tap a type → sign-in form appears
  • After connecting, tap "Media Servers" again → server list shows the new server

Single server (regression):

  • Tap "Media Servers" → server list with one entry and "Add Server" button
  • Tap the server → navigates directly to library, no change from before

Multiple servers (mixed types):

  • Tap "Media Servers" → unified list shows all servers with type icons
  • Tap any server → activates it and opens the correct library browser
  • Tap "Add Server" → type picker → connection form → new server appears in list

Multiple servers (same type):

  • Settings → connection details → shows all servers of that type with active checkmark
  • Tap "Add Server" from unified list → pick same type → add sheet appears within the list view
  • Sign in → new server appears; sign out per-server works independently

Migration:

  • User with an existing single saved connection opens the app → connection still works; keychain is migrated to array format transparently

Notes

  • "Add Server" and "Media Servers" are new UI strings that need localization keys

@GianniCarlo GianniCarlo requested a review from Copilot March 24, 2026 17:00

This comment was marked as outdated.

@matalvernaz
Copy link
Copy Markdown
Contributor Author

Addressed all review feedback:

  • Connection validation on reload: both Jellyfin and ABS services now filter loaded connections through isConnectionValid and normalize activeConnectionID (reset to connections.first?.id if the stored ID no longer exists, or nil if connections is empty)
  • Localization: all missing .localized calls added to AudiobookShelfConnectedView (integration_username_placeholder, logout_title, integration_section_login) and AudiobookShelfServerPickerView (integration_section_login)
  • "Add Another Server" string: replaced hardcoded literals in both JellyfinConnectedView and AudiobookShelfConnectedView with "integration_add_server_button".localized; added the key to en.lproj/Localizable.strings

@GianniCarlo
Copy link
Copy Markdown
Collaborator

@matalvernaz after the PR that adds the different options for AudiobookShelf, I reworked how both integrations are built, and the UX for how we handle showing the connection details. I made the last change also thinking of multiple connections for the integrations, but I still have a couple of things to work on prior to the next release, I'll put all the latest changes in the beta though

@matalvernaz
Copy link
Copy Markdown
Contributor Author

Hey, thanks for the update. I see some of your changes and would be happy to keep working on this based on them. Is that something you'd want me to help with, or is there anything else that would be more useful?

@GianniCarlo
Copy link
Copy Markdown
Collaborator

@matalvernaz hmmm I guess it's more towards what would be more useful to you, and what you want to see next in BookPlayer, multi-server support is something that I think we should do, but if you would rather do another thing, we could take a look at that too 👌

@matalvernaz
Copy link
Copy Markdown
Contributor Author

Okay awesome, I'll work on that then! I see how I'd need to change what I did. I can't think of anything else that'd be that useful yet, but will contribute when I do, and hopefully fix things to be better instead of worse.

@matalvernaz matalvernaz force-pushed the feat/multi-server branch 2 times, most recently from b201d99 to a161345 Compare April 10, 2026 03:10
@matalvernaz
Copy link
Copy Markdown
Contributor Author

Hey! I've rebased this onto the latest develop and added a unified "Media Servers" UI on top of the multi-server work.

What changed: The two separate "Download from Jellyfin" / "Download from AudiobookShelf" menu items are now replaced with a single "Media Servers" button. It opens a unified list showing all saved servers from both integrations (with type icons), and an "Add Server" button that lets you pick the server type and go through the connection flow.

I've finally managed to get hold of a macOS compilation environment, so I can test on my phone. So far I've only tested with two Jellyfin servers added, but the multi-server support seems to be working alright for me. Will continue testing with AudiobookShelf as well.

The branch is up to date with develop as of today.

Brings the branch up to date with develop (Core Data v11 migration,
custom-headers PR TortugaPower#1513, sticky-sort, end-of-chapter fix, etc.) and
folds in a few iterations on top:

- A single \"Media Servers\" entry in the import menu in place of the
  separate Jellyfin / AudiobookShelf items. Lists every saved server
  from both integrations with type icons; \"Add Server\" picks the
  type and goes through the connection flow.

- Tap-a-server presents the per-integration library browser as a sheet
  on top of the unified list, with a leading \"Media Servers\" back
  button that closes the inner sheet and lands you back on the list.
  Earlier rework tried to push via NavigationStack but pushing a
  TabView in a NavigationStack auto-pops on iOS 26 — sheet-on-sheet
  sidesteps that entirely.

- Library picker no longer traps you when there are 2+ libraries and
  none chosen yet (Cancel is unconditional and falls back to dismissing
  the whole sheet when there's no library to dismiss to).

- Connection-form xmark closes the form instead of dismissing the
  whole integration view.

- Empty-state rows restyled to match the populated server-row layout
  so they read as tappable. Brand strings consolidated through
  ServerType.displayName, fonts unified on bpFont.
@matalvernaz
Copy link
Copy Markdown
Contributor Author

Hey! Got this caught up to current develop (lots to fold in: Core Data v11, custom-headers from #1513, sticky-sort, EoC fix). Rolled in a few iterations on the multi-server UI on top.

Tapping a server now opens its library browser as a sheet on top of the unified list, with a "Media Servers" back button in the leading slot. I tried pushing via NavigationStack first, but pushing a TabView inside one auto-pops on iOS 26 — sheet-on-sheet just sidesteps that.

Also fixed a couple of dead-end exits I noticed while testing. Library picker now has an unconditional Cancel (falls back to dismissing the integration sheet if you haven't picked a library yet), and the connection-form xmark closes just the form instead of bailing out of the whole integration view. The empty state's add-server rows got restyled to match the populated server-row layout (icon + name + chevron) so they read as tappable.

Tested on TestFlight build 5.20.0 / 20260505091947 with two Jellyfin servers and one ABS. Happy to split anything out if it's easier to review separately.

@matalvernaz matalvernaz force-pushed the feat/multi-server branch from a85e9c1 to e21974a Compare May 5, 2026 10:28
@GianniCarlo
Copy link
Copy Markdown
Collaborator

thanks @matalvernaz ! I had a mental block on getting out the current release 5.20.0 before looking into other PRs, but now that's done, I can focus again

matalvernaz and others added 8 commits May 17, 2026 03:42
activateConnection and deleteConnection were calling createClient without
passing the active connection's customHeaders, so any server behind a
Cloudflare Access (or similar) proxy stopped responding after the user
switched servers or signed out of one of several — the next request went
out missing the required CF-Access-Client-* headers and was rejected
until the app was relaunched (the only path that did pass them was
reloadConnections at launch).

Centralize the client-rebuild in rebuildClient(for:) so future call sites
can't drop headers, and capture the old client before mutating state so
the fire-and-forget signOut doesn't race the new client assignment.
When fetchLibraries (or the equivalent ABS call) threw, the resulting
alert had a single OK button that set showConnectionForm = true. That
flow is the right one for "the user wants to sign in again" but it's
the wrong one for everything else (transient network, expired token,
custom-header proxy hiccup). Users who hit it would re-add their server
and end up with a duplicate because the byte-exact URL dedup in the
connection service didn't treat trailing-slash / scheme variants as the
same connection.

Replace the single OK with three explicit buttons — Sign In (the old
behavior, now opt-in), Retry (re-call loadLibraries), and Cancel (close
the integration sheet, return to the Media Servers list). Same fix on
both JellyfinRootView and AudiobookShelfRootView. New
"integration_retry_button" localization key seeded with English
literals across all 26 .lproj files matching the rest of the fork's
localization pattern.
IntegrationConnectionView's onConnect / onSignIn / onStartQuickConnect
each kicked off an unstructured Task that the view had no handle to.
If the user dismissed the sheet (swipe-to-dismiss or hitting the close
button) while a sign-in was in flight, the Task kept running and could
persist a connection to the service after the user thought they gave
up on the operation.

Store each action's Task in @State and cancel it from .onDisappear.
Catch CancellationError separately so the dismissal doesn't get
surfaced as an alert.

The View-side cancel alone isn't sufficient — Swift cooperative
cancellation only flags the Task, it doesn't synchronously interrupt
an awaiting call, and the service methods kept persisting after the
network call returned. Add try Task.checkCancellation() inside
JellyfinConnectionService.signIn(username:password:...) and
AudiobookShelfConnectionService.signIn(...) immediately after the
auth round-trip returns, so the persist step is skipped on cancel.
The matching call inside signInWithQuickConnect was already added in
the Quick-Connect cancel-race fix.
Two small input-validation gaps:

H18 — pingServer accepts a schemeless string like "abs.example.com"
verbatim because URL(string:) parses it as a relative path. The
subsequent appendingPathComponent("ping") yields "abs.example.com/ping"
which URLSession returns an opaque .unsupportedURL for, and the user
has no idea they needed to prepend https://. Normalize in the view
model before calling pingServer: trim whitespace, prepend https:// if
no scheme. Mirror the result back into the form field so the user can
see what we actually used.

H19 — Username and password were passed to ABS auth verbatim, so an
autocorrect-inserted trailing space on the username is enough to fail
otherwise-correct credentials with a 401. Trim both in
handleSignInAction.
Foundation URL equality is byte-exact, so https://server vs
https://server/ vs https://server:443 vs https://Server were treated as
four different connections by the deduplication in
JellyfinConnectionService and AudiobookShelfConnectionService. Users
hitting the C2 error alert in either integration would commonly re-add
their server slightly differently (often with a trailing slash) and
end up with two saved entries, one of which was broken.

Add URL.canonicalDedupKey to BookPlayerKit's URL extension: lowercase
scheme/host, drop default ports (80/443), strip the trailing slash on
non-root paths, and drop userinfo/query/fragment. Both services'
removeAll-then-append dedup blocks now compare canonical keys.
Token-expiry / mid-session-unauthorized had no first-class handling —
401/403 propagated as a generic load failure, the alert offered a
single OK that pushed the user into the add-server form, and users
who took it ended up with duplicate connections (the C2-minimum fix
just rearranged the alert buttons to stop the worst case).

Distinguish session-expired from generic failures end to end:

- New IntegrationError.sessionExpired(serverName:) carries the offending
  server name so the alert message is specific ("Your session for
  Library Server expired. Sign in again to continue.").
- Jellyfin: wrap the api-client `send<T>` to catch
  APIError.unacceptableStatusCode(401|403) and throw .sessionExpired —
  only when there's actually a saved connection (so pre-sign-in probes
  inside `findServer` aren't mis-classified).
- ABS: centralize HTTP status validation in
  validateAuthenticatedResponse(_:) and use it across every
  authenticated data-fetch site (fetchLibraries, fetchItems,
  fetchItemDetails, search, …). Same connection-presence guard. The
  signIn path keeps its existing 401 → URLError(.userAuthenticationRequired)
  mapping ("wrong password" is a different UX than "your session expired").
- Both RootView alerts (JellyfinRootView, AudiobookShelfRootView)
  special-case `IntegrationError.isSessionExpired`: show Sign In +
  Cancel only, skip the Retry button that's meaningless when the
  token's revoked.
- Both signIn implementations now preserve the existing connection's
  id and selectedLibraryId when re-authing on the same
  (canonical-url, userID) pair — so when the user signs in again
  they land back on the same library context they had before,
  not a fresh record. This is the half of the fix that makes "Sign In
  again" actually transparent UX rather than "you lost your saved
  library choice".

New localization key `integration_error_session_expired` seeded with
English literals across all 26 .lproj files.

This comment was marked as outdated.

@matalvernaz
Copy link
Copy Markdown
Contributor Author

Hey @GianniCarlo, thanks for jumping on this. Copilot caught a lot of locale-file noise, but there are three things buried in there that look like real regressions from 3c057c9 worth fixing before another pass:

  1. AudiobookShelfConnectionService.swift:64pingServer is an unauthenticated probe but now goes through validateAuthenticatedResponse, which throws IntegrationError.sessionExpired on 401/403 whenever any saved connection exists. The session-expired flow in 4d2a19b5 was scoped to authenticated paths on purpose, so pre-sign-in probes (e.g. findServer) wouldn't get mis-classified. The centralization here folds the unauth path back into the auth validator. Probably wants a 2xx-only validator for the ping path (or just keep the inline status check it had before).

  2. JellyfinRootView.swift:608accessibilityLabel is set to the raw key "settings_title" rather than "settings_title".localized. I'm blind and use VoiceOver, so this one actually bites me — it'll read out the key name verbatim.

  3. BookPlayer.xcodeproj/project.pbxproj — duplicate UUIDs (B14881000000000000000001 / …0002) for IntegrationCustomHeadersSectionView.swift in both PBXBuildFile and PBXFileReference sections. Looks like a leftover from folding in Add support for custom headers #1513 — strict parsers will reject it.

Happy to push the fixes myself if that's easier, just let me know. The locale-key seeds (missing integration_add_server_button / media_servers_title across ~25 .lproj files) I can also batch in if you want.

@GianniCarlo
Copy link
Copy Markdown
Collaborator

Hi @matalvernaz ! thanks for pointing those out, I'll take care of them, my intend is to merge this PR today, also I have yet to respond to Alex's email, I'll get to that this weekend 👌

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comment on lines +26 to +34
if let port = components.port,
(components.scheme == "http" && port == 80)
|| (components.scheme == "https" && port == 443) {
components.port = nil
}

if components.path.count > 1, components.path.hasSuffix("/") {
components.path.removeLast()
}
Comment thread Shared/Services/KeychainService.swift
Comment thread BookPlayer/Jellyfin/JellyfinRootView.swift
Comment on lines +11 to +12
/// Current step of the sign-in flow. `nil` means the user is not actively
/// signing in — the view should show the saved-servers list.
Comment on lines 20 to +26
enum IntegrationViewMode {
/// Bound to a live library session; pre-populates form from the active connection.
case regular
/// Cog → Connection Details flow; pre-populates form, shows saved-list view.
case viewDetails
/// Dedicated Add Server flow; starts with empty form, no active-connection state leaks.
case addServer
Comment on lines +43 to +45
/// Drives what the view renders.
/// `.enteringServerURL` → URL form; `.enteringCredentials` → credentials form; `nil` → saved-servers list.
var signInFlow: SignInStep? { get set }
Comment on lines +57 to 60
case .none:
// Not in sign-in flow → render the saved-servers list.
IntegrationServerInformationSectionView(
serverName: viewModel.form.serverName,
Comment thread Shared/Services/KeychainService.swift
@GianniCarlo GianniCarlo merged commit 796a81e into TortugaPower:develop May 30, 2026
1 check passed
@GianniCarlo
Copy link
Copy Markdown
Collaborator

it's finally merged @matalvernaz 🎉 , sorry about the delay

matalvernaz added a commit to matalvernaz/BookPlayer that referenced this pull request May 30, 2026
Brings in the now-upstream multi-server merge (TortugaPower#1491) and audio-session
crash fix (TortugaPower#1524) plus the privacy policy bump.

Conflict resolution: prefer upstream/develop where the same surface
diverged, preserve test/all-changes work that develop doesn't have.

- Multi-server core (10 Jellyfin/ABS/MainView files): took develop's
  reworked version. Matt's original multi-server commits are now
  effectively encoded in develop's squashed merge of TortugaPower#1491.
- 26 Localizable.strings: took develop's base, re-appended local-only
  keys (Hummingbird/NNELS UI, Jellyfin Quick Connect, share-import,
  audio-session description) so referenced strings still resolve.
- PlayerManager.swift: union of audio-session var declarations (both
  approaches reference symbols elsewhere in the file); took develop's
  Sentry-only recovery in the activation-failure block. Kept FLAC seek
  tracking (initialSeekInProgress / loadGeneration).
- URL+BookPlayer.swift: took develop's loop trailing-slash strip.
- project.pbxproj: union for the build/file ref blocks; deduped the
  orphaned A1B2.../F1A2B3C4... MediaServersView UUID entries that
  would have collided with develop's D5E6F708 UUIDs.

Known carry-over from develop: JellyfinRootView.swift:608 still uses
the raw key `"settings_title"` for accessibilityLabel rather than
`.localized` — VoiceOver will read out the key name. Not patching here
since it's an upstream bug; re-flag to @GianniCarlo.
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