From a6adc8299d2607f318cabe43ca9b1cd1d1149cb6 Mon Sep 17 00:00:00 2001
From: cucadmuh <49571317+cucadmuh@users.noreply.github.com>
Date: Tue, 21 Jul 2026 14:18:54 +0300
Subject: [PATCH] feat(orbit): isolate sessions to selected server
---
CHANGELOG.md | 2 +-
ORBIT.md | 10 +-
.../orbit/components/OrbitJoinModal.tsx | 4 +-
.../orbit/components/OrbitStartModal.test.tsx | 51 ++++++++++
.../orbit/components/OrbitStartModal.tsx | 49 +++++++---
src/features/orbit/store/orbitStore.ts | 8 ++
src/features/orbit/utils/host.ts | 67 ++++++++++++-
.../orbit/utils/hostOwnership.test.ts | 96 ++++++++++++++++++-
.../orbit/utils/teardownRevision.test.ts | 49 +++++++++-
.../playback/store/playerStore.queue.test.ts | 35 +++++++
.../playback/store/playerStoreTypes.ts | 3 +
.../playback/store/queueMutationActions.ts | 55 ++++++++++-
src/locales/bg/orbit.ts | 3 +
src/locales/de/orbit.ts | 3 +
src/locales/en/orbit.ts | 3 +
src/locales/es/orbit.ts | 3 +
src/locales/fr/orbit.ts | 3 +
src/locales/hu/orbit.ts | 3 +
src/locales/it/orbit.ts | 5 +-
src/locales/ja/orbit.ts | 3 +
src/locales/nb/orbit.ts | 3 +
src/locales/nl/orbit.ts | 3 +
src/locales/pl/orbit.ts | 3 +
src/locales/ro/orbit.ts | 3 +
src/locales/ru/orbit.ts | 3 +
src/locales/zh/orbit.ts | 3 +
26 files changed, 443 insertions(+), 30 deletions(-)
create mode 100644 src/features/orbit/components/OrbitStartModal.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3e34d60..12b75711 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1326](https://github.com/Psychotoxical/psysonic/pull/1326)**
* Playlist creation, smart-playlist editing, radio actions and other destination-sensitive flows select the target server explicitly instead of silently using the active server.
-* Context menus, ratings, favourites, sharing, offline pins, device sync and Orbit carry the item's owner through the complete action. The same numeric Subsonic ID may now safely exist on several servers at once.
+* Context menus, ratings, favourites, sharing, offline pins, device sync and Orbit carry the item's owner through the complete action. Creating an Orbit session from a multi-server scope now asks which server should host it, temporarily keeps only that server in the library scope and shared queue, then restores the previous scope when the session ends.
* Sharing a mixed-server queue asks which server's tracks to include in the link; single-server queues still copy immediately without an extra prompt.
## Changed
diff --git a/ORBIT.md b/ORBIT.md
index 0879c976..b20f3a89 100644
--- a/ORBIT.md
+++ b/ORBIT.md
@@ -40,8 +40,9 @@ Click **Psy Orbit** in the top bar → **Create a session**. The start modal ope
- **Session name** — a random playful name is generated; edit it or reroll with the dice button.
- **Max guests** — cap on concurrent participants (1–32). You don't count.
+- **Session server** — when your library scope includes several servers, choose the Navidrome instance that will host the session. Psysonic temporarily narrows the library scope and mixed queue to that server, then restores the previous scope when the session ends.
- **Invite link** — ready to copy and share the moment the modal opens. Pre-generated from a fresh session id + the slugified name.
-- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep your current queue and share it with the guests.
+- **Clear my queue first** — optional. Start with an empty queue (guest suggestions land fresh) vs. keep the chosen server's queued tracks and share them with the guests.
Click **Start Orbit**. The session bar appears at the top of the window (session name, participant count, shuffle countdown, settings / share / help / exit buttons). The link is now live — share it.
@@ -267,11 +268,10 @@ The state blob is size-bounded to 4 KB (serialised JSON). `serialiseOrbitState`
### Cleanup
-Three layers of defense against orphaned playlists:
+Two layers of defense against orphaned playlists:
1. **Explicit exit.** `endOrbitSession` (host) or `leaveOrbitSession` (guest) deletes the participant's own playlists synchronously. The happy path.
-2. **Server-switch teardown.** Switching Navidrome servers tears the current session down first (up to 1.5 s), then switches. Prevents "in session against wrong server" states.
-3. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
+2. **App-start orphan sweep.** Every app launch runs `cleanupOrphanedOrbitPlaylists`: lists every `__psyorbit_*` playlist the current user owns, parses the heartbeat from the comment, deletes anything with a heartbeat older than 5 minutes (or `ended: true`, or an unparseable comment). The current local session is always protected.
The 5-minute TTL is a conservative compromise: long enough to survive a brief app restart (and a session running on another device of yours), short enough that a dead session doesn't clutter the server indefinitely.
@@ -295,7 +295,7 @@ The 5-minute TTL is a conservative compromise: long enough to survive a brief ap
- **Bulk "Play All" in-session.** Dialog: "Add 14 tracks to the Orbit queue?" On confirm, appended. On cancel, no-op.
- **Single-click on song row in-session.** Swallowed; shows "Double-click to add" toast.
- **Multiple accounts on target server.** Paste flow opens an account picker modal. Keyboard-navigable.
-- **Server switch while in session.** Teardown runs before switch. Any server-resident session playlists get cleaned up by their host's next app-start sweep.
+- **Server switch while in session.** The session remains bound to its original server; changing the visible active server cannot redirect playlist reads, writes, suggestions or cleanup.
- **Initial sync race.** The guest's first tick retries on 500 ms cadence until the player state actually matches the host's last-known track (up to 2 s per attempt, then falls through with a best-effort mirror).
- **`positionAt` stale on join.** Seek fraction is clamped to [0, 0.99] — prevents `audio:ended` from firing at the very start of a join.
- **Outbox deletion mid-session** (cleanup race): host sees the guest drop out on the next sweep; guest's next heartbeat recreates the outbox if they're still connected.
diff --git a/src/features/orbit/components/OrbitJoinModal.tsx b/src/features/orbit/components/OrbitJoinModal.tsx
index a572c757..656a4c76 100644
--- a/src/features/orbit/components/OrbitJoinModal.tsx
+++ b/src/features/orbit/components/OrbitJoinModal.tsx
@@ -51,8 +51,8 @@ export default function OrbitJoinModal({ onClose }: Props) {
try {
let targetServerId = active?.id ?? '';
// Auto-switch to the link's server if the user has an account for it.
- // Multiple candidates → picker modal. switch tears down any lingering
- // orbit session.
+ // Multiple candidates → picker modal. Any existing Orbit binding remains
+ // pinned to its original server until its own lifecycle ends.
if (activeUrl !== wantUrl) {
const candidates = useAuthStore.getState().servers
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
diff --git a/src/features/orbit/components/OrbitStartModal.test.tsx b/src/features/orbit/components/OrbitStartModal.test.tsx
new file mode 100644
index 00000000..2c3eb22b
--- /dev/null
+++ b/src/features/orbit/components/OrbitStartModal.test.tsx
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithProviders } from '@/test/helpers/renderWithProviders';
+import { resetAuthStore } from '@/test/helpers/storeReset';
+import { makeServer } from '@/test/helpers/factories';
+import { useAuthStore } from '@/store/authStore';
+
+const mocks = vi.hoisted(() => ({
+ startOrbitSession: vi.fn(),
+}));
+
+vi.mock('@/features/orbit/utils/orbit', () => ({
+ buildOrbitShareLink: (serverBase: string, sid: string) => `psysonic2-orbit:${serverBase}:${sid}`,
+ generateSessionId: () => 'session-id',
+ startOrbitSession: mocks.startOrbitSession,
+}));
+vi.mock('@/features/orbit/utils/orbitNames', () => ({
+ randomOrbitSessionName: () => 'Test Orbit',
+}));
+
+import OrbitStartModal from '@/features/orbit/components/OrbitStartModal';
+
+beforeEach(() => {
+ resetAuthStore();
+ mocks.startOrbitSession.mockReset().mockResolvedValue({});
+ const first = makeServer({ id: 'srv-a', name: 'Server A', url: 'https://a.example' });
+ const second = makeServer({ id: 'srv-b', name: 'Server B', url: 'https://b.example' });
+ useAuthStore.setState({
+ servers: [first, second],
+ activeServerId: first.id,
+ libraryBrowseServerIds: [first.id, second.id],
+ });
+});
+
+describe('OrbitStartModal', () => {
+ it('starts Orbit on the server selected from the current library scope', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByRole('radio', { name: 'Server B' }));
+ await user.click(screen.getByRole('button', { name: 'Copy link & start' }));
+
+ expect(mocks.startOrbitSession).toHaveBeenCalledWith(expect.objectContaining({
+ name: 'Test Orbit',
+ sid: 'session-id',
+ serverId: 'srv-b',
+ clearQueue: false,
+ }));
+ });
+});
diff --git a/src/features/orbit/components/OrbitStartModal.tsx b/src/features/orbit/components/OrbitStartModal.tsx
index 61544a7e..d69ddfcf 100644
--- a/src/features/orbit/components/OrbitStartModal.tsx
+++ b/src/features/orbit/components/OrbitStartModal.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -12,9 +12,10 @@ import {
} from '@/features/orbit/utils/orbit';
import { randomOrbitSessionName } from '@/features/orbit/utils/orbitNames';
import { useAuthStore } from '@/store/authStore';
-import { usePlayerStore } from '@/features/playback/store/playerStore';
import { isLanUrl, serverShareBaseUrl } from '@/lib/server/serverEndpoint';
+import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
import { ORBIT_DEFAULT_MAX_USERS } from '@/features/orbit/api/orbit';
+import ServerChoiceList from '@/ui/ServerChoiceList';
interface Props { onClose: () => void; }
@@ -37,7 +38,18 @@ export default function OrbitStartModal({ onClose }: Props) {
const [hasCopied, setHasCopied] = useState(false);
const [clearQueue, setClearQueue] = useState(false);
- const [server] = useState(() => useAuthStore.getState().getActiveServer());
+ const servers = useAuthStore(state => state.servers);
+ const libraryBrowseServerIds = useAuthStore(state => state.libraryBrowseServerIds);
+ const [serverId, setServerId] = useState(() => {
+ const auth = useAuthStore.getState();
+ return auth.libraryBrowseServerIds.includes(auth.activeServerId ?? '')
+ ? auth.activeServerId ?? ''
+ : auth.libraryBrowseServerIds[0] ?? auth.activeServerId ?? '';
+ });
+ const serverOptions = servers
+ .filter(server => libraryBrowseServerIds.includes(server.id))
+ .map(server => ({ id: server.id, label: serverListDisplayLabel(server, servers) }));
+ const server = servers.find(candidate => candidate.id === serverId);
// Orbit links go to remote guests — use the share URL (public by default
// when both are set; LAN only if shareUsesLocalUrl is on). The LAN warning
// then correctly reads the address the guest will actually see.
@@ -45,10 +57,7 @@ export default function OrbitStartModal({ onClose }: Props) {
const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer');
const onLan = isLanUrl(serverBase);
- const shareLink = useMemo(
- () => buildOrbitShareLink(serverBase, sid),
- [serverBase, sid],
- );
+ const shareLink = buildOrbitShareLink(serverBase, sid);
const writeLinkToClipboard = async (): Promise => {
try {
@@ -72,6 +81,7 @@ export default function OrbitStartModal({ onClose }: Props) {
setError(null);
const trimmed = name.trim();
if (!trimmed) { setError(t('orbit.errNameRequired')); return; }
+ if (!server) { setError(t('orbit.joinErrNoUser')); return; }
if (!hasCopied) {
const ok = await writeLinkToClipboard();
@@ -80,8 +90,7 @@ export default function OrbitStartModal({ onClose }: Props) {
setBusy(true);
try {
- if (clearQueue) usePlayerStore.getState().clearQueue();
- await startOrbitSession({ name: trimmed, maxUsers, sid, serverId: server?.id });
+ await startOrbitSession({ name: trimmed, maxUsers, sid, serverId: server.id, clearQueue });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : t('orbit.errStartFailed'));
@@ -128,6 +137,24 @@ export default function OrbitStartModal({ onClose }: Props) {
{onLan ? t('orbit.tipLan') : t('orbit.tipRemote')}
+ {serverOptions.length > 1 && (
+