What's the feature?
An opt-in "Combine connected servers" mode that lets Sodalite hold more than one Jellyfin session live at once and present the Home tab (plus the Live TV tab's presence) as a single unified view across every signed-in server, instead of only the one active server.
Following are proposed features based on an analysis by Claude of the Infuse 1.0.0 (build 22) code base:
-
Concurrent sessions. Promote the existing per-server keychain material (accessToken(serverID:), userID(serverID:), resolved route) into a small session registry so N servers can be queried at once. The "active" server stays the anchor for navigation, Search, Catalog, Seerr and playback launch; the unified behavior is scoped to Home aggregation + the Live TV tab probe. Items surfaced from a non-active server carry their origin server id so detail/playback resolve against the right client.
-
Live TV tab appears if any connected server exposes Live TV. Change the serverHasLiveTV probe in TabRootView from active-only to a union over connected servers (bounded by the cap in point 6). The Live TV feature then needs a server selector or per-section server grouping, since JellyfinLiveTvService currently binds to the single client.
-
Duplicate library names shown separately. Aggregate libraries by (serverID, libraryId) so "Movies" on server A and "Movies" on server B are distinct tiles. Disambiguate in the UI with a secondary server-name label / small chip — don't mutate JellyfinLibrary.name. Server-scope the colliding FilterCacheKey.Home.genre(name:) / tag(name:) keys as part of this. To avoid UI overload, don't display the server name for a library unless there are duplicate library names.
-
Continue Watching (and Next Up) across all connected servers, chronologically merged. Fan getResumeItems out to each connected server and merge on Jellyfin's UserData.LastPlayedDate descending, so the row is a true global timeline. Dedup by (serverID, itemId), optionally collapsing the same title in progress on two servers via provider ids.
- For users who merge CW + Next Up (
mergeCWNextUp): resume items (real timestamps) interleave chronologically first, then Next Up entries follow, grouped per server or ordered by each series' last-played date.
- For users who don't merge: keep two rows, but each is itself aggregated — Continue Watching sorted by
LastPlayedDate across servers; Next Up aggregated across servers with a stable per-server grouping (Next Up has no cross-server date to sort on, so interleave by series recency rather than inventing an order).
-
My Media shows libraries from every connected server, user-sortable, minus hidden ones. Feed the aggregated (serverID, libraryId) set into the My Media row and into the per-library "Latest in X" rows. Add a per-library order and hidden flag, persisted as one list (mirroring HomeCustomizeView's enabled/disabled split), keyed by (serverID, libraryId). Hiding a library cascades: its My Media tile, its "Latest in X" row, and its contribution to aggregated Latest/Continue Watching all drop.
-
Cap the unified view at the 5 most-recently-used servers. listKnownServers() is ordered by most-recently-added, not most-recently-activated — add a lastActivatedAt timestamp written in switchServer/session-restore, and aggregate only the top 5 by that. Servers beyond the cap still work when opened explicitly via the switcher.
-
Drag-and-drop ordering + hiding of individual library rows on Home. Extend the existing row customization to the aggregated per-library rows. On iOS this can be true drag-and-drop; on tvOS reuse the existing tap-to-pick-up / tap-to-place "move mode" (HomeCustomizeView), since tvOS has no drag gesture. "Hide from Home" reuses the existing per-row eye toggle, now also at library granularity.
-
Search: one form, with an explicit server scope selector, not a fan-out. Cross-server search fan-out multiplies latency, rate-limit exposure and result-ranking ambiguity (each server ranks its own relevance; there's no shared score to merge on), and SearchViewModel today assumes a single itemService + userID. Rather than solve global ranking, keep a single search form with a segmented control / button row that selects which Jellyfin server to search against, defaulting to the active server and remembering the last-used scope — the model Infuse uses. Changing the scope re-runs the current query against the chosen server.
Default stays exactly as today. With the mode off, single active server, single session, current caches, current switcher — nothing changes.
Design & performance considerations (supplementing the above)
- Progressive, failure-isolated rendering.
HomeViewModel.loadContent() already upserts rows into a withTaskGroup as each completes, and only shows the unreachable-server error on first load. Extend that per server: each server's slice of a row upserts independently, and a slow or offline server B never blanks server A's content — it degrades to a quiet "couldn't reach B" affordance, matching how loadRow already swallows errors to nil and leaves the on-screen row alone.
- Contention control. The
AsyncSemaphore(limit: 6) is per HTTPClient and "per-client so Jellyfin/Seerr don't share a budget" — N servers each getting their own 6 means 6N sockets against one uplink (and possibly one shared reverse proxy). Recommend: the active server's Home paints first at normal priority; secondary servers fan out at .utility and staggered, reusing the existing 3 s / 8 s / 13 s deferral pattern for heavy passes. The 5-server cap is the hard ceiling.
- Turn cache teardown into a durable per-server cache. Prefix every
FilterCache key with serverID and, in unified mode, stop calling FilterCache.shared.clearAll() on switch — evict per server on logout / removeServer instead. This makes both switching and unified aggregation fast, and directly addresses the "does it cache artwork/metadata" gap: today it effectively doesn't survive a switch.
- Image auth.
AsyncCachedImage must attach the correct per-server X-Emby-Token via a host→token map rather than matching only the active host, or every poster from a non-active server 401s. ImageCache itself is already URL-keyed (host embedded) and cross-server-safe.
- Revalidation over refetch. Keep the per-server 60 s stale window (
HomeView.refreshStaleSeconds); HTTPClient already does If-None-Match conditional GETs, so re-aggregating on return is cheap (304 stubs) when nothing changed on either server.
- Decouple
serverDidSwitch from unified Home. Changing the anchor server shouldn't nuke and reload an aggregated Home; the aggregation should react to "connected set changed," not "active pointer moved."
- Out of scope:
SharedSessionMirror / tvOS Top Shelf stays bound to the active server
Why? When would you use it?
I use two Jellyfin servers with the library split by type — movies and music on one, TV shows and live TV on the other. Today every Sodalite Home visit only ever reflects one of them, so "what do I want to watch" always starts with a decision about which server rather than which show. Switching between them is a modal trip through Settings → Servers or the launch picker, and because switchServer wipes FilterCache, the destination Home rebuilds from scratch every time — Continue Watching, My Media and Latest all reload on each hop.
The couch scenario: I sit down, open Sodalite, and want one Continue Watching row with last night's movie and the episode I'm mid-season on, one My Media grid with every library from both boxes, and the Live TV tab present because one of the servers has a tuner — without knowing or caring which server each item lives on.
Infuse and Moonfin both support unified interfaces across servers.
Anything you tried instead?
- The server switcher (
ServerSwitchSheet, launch picker): works, but it's modal, single-server-at-a-time, and each switch calls FilterCache.shared.clearAll(), so there's a visible reload every hop. It answers "use the other server," not "see both."
- Merge Continue Watching + Next Up (
homeMergeCWNextUp): I have this on and it's great — but it only merges within the active server.
- Per-library "Latest in X" rows: these split per library, but only for libraries on the same server.
- Home customization (reorder / hide rows): already solid, just scoped to one server's config at a time.
How important is this to you?
Would significantly change how I use the app
What's the feature?
An opt-in "Combine connected servers" mode that lets Sodalite hold more than one Jellyfin session live at once and present the Home tab (plus the Live TV tab's presence) as a single unified view across every signed-in server, instead of only the one active server.
Following are proposed features based on an analysis by Claude of the Infuse 1.0.0 (build 22) code base:
Concurrent sessions. Promote the existing per-server keychain material (
accessToken(serverID:),userID(serverID:), resolved route) into a small session registry so N servers can be queried at once. The "active" server stays the anchor for navigation, Search, Catalog, Seerr and playback launch; the unified behavior is scoped to Home aggregation + the Live TV tab probe. Items surfaced from a non-active server carry their origin server id so detail/playback resolve against the right client.Live TV tab appears if any connected server exposes Live TV. Change the
serverHasLiveTVprobe inTabRootViewfrom active-only to a union over connected servers (bounded by the cap in point 6). The Live TV feature then needs a server selector or per-section server grouping, sinceJellyfinLiveTvServicecurrently binds to the single client.Duplicate library names shown separately. Aggregate libraries by
(serverID, libraryId)so "Movies" on server A and "Movies" on server B are distinct tiles. Disambiguate in the UI with a secondary server-name label / small chip — don't mutateJellyfinLibrary.name. Server-scope the collidingFilterCacheKey.Home.genre(name:)/tag(name:)keys as part of this. To avoid UI overload, don't display the server name for a library unless there are duplicate library names.Continue Watching (and Next Up) across all connected servers, chronologically merged. Fan
getResumeItemsout to each connected server and merge on Jellyfin'sUserData.LastPlayedDatedescending, so the row is a true global timeline. Dedup by(serverID, itemId), optionally collapsing the same title in progress on two servers via provider ids.mergeCWNextUp): resume items (real timestamps) interleave chronologically first, then Next Up entries follow, grouped per server or ordered by each series' last-played date.LastPlayedDateacross servers; Next Up aggregated across servers with a stable per-server grouping (Next Up has no cross-server date to sort on, so interleave by series recency rather than inventing an order).My Media shows libraries from every connected server, user-sortable, minus hidden ones. Feed the aggregated
(serverID, libraryId)set into the My Media row and into the per-library "Latest in X" rows. Add a per-library order and hidden flag, persisted as one list (mirroringHomeCustomizeView's enabled/disabled split), keyed by(serverID, libraryId). Hiding a library cascades: its My Media tile, its "Latest in X" row, and its contribution to aggregated Latest/Continue Watching all drop.Cap the unified view at the 5 most-recently-used servers.
listKnownServers()is ordered by most-recently-added, not most-recently-activated — add alastActivatedAttimestamp written inswitchServer/session-restore, and aggregate only the top 5 by that. Servers beyond the cap still work when opened explicitly via the switcher.Drag-and-drop ordering + hiding of individual library rows on Home. Extend the existing row customization to the aggregated per-library rows. On iOS this can be true drag-and-drop; on tvOS reuse the existing tap-to-pick-up / tap-to-place "move mode" (
HomeCustomizeView), since tvOS has no drag gesture. "Hide from Home" reuses the existing per-row eye toggle, now also at library granularity.Search: one form, with an explicit server scope selector, not a fan-out. Cross-server search fan-out multiplies latency, rate-limit exposure and result-ranking ambiguity (each server ranks its own relevance; there's no shared score to merge on), and
SearchViewModeltoday assumes a singleitemService+userID. Rather than solve global ranking, keep a single search form with a segmented control / button row that selects which Jellyfin server to search against, defaulting to the active server and remembering the last-used scope — the model Infuse uses. Changing the scope re-runs the current query against the chosen server.Default stays exactly as today. With the mode off, single active server, single session, current caches, current switcher — nothing changes.
Design & performance considerations (supplementing the above)
HomeViewModel.loadContent()already upserts rows into awithTaskGroupas each completes, and only shows the unreachable-server error on first load. Extend that per server: each server's slice of a row upserts independently, and a slow or offline server B never blanks server A's content — it degrades to a quiet "couldn't reach B" affordance, matching howloadRowalready swallows errors toniland leaves the on-screen row alone.AsyncSemaphore(limit: 6)is perHTTPClientand "per-client so Jellyfin/Seerr don't share a budget" — N servers each getting their own 6 means 6N sockets against one uplink (and possibly one shared reverse proxy). Recommend: the active server's Home paints first at normal priority; secondary servers fan out at.utilityand staggered, reusing the existing 3 s / 8 s / 13 s deferral pattern for heavy passes. The 5-server cap is the hard ceiling.FilterCachekey withserverIDand, in unified mode, stop callingFilterCache.shared.clearAll()on switch — evict per server onlogout/removeServerinstead. This makes both switching and unified aggregation fast, and directly addresses the "does it cache artwork/metadata" gap: today it effectively doesn't survive a switch.AsyncCachedImagemust attach the correct per-serverX-Emby-Tokenvia a host→token map rather than matching only the active host, or every poster from a non-active server 401s.ImageCacheitself is already URL-keyed (host embedded) and cross-server-safe.HomeView.refreshStaleSeconds);HTTPClientalready doesIf-None-Matchconditional GETs, so re-aggregating on return is cheap (304 stubs) when nothing changed on either server.serverDidSwitchfrom unified Home. Changing the anchor server shouldn't nuke and reload an aggregated Home; the aggregation should react to "connected set changed," not "active pointer moved."SharedSessionMirror/ tvOS Top Shelf stays bound to the active serverWhy? When would you use it?
I use two Jellyfin servers with the library split by type — movies and music on one, TV shows and live TV on the other. Today every Sodalite Home visit only ever reflects one of them, so "what do I want to watch" always starts with a decision about which server rather than which show. Switching between them is a modal trip through Settings → Servers or the launch picker, and because
switchServerwipesFilterCache, the destination Home rebuilds from scratch every time — Continue Watching, My Media and Latest all reload on each hop.The couch scenario: I sit down, open Sodalite, and want one Continue Watching row with last night's movie and the episode I'm mid-season on, one My Media grid with every library from both boxes, and the Live TV tab present because one of the servers has a tuner — without knowing or caring which server each item lives on.
Infuse and Moonfin both support unified interfaces across servers.
Anything you tried instead?
ServerSwitchSheet, launch picker): works, but it's modal, single-server-at-a-time, and each switch callsFilterCache.shared.clearAll(), so there's a visible reload every hop. It answers "use the other server," not "see both."homeMergeCWNextUp): I have this on and it's great — but it only merges within the active server.How important is this to you?
Would significantly change how I use the app