Skip to content

Opt-in "Combine connected servers" / unified home screen mode #85

Description

@classicjazz

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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).
  5. 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.

  6. 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.

  7. 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.

  8. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions