Skip to content

Add multi-server support for Jellyfin and AudiobookShelf#1491

Open
matalvernaz wants to merge 2 commits intoTortugaPower:developfrom
matalvernaz:feat/multi-server
Open

Add multi-server support for Jellyfin and AudiobookShelf#1491
matalvernaz wants to merge 2 commits intoTortugaPower: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
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

Adds multi-server connection support for Jellyfin and AudiobookShelf so users can save, switch, and manage multiple server instances without re-entering credentials.

Changes:

  • Persist multiple connections in Keychain (array), migrate from single-connection format, and track an “active” connection via UserDefaults.
  • Introduce server picker views for the import (“Download from …”) flow when 2+ servers exist.
  • Update Settings-connected views to list all servers with per-server sign-out and “Add Another Server” flow.

Reviewed changes

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

Show a summary per file
File Description
BookPlayer/Jellyfin/Network/JellyfinConnectionService.swift Stores/reloads multiple Jellyfin connections, manages active connection, and persists to Keychain/UserDefaults
BookPlayer/Jellyfin/Network/JellyfinConnectionData.swift Adds id with backward-compatible decoding for migration
BookPlayer/Jellyfin/Connection Screen/JellyfinServerPickerView.swift New server picker UI for multi-server import flow
BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionViewModel.swift Manages list of saved servers + activate/delete/add/cancel flows
BookPlayer/Jellyfin/Connection Screen/JellyfinConnectionView.swift Routes between picker vs form based on mode/server count; adds Cancel toolbar
BookPlayer/Jellyfin/Connection Screen/JellyfinConnectedView.swift Lists all servers in Settings and supports per-server actions
BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionService.swift Stores/reloads multiple ABS connections, active selection, and Keychain persistence
BookPlayer/AudiobookShelf/Network/AudiobookShelfConnectionData.swift Adds id with backward-compatible decoding for migration
BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfServerPickerView.swift New server picker UI for multi-server import flow
BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionViewModel.swift Manages list of saved servers + activate/delete/add/cancel flows
BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectionView.swift Routes between picker vs form based on mode/server count; adds Cancel toolbar
BookPlayer/AudiobookShelf/Connection Screen/AudiobookShelfConnectedView.swift Lists all servers in Settings and supports per-server actions

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

Comment on lines +267 to 286
private func reloadConnections() {
// Try new array format first
if let stored: [JellyfinConnectionData] = try? keychainService.get(.jellyfinConnection),
!stored.isEmpty {
connections = stored
}
// Migrate from old single-connection format (or array with one migrated item)
else if let stored: JellyfinConnectionData = try? keychainService.get(.jellyfinConnection) {
connections = [stored]
saveConnections()
}

private func isConnectionValid(_ data: JellyfinConnectionData) -> Bool {
return !data.userID.isEmpty && !data.accessToken.isEmpty
// Rebuild the client for the active connection
if let conn = connection {
client = createClient(serverUrlString: conn.url.absoluteString, accessToken: conn.accessToken)
if activeConnectionID == nil {
activeConnectionID = conn.id
}
}
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The previous implementation validated stored connection data before using it; this version loads connections without validation and can rebuild a client from invalid/empty credentials. Additionally, if activeConnectionID is set but no longer exists in connections, connection falls back to connections.first while activeConnectionID remains stale, which can lead to inconsistent persisted state. Suggested fix: filter loaded connections using isConnectionValid, and normalize activeConnectionID after loading (e.g., if it doesn’t match any connection, set it to connections.first?.id).

Copilot uses AI. Check for mistakes.
Comment on lines +380 to +381
if activeConnectionID == nil {
activeConnectionID = connections.first?.id
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Like the Jellyfin service, this reload path doesn’t validate stored connections (even though isConnectionValid exists below) and can keep unusable credentials around. It also doesn’t handle the case where activeConnectionID is non-nil but no longer exists in connections (stale UserDefaults), leaving the persisted active ID inconsistent with the effective connection selection. Suggested fix: filter loaded connections via isConnectionValid, and if activeConnectionID doesn’t match any saved connection, reset it to connections.first?.id.

Suggested change
if activeConnectionID == nil {
activeConnectionID = connections.first?.id
// Remove any invalid or incomplete connections
connections = connections.filter { isConnectionValid($0) }
// Ensure the active connection ID matches a saved connection (or reset it)
if connections.isEmpty {
activeConnectionID = nil
} else if let activeID = activeConnectionID,
!connections.contains(where: { $0.id == activeID }) {
activeConnectionID = connections.first?.id
} else if activeConnectionID == nil {
activeConnectionID = connections.first?.id

Copilot uses AI. Check for mistakes.
Button {
viewModel.handleAddServerAction()
} label: {
Label("Add Another Server", systemImage: "plus.circle")
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

This introduces a new user-facing string as a hardcoded literal. The PR description notes this needs a localization key; please replace the literal with a localized string key (e.g., the suggested integration_add_server_button) for consistency with the rest of the integration UI.

Suggested change
Label("Add Another Server", systemImage: "plus.circle")
Label("integration_add_server_button".localized, systemImage: "plus.circle")

Copilot uses AI. Check for mistakes.
Button {
viewModel.handleAddServerAction()
} label: {
Label("Add Another Server", systemImage: "plus.circle")
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Several texts appear to be missing .localized in the AudiobookShelf UI (e.g., integration_username_placeholder, logout_title, integration_section_login), which will cause the raw localization keys to be displayed to users. Please apply .localized consistently (matching the Jellyfin screens) and localize the new "Add Another Server" label via a localization key.

Copilot uses AI. Check for mistakes.

// Username row
HStack {
Text("integration_username_placeholder")
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Several texts appear to be missing .localized in the AudiobookShelf UI (e.g., integration_username_placeholder, logout_title, integration_section_login), which will cause the raw localization keys to be displayed to users. Please apply .localized consistently (matching the Jellyfin screens) and localize the new "Add Another Server" label via a localization key.

Copilot uses AI. Check for mistakes.
}

// Sign out button
Button("logout_title", role: .destructive) {
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Several texts appear to be missing .localized in the AudiobookShelf UI (e.g., integration_username_placeholder, logout_title, integration_section_login), which will cause the raw localization keys to be displayed to users. Please apply .localized consistently (matching the Jellyfin screens) and localize the new "Add Another Server" label via a localization key.

Copilot uses AI. Check for mistakes.
.foregroundStyle(.red)
} header: {
if isActive {
Text("integration_section_login")
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

Several texts appear to be missing .localized in the AudiobookShelf UI (e.g., integration_username_placeholder, logout_title, integration_section_login), which will cause the raw localization keys to be displayed to users. Please apply .localized consistently (matching the Jellyfin screens) and localize the new "Add Another Server" label via a localization key.

Copilot uses AI. Check for mistakes.
}
}
} header: {
Text("integration_section_login")
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The server picker header uses a localization key without calling .localized, so it will likely render the key string itself. Update it to use the localized value (consistent with JellyfinServerPickerView).

Suggested change
Text("integration_section_login")
Text("integration_section_login".localized)

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +30
private var activeConnectionID: String? = UserDefaults.standard.string(forKey: "jellyfinActiveConnectionID") {
didSet { UserDefaults.standard.set(activeConnectionID, forKey: "jellyfinActiveConnectionID") }
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

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

The UserDefaults key is a string literal embedded in the service. To reduce the risk of typos and ease future refactors, consider centralizing these keys (e.g., a UserDefaultsKeys namespace/enum) and reusing it in both Jellyfin and AudiobookShelf services.

Copilot uses AI. Check for mistakes.
@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.

Adapts the multi-server feature to the protocol-based integration
framework introduced in the integrations rework. Instead of
integration-specific views and services, multi-server support now
plugs into the shared IntegrationConnectionViewModelProtocol and
generic views in MediaServerIntegration/.

Data model (both integrations):
- Add id: String (UUID) to connection data with backward-compatible
  Decodable init for zero-friction migration from single-connection format

Connection services (both integrations):
- Keychain storage: single object -> [ConnectionData] array
- reloadConnections(): tries array format first, migrates old single
  object on first launch, validates via isConnectionValid, normalizes
  activeConnectionID
- signIn(): appends new connection, deduplicates on url + userID
- activateConnection(id:): switch active server
- deleteConnection(id:): remove specific server

Shared protocol & views:
- IntegrationConnectionViewModelProtocol gains servers, isAddingServer,
  handleSignOutAction(id:), handleActivateAction(id:),
  handleAddServerAction(), handleCancelAddServerAction()
- IntegrationConnectedView: shows all saved servers with per-server
  sign-out when 2+, plus "Add Another Server" button
- IntegrationConnectionView: handles isAddingServer flow with Cancel
  toolbar button and full connect/sign-in progression
- New IntegrationServerPickerView: shared picker for import flow
  when 2+ servers exist

Root views (both integrations):
- Show server picker sheet when 2+ servers on launch
- Reload library when switching servers
Replace the separate "Download from Jellyfin" and "Download from
AudiobookShelf" menu items with one "Media Servers" button that opens
a unified view showing all saved servers from both integrations.

- Add MediaServersView with unified server list, type picker for
  adding new servers, and in-place add-server sheets
- Add skipServerPicker parameter to root views so the caller can
  bypass the per-integration server picker after activating a server
- Add .mediaServers case to IntegrationSheet enum
- Add localization keys for the new UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@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.

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