feat(orbit): isolate sessions to selected server

This commit is contained in:
cucadmuh
2026-07-21 14:18:54 +03:00
parent 6928f1a4aa
commit a6adc8299d
26 changed files with 443 additions and 30 deletions
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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 (132). 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.
@@ -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);
@@ -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(<OrbitStartModal onClose={vi.fn()} />);
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,
}));
});
});
@@ -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<boolean> => {
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) {
<span>{onLan ? t('orbit.tipLan') : t('orbit.tipRemote')}</span>
</div>
{serverOptions.length > 1 && (
<div className="orbit-start-modal__field">
<div className="orbit-start-modal__label">{t('orbit.labelServer')}</div>
<ServerChoiceList
value={serverId}
options={serverOptions}
onChange={(nextServerId) => {
setServerId(nextServerId);
setHasCopied(false);
setError(null);
}}
ariaLabel={t('orbit.serverAria')}
disabled={busy}
/>
<div className="orbit-start-modal__helper">{t('orbit.helperServer')}</div>
</div>
)}
<div className="orbit-start-modal__field">
<label className="orbit-start-modal__label" htmlFor="orbit-name">
{t('orbit.labelName')}
@@ -141,7 +168,7 @@ export default function OrbitStartModal({ onClose }: Props) {
onChange={e => { setName(e.target.value); setHasCopied(false); }}
onKeyDown={e => {
if (e.key !== 'Enter') return;
if (busy || !name.trim()) return;
if (busy || !name.trim() || !server) return;
e.preventDefault();
void onStart();
}}
@@ -222,7 +249,7 @@ export default function OrbitStartModal({ onClose }: Props) {
type="button"
className="btn btn-primary"
onClick={onStart}
disabled={busy || !name.trim()}
disabled={busy || !name.trim() || !server}
>
{busy
? t('orbit.btnStarting')
+8
View File
@@ -17,6 +17,11 @@ import type { OrbitState } from '@/features/orbit/api/orbit';
export type OrbitRole = 'host' | 'guest';
export interface OrbitHostScopeSnapshot {
activeServerId: string | null;
libraryBrowseServerIds: string[];
}
/** Fine-grained lifecycle phase. Drives which modal/indicator UI is visible. */
export type OrbitPhase =
/** No session bound. */
@@ -39,6 +44,8 @@ interface OrbitStore {
sessionId: string | null;
/** Saved server profile that owns every remote Orbit playlist operation. */
serverId: string | null;
/** Host-only active-server/library scope to restore when the session ends. */
hostScopeSnapshot: OrbitHostScopeSnapshot | null;
/** Monotonic local generation; invalidates async work from prior bindings. */
bindingRevision: number;
/** Navidrome playlist id of the canonical session playlist. */
@@ -106,6 +113,7 @@ const initialState = {
role: null,
sessionId: null,
serverId: null,
hostScopeSnapshot: null,
bindingRevision: 0,
sessionPlaylistId: null,
outboxPlaylistId: null,
+64 -3
View File
@@ -2,9 +2,12 @@ import { createPlaylist, deletePlaylist } from '@/lib/api/subsonicPlaylists';
import { getSongForServer } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/lib/media/songToTrack';
import { useAuthStore } from '@/store/authStore';
import { deriveLibraryBrowseServerIdsWithFallback } from '@/lib/library/libraryBrowseScope';
import { switchActiveServer } from '@/utils/server/switchActiveServer';
import {
orbitBindingIsCurrent,
orbitBindingRevisionIsCurrent,
type OrbitHostScopeSnapshot,
useOrbitStore,
} from '@/features/orbit/store/orbitStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
@@ -36,6 +39,39 @@ export interface StartOrbitArgs {
sid?: string;
/** Captured server profile shown in the start modal. */
serverId?: string;
/** Start with an empty queue instead of retaining the chosen server's tracks. */
clearQueue?: boolean;
}
function restoreHostScopeSnapshot(snapshot: OrbitHostScopeSnapshot | null): void {
if (!snapshot) return;
const auth = useAuthStore.getState();
const activeServerId = snapshot.activeServerId
&& auth.servers.some(server => server.id === snapshot.activeServerId)
? snapshot.activeServerId
: auth.activeServerId;
const libraryBrowseServerIds = deriveLibraryBrowseServerIdsWithFallback({
servers: auth.servers,
activeServerId,
libraryBrowseServerIds: snapshot.libraryBrowseServerIds,
});
const activeChanged = activeServerId !== auth.activeServerId;
const scopeChanged = libraryBrowseServerIds.length !== auth.libraryBrowseServerIds.length
|| libraryBrowseServerIds.some((id, index) => id !== auth.libraryBrowseServerIds[index]);
if (!activeChanged && !scopeChanged) return;
// Every saved server already has a live HTTP context. Restore the previous
// pointers synchronously so normal teardown and app exit do not wait on a probe.
useAuthStore.setState(state => ({
...(activeChanged ? {
activeServerId,
musicFolders: activeServerId ? state.musicFoldersByServer[activeServerId] ?? [] : [],
} : {}),
...(scopeChanged ? {
libraryBrowseServerIds,
libraryBrowseScopeVersion: state.libraryBrowseScopeVersion + 1,
} : {}),
}));
}
/**
@@ -61,12 +97,25 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
throw new Error(`Cannot start while phase is ${store.phase}`);
}
const startRevision = store.bindingRevision;
const hostScopeSnapshot: OrbitHostScopeSnapshot = {
activeServerId: auth.activeServerId,
libraryBrowseServerIds: [...auth.libraryBrowseServerIds],
};
store.setPhase('starting');
let sessionPlaylistId: string | null = null;
let outboxPlaylistId: string | null = null;
try {
if (auth.activeServerId !== serverId) {
const switched = await switchActiveServer(server);
if (!orbitBindingRevisionIsCurrent(startRevision)) throw new Error('Orbit start superseded');
if (!switched) throw new Error('Could not connect to the selected Orbit server');
}
useAuthStore.getState().setLibraryBrowseServerExclusive(serverId);
if (args.clearQueue) usePlayerStore.getState().clearQueue();
else usePlayerStore.getState().retainQueueForServer(serverId);
const sid = args.sid ?? generateSessionId();
const sessionName = orbitSessionPlaylistName(sid);
const outboxName = orbitOutboxPlaylistName(sid, username);
@@ -102,6 +151,7 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
useOrbitStore.setState({
role: 'host',
serverId,
hostScopeSnapshot,
bindingRevision: startRevision + 1,
sessionId: sid,
sessionPlaylistId,
@@ -117,7 +167,10 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
// Best-effort cleanup of anything we managed to create before the failure.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId, serverId); } catch { /* ignore */ } }
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId, serverId); } catch { /* ignore */ } }
if (orbitBindingRevisionIsCurrent(startRevision)) useOrbitStore.getState().setPhase('idle');
if (orbitBindingRevisionIsCurrent(startRevision)) {
restoreHostScopeSnapshot(hostScopeSnapshot);
useOrbitStore.getState().setPhase('idle');
}
throw err;
}
}
@@ -135,12 +188,17 @@ export async function endOrbitSession(): Promise<void> {
role,
state,
serverId,
hostScopeSnapshot,
bindingRevision,
sessionPlaylistId,
outboxPlaylistId,
} = useOrbitStore.getState();
if (role !== 'host') return;
if (!serverId) { useOrbitStore.getState().reset(); return; }
if (!serverId) {
restoreHostScopeSnapshot(hostScopeSnapshot);
useOrbitStore.getState().reset();
return;
}
// 1) Flip `ended` so guests notice on their next poll even if deletion fails.
if (sessionPlaylistId && state) {
@@ -156,7 +214,10 @@ export async function endOrbitSession(): Promise<void> {
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId, serverId); } catch { /* best-effort */ } }
// 3) Local teardown.
if (orbitBindingRevisionIsCurrent(bindingRevision)) useOrbitStore.getState().reset();
if (orbitBindingRevisionIsCurrent(bindingRevision)) {
restoreHostScopeSnapshot(hostScopeSnapshot);
useOrbitStore.getState().reset();
}
}
/**
+95 -1
View File
@@ -10,16 +10,48 @@ const mocks = vi.hoisted(() => ({
setState: vi.fn(),
phase: 'idle',
bindingRevision: 0,
activeServerId: 'srv-owner',
libraryBrowseServerIds: ['srv-owner'] as string[],
libraryBrowseScopeVersion: 0,
switchActiveServer: vi.fn(),
setLibraryBrowseServerExclusive: vi.fn(),
retainQueueForServer: vi.fn(),
clearQueue: vi.fn(),
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => ({
servers: [mocks.activeServer, { id: 'srv-other', username: 'other' }],
activeServerId: mocks.activeServerId,
libraryBrowseServerIds: mocks.libraryBrowseServerIds,
libraryBrowseScopeVersion: mocks.libraryBrowseScopeVersion,
musicFoldersByServer: {},
getActiveServer: () => mocks.activeServer,
setLibraryBrowseServerExclusive: mocks.setLibraryBrowseServerExclusive,
}),
setState: (update: object | ((state: object) => object)) => {
const current = {
servers: [mocks.activeServer, { id: 'srv-other', username: 'other' }],
activeServerId: mocks.activeServerId,
libraryBrowseServerIds: mocks.libraryBrowseServerIds,
libraryBrowseScopeVersion: mocks.libraryBrowseScopeVersion,
musicFoldersByServer: {},
};
const next = (typeof update === 'function' ? update(current) : update) as {
activeServerId?: string | null;
libraryBrowseServerIds?: string[];
libraryBrowseScopeVersion?: number;
};
if (next.activeServerId !== undefined) mocks.activeServerId = next.activeServerId ?? '';
if (next.libraryBrowseServerIds) mocks.libraryBrowseServerIds = next.libraryBrowseServerIds;
if (next.libraryBrowseScopeVersion !== undefined) {
mocks.libraryBrowseScopeVersion = next.libraryBrowseScopeVersion;
}
},
},
}));
vi.mock('@/utils/server/switchActiveServer', () => ({ switchActiveServer: mocks.switchActiveServer }));
vi.mock('@/features/orbit/store/orbitStore', () => ({
orbitBindingRevisionIsCurrent: (revision: number) => mocks.bindingRevision === revision,
orbitBindingIsCurrent: ({ bindingRevision }: { bindingRevision: number }) => (
@@ -45,7 +77,14 @@ vi.mock('@/features/orbit/utils/remote', () => ({
vi.mock('@/features/orbit/utils/transitions', () => ({
readOrbitTransitionSettings: () => ({ gaplessEnabled: true, crossfadeEnabled: false, crossfadeDuration: 0 }),
}));
vi.mock('@/features/playback/store/playerStore', () => ({ usePlayerStore: { getState: () => ({}) } }));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => ({
retainQueueForServer: mocks.retainQueueForServer,
clearQueue: mocks.clearQueue,
}),
},
}));
vi.mock('@/lib/api/subsonicLibrary', () => ({ getSongForServer: vi.fn() }));
vi.mock('@/lib/media/songToTrack', () => ({ songToTrack: vi.fn() }));
@@ -54,8 +93,20 @@ import { startOrbitSession } from '@/features/orbit/utils/host';
beforeEach(() => {
mocks.phase = 'idle';
mocks.bindingRevision = 0;
mocks.activeServerId = 'srv-owner';
mocks.libraryBrowseServerIds = ['srv-owner'];
mocks.libraryBrowseScopeVersion = 0;
mocks.activeServer.id = 'srv-owner';
mocks.activeServer.username = 'host';
mocks.switchActiveServer.mockReset().mockImplementation(async (server: { id: string }) => {
mocks.activeServerId = server.id;
return true;
});
mocks.setLibraryBrowseServerExclusive.mockReset().mockImplementation((serverId: string) => {
mocks.libraryBrowseServerIds = [serverId];
});
mocks.retainQueueForServer.mockReset();
mocks.clearQueue.mockReset();
mocks.createPlaylist.mockReset()
.mockResolvedValueOnce({ id: 'session-playlist' })
.mockResolvedValueOnce({ id: 'outbox-playlist' });
@@ -93,6 +144,49 @@ describe('startOrbitSession server ownership', () => {
serverId: 'srv-owner',
phase: 'active',
}));
expect(mocks.retainQueueForServer).toHaveBeenCalledWith('srv-owner');
});
it('switches to a selected scoped server and records the scope to restore', async () => {
mocks.activeServerId = 'srv-owner';
mocks.libraryBrowseServerIds = ['srv-owner', 'srv-other'];
await startOrbitSession({ name: 'Session', sid: 'dddd4444', serverId: 'srv-other' });
expect(mocks.switchActiveServer).toHaveBeenCalledWith(expect.objectContaining({ id: 'srv-other' }));
expect(mocks.setLibraryBrowseServerExclusive).toHaveBeenCalledWith('srv-other');
expect(mocks.retainQueueForServer).toHaveBeenCalledWith('srv-other');
expect(mocks.setState).toHaveBeenCalledWith(expect.objectContaining({
serverId: 'srv-other',
hostScopeSnapshot: {
activeServerId: 'srv-owner',
libraryBrowseServerIds: ['srv-owner', 'srv-other'],
},
}));
});
it('clears instead of pruning when the host requests an empty queue', async () => {
await startOrbitSession({
name: 'Session',
sid: 'eeee5555',
serverId: 'srv-owner',
clearQueue: true,
});
expect(mocks.clearQueue).toHaveBeenCalledTimes(1);
expect(mocks.retainQueueForServer).not.toHaveBeenCalled();
});
it('restores the previous active server and library scope when startup fails', async () => {
mocks.libraryBrowseServerIds = ['srv-owner', 'srv-other'];
mocks.createPlaylist.mockReset().mockRejectedValueOnce(new Error('create failed'));
await expect(startOrbitSession({ name: 'Session', sid: 'ffff6666', serverId: 'srv-other' }))
.rejects.toThrow('create failed');
expect(mocks.activeServerId).toBe('srv-owner');
expect(mocks.libraryBrowseServerIds).toEqual(['srv-owner', 'srv-other']);
expect(mocks.setPhase).toHaveBeenLastCalledWith('idle');
});
it('cleans up partial creation on the same captured owner', async () => {
@@ -5,6 +5,18 @@ import { useOrbitStore } from '@/features/orbit/store/orbitStore';
const mocks = vi.hoisted(() => ({
writeOrbitState: vi.fn(),
deletePlaylist: vi.fn(),
authState: {
servers: [
{ id: 'srv-old', url: 'https://old.example' },
{ id: 'srv-selected', url: 'https://selected.example' },
{ id: 'srv-new', url: 'https://new.example' },
],
activeServerId: 'srv-selected' as string | null,
libraryBrowseServerIds: ['srv-selected'] as string[],
libraryBrowseScopeVersion: 0,
musicFoldersByServer: {},
musicFolders: [],
},
}));
vi.mock('@/features/orbit/utils/remote', () => ({
@@ -20,7 +32,18 @@ vi.mock('@/lib/api/subsonicPlaylists', () => ({
}));
vi.mock('@/lib/api/subsonicLibrary', () => ({ getSongForServer: vi.fn() }));
vi.mock('@/lib/media/songToTrack', () => ({ songToTrack: vi.fn() }));
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => ({ servers: [] }) } }));
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => mocks.authState,
setState: (update: object | ((state: typeof mocks.authState) => object)) => {
Object.assign(
mocks.authState,
typeof update === 'function' ? update(mocks.authState) : update,
);
},
},
}));
vi.mock('@/utils/server/switchActiveServer', () => ({ switchActiveServer: vi.fn() }));
vi.mock('@/store/playlistMembershipStore', () => ({
usePlaylistMembershipStore: { getState: () => ({ setPlaylistSongIds: vi.fn() }) },
}));
@@ -53,7 +76,11 @@ beforeEach(() => {
outboxPlaylistId: null,
phase: 'idle',
state: null,
hostScopeSnapshot: null,
});
mocks.authState.activeServerId = 'srv-selected';
mocks.authState.libraryBrowseServerIds = ['srv-selected'];
mocks.authState.libraryBrowseScopeVersion = 0;
mocks.writeOrbitState.mockReset().mockResolvedValue(undefined);
mocks.deletePlaylist.mockReset().mockResolvedValue(undefined);
});
@@ -96,4 +123,24 @@ describe('Orbit teardown generations', () => {
bindingRevision: 2,
}));
});
it('restores the host active server and library scope before returning to idle', async () => {
bind('host', 1, 'srv-selected');
useOrbitStore.setState({
hostScopeSnapshot: {
activeServerId: 'srv-old',
libraryBrowseServerIds: ['srv-old', 'srv-selected'],
},
});
await endOrbitSession();
expect(mocks.authState.activeServerId).toBe('srv-old');
expect(mocks.authState.libraryBrowseServerIds).toEqual(['srv-old', 'srv-selected']);
expect(useOrbitStore.getState()).toEqual(expect.objectContaining({
role: null,
phase: 'idle',
hostScopeSnapshot: null,
}));
});
});
@@ -279,6 +279,41 @@ describe('reorderQueue', () => {
});
describe('mixed-server queue identity', () => {
it('retains one server and follows its current track to the new index', () => {
const other = makeTrack({ id: 'other', serverId: 'srv-a' });
const current = makeTrack({ id: 'current', serverId: 'srv-b' });
const next = makeTrack({ id: 'next', serverId: 'srv-b' });
seedQueue([other, current, next], { index: 1, currentTrack: current });
usePlayerStore.getState().retainQueueForServer('srv-b');
const state = usePlayerStore.getState();
expect(state.queueItems).toEqual([
expect.objectContaining({ serverId: 'srv-b', trackId: 'current' }),
expect.objectContaining({ serverId: 'srv-b', trackId: 'next' }),
]);
expect(state.queueIndex).toBe(0);
expect(state.currentTrack).toBe(current);
expect(state.queueServerId).toBe('srv-b');
});
it('stops and clears a current track owned by a removed server', () => {
const current = makeTrack({ id: 'current', serverId: 'srv-a' });
const retained = makeTrack({ id: 'retained', serverId: 'srv-b' });
seedQueue([current, retained], { index: 0, currentTrack: current });
usePlayerStore.setState({ isPlaying: true, currentTime: 30, progress: 0.5 });
usePlayerStore.getState().retainQueueForServer('srv-b');
const state = usePlayerStore.getState();
expect(state.queueItems).toEqual([
expect.objectContaining({ serverId: 'srv-b', trackId: 'retained' }),
]);
expect(state.currentTrack).toBeNull();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
});
it('prunes after the current owner, not the first equal raw id', () => {
const a = makeTrack({ id: 'shared', serverId: 'srv-a' });
const b = makeTrack({ id: 'shared', serverId: 'srv-b' });
@@ -110,6 +110,9 @@ export interface PlayerState {
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only.
* When `skipQueueUndo` is true, callers must push undo separately (macro rebuild). */
pruneUpcomingToCurrent: (skipQueueUndo?: boolean) => void;
/** Keep only queue items owned by one server. Used when Orbit temporarily
* constrains a mixed-server session to its selected Navidrome host. */
retainQueueForServer: (serverId: string) => void;
clearQueue: () => void;
isQueueVisible: boolean;
@@ -28,6 +28,8 @@ import {
clearQueueServerForPlayback,
ensureQueueServerPinned,
} from '@/features/playback/utils/playback/playbackServer';
import { profileIdFromQueueRef } from '@/lib/media/trackServerScope';
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
import { clearTimelineSessionHistory } from '@/features/playback/store/timelineSessionHistory';
import {
getShuffleOriginalOrder,
@@ -69,11 +71,11 @@ function seedIncoming(state: PlayerState, tracks: Track[]): void {
}
/**
* Eleven queue-mutation actions. Shared invariant: every action except
* `setRadioArtistId` pushes a queue-undo snapshot and calls
* `syncUserQueueMutationToServer` so the Navidrome `savePlayQueue` stays in sync.
* Exceptions: `enqueue`'s optional third argument **`skipQueueUndo`** and
* **`pruneUpcomingToCurrent(true)`** Lucky Mix pushes one snapshot up-front.
* Queue-mutation actions. Explicit queue edits normally push an undo snapshot
* and sync Navidrome's `savePlayQueue`. Lifecycle mutations are exceptions:
* `clearQueue` and `retainQueueForServer` are not undoable, while
* `enqueue(..., skipQueueUndo)` and `pruneUpcomingToCurrent(true)` rely on a
* caller-owned snapshot.
*/
export function createQueueMutationActions(set: SetState, get: GetState): Pick<
PlayerState,
@@ -83,6 +85,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
| 'enqueueRadio'
| 'setRadioArtistId'
| 'pruneUpcomingToCurrent'
| 'retainQueueForServer'
| 'clearQueue'
| 'reorderQueue'
| 'shuffleQueue'
@@ -342,6 +345,48 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
syncUserQueueMutationToServer(items, newItems, s.currentTrack, s.currentTime);
},
retainQueueForServer: (serverId) => {
const state = get();
const previousItems = itemsOf(state);
const targetProfileId = resolveServerIdForIndexKey(serverId) || serverId;
const fallbackProfileId = resolveServerIdForIndexKey(state.queueServerId ?? '')
|| state.queueServerId
|| useAuthStore.getState().activeServerId
|| '';
const nextItems = previousItems.filter(ref => (
(profileIdFromQueueRef(ref) || fallbackProfileId) === targetProfileId
));
const nextQueueIndex = state.currentTrack
? nextItems.findIndex(ref => queueItemRefMatchesTrack(ref, state.currentTrack!))
: -1;
const keepsCurrentTrack = nextQueueIndex >= 0 && !state.currentRadio;
const mustStop = Boolean(state.currentRadio || (state.currentTrack && !keepsCurrentTrack));
if (mustStop) get().stop();
const queueServerId = canonicalQueueServerKey(serverId) || serverId;
set({
queueItems: nextItems,
queueIndex: keepsCurrentTrack ? nextQueueIndex : 0,
queueServerId,
navidromePublicSharePageUrl: null,
...(!keepsCurrentTrack && state.currentTrack ? {
currentTrack: null,
waveformBins: null,
isPlaying: false,
progress: 0,
buffered: 0,
currentTime: 0,
} : {}),
});
syncUserQueueMutationToServer(
previousItems,
nextItems,
keepsCurrentTrack ? state.currentTrack : null,
keepsCurrentTrack ? state.currentTime : 0,
);
},
clearQueue: () => {
const previousItems = itemsOf(get());
get().stop();
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Започнете сесия — другите потребители на {{server}} ще слушат заедно и могат да предлагат песни.',
fallbackServer: 'този сървър',
labelServer: 'Сървър за сесията',
helperServer: 'Само този сървър остава в обхвата на библиотеката и споделената опашка до края на сесията. След това предишният обхват се възстановява.',
serverAria: 'Изберете сървър за тази Orbit сесия',
tipLan: 'В момента сте свързани чрез адрес в домашната мрежа. Гости извън мрежата ви не могат да се присъединят — превключете към публичен адрес на сървъра в настройките преди да започнете.',
tipRemote: 'За да могат гости извън домашната ви мрежа да се присъединят, адресът на сървъра ви трябва да е публично достъпен. Ако сървърът ви е само вътрешен, превключете преди да започнете.',
labelName: 'Име на сесията',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Starte eine Session — andere Nutzer auf {{server}} hören im Takt mit dir und können Songs vorschlagen.',
fallbackServer: 'diesem Server',
labelServer: 'Session-Server',
helperServer: 'Bis zum Ende der Session bleiben nur dieser Server im Bibliotheksbereich und seine Titel in der gemeinsamen Warteschlange. Danach wird der vorherige Bereich wiederhergestellt.',
serverAria: 'Server für diese Orbit-Session auswählen',
tipLan: 'Du bist aktuell mit einer Heimnetz-Adresse verbunden. Gäste außerhalb deines Heimnetzes können so nicht beitreten — wechsle vor dem Start in den Servereinstellungen zu einer öffentlichen Adresse.',
tipRemote: 'Damit Gäste außerhalb deines Heimnetzes beitreten können, muss deine Serveradresse öffentlich erreichbar sein. Ist dein Server nur intern verfügbar, wechsle vor dem Start zu einer öffentlichen Adresse.',
labelName: 'Sessionname',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Start a session — other users on {{server}} will listen along and can suggest tracks.',
fallbackServer: 'this server',
labelServer: 'Session server',
helperServer: 'Only this server stays in your library scope and shared queue until the session ends. Your previous scope returns afterwards.',
serverAria: 'Choose the server for this Orbit session',
tipLan: "You're currently connected via a home-network address. Guests outside your network can't join — switch to a public server address in settings before you start.",
tipRemote: 'For guests outside your home network to join, your server address has to be publicly reachable. If your server is internal only, switch before you start.',
labelName: 'Session name',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Inicia una sesión — otros usuarios en {{server}} escucharán contigo y podrán sugerir canciones.',
fallbackServer: 'este servidor',
labelServer: 'Servidor de la sesión',
helperServer: 'Solo este servidor permanece en el ámbito de tu biblioteca y en la cola compartida hasta que termine la sesión. Después se restaura el ámbito anterior.',
serverAria: 'Elige el servidor para esta sesión de Orbit',
tipLan: 'Actualmente estás conectado mediante una dirección de red local. Los invitados fuera de tu red no pueden unirse — cambia a una dirección de servidor pública en los ajustes antes de empezar.',
tipRemote: 'Para que los invitados fuera de tu red doméstica puedan unirse, la dirección de tu servidor debe ser accesible públicamente. Si tu servidor es solo interno, cambia antes de empezar.',
labelName: 'Nombre de la sesión',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Démarre une session — les autres utilisateurs de {{server}} écouteront en même temps et pourront suggérer des morceaux.',
fallbackServer: 'ce serveur',
labelServer: 'Serveur de la session',
helperServer: 'Seul ce serveur reste dans le périmètre de la bibliothèque et la file partagée jusqu’à la fin de la session. Le périmètre précédent est ensuite restauré.',
serverAria: 'Choisir le serveur pour cette session Orbit',
tipLan: 'Tu es actuellement connecté via une adresse de réseau local. Les invités hors de ton réseau ne peuvent pas rejoindre — bascule vers une adresse publique dans les paramètres avant de démarrer.',
tipRemote: 'Pour que des invités hors de ton réseau domestique puissent rejoindre, l\'adresse de ton serveur doit être publiquement accessible. Si ton serveur est interne, bascule avant de démarrer.',
labelName: 'Nom de la session',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Indíts egy munkamenetet — a(z) {{server}} többi felhasználója együtt hallgat, és számokat javasolhat.',
fallbackServer: 'ez a szerver',
labelServer: 'Munkamenet-kiszolgáló',
helperServer: 'A munkamenet végéig csak ez a kiszolgáló marad a könyvtár hatókörében és a megosztott sorban. Ezután visszaáll a korábbi hatókör.',
serverAria: 'Válaszd ki az Orbit munkamenet kiszolgálóját',
tipLan: 'Jelenleg otthoni hálózati címen keresztül csatlakozol. A hálózatodon kívüli vendégek nem tudnak csatlakozni — válts nyilvános szervercímre a beállításokban, mielőtt elindítod.',
tipRemote: 'Ahhoz, hogy az otthoni hálózatodon kívüli vendégek csatlakozhassanak, a szervercímednek nyilvánosan elérhetőnek kell lennie. Ha a szervered csak belső, válts, mielőtt elindítod.',
labelName: 'Munkamenet neve',
+4 -1
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Avvia una sessione — gli altri utenti su {{server}} ascolteranno insieme a te e potranno suggerire brani.',
fallbackServer: 'questo server',
labelServer: 'Server della sessione',
helperServer: 'Fino alla fine della sessione, solo questo server resta nellambito della libreria e nella coda condivisa. In seguito viene ripristinato lambito precedente.',
serverAria: 'Scegli il server per questa sessione Orbit',
tipLan: 'Sei connesso tramite un indirizzo di rete domestica. Gli ospiti esterni alla tua rete non potranno unirsi — passa a un indirizzo server pubblico nelle impostazioni prima di iniziare.',
tipRemote: 'Perché gli ospiti esterni alla tua rete domestica possano unirsi, il tuo server deve essere raggiungibile pubblicamente. Se il tuo server è solo interno, cambialo prima di iniziare.',
labelName: 'Nome sessione',
@@ -198,4 +201,4 @@ export const orbit = {
exitRemovedBody: '{{host}} ti ha rimosso da "{{name}}". Puoi rientrare in qualsiasi momento tramite il link di invito.',
exitEndedBody: '"{{name}}" è terminata. Speriamo ti sia divertito.',
exitOk: 'OK',
};
};
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'セッションを開始すると、{{server}}上の他のユーザーが一緒に聴き、トラックを提案できます。',
fallbackServer: 'このサーバー',
labelServer: 'セッションサーバー',
helperServer: 'セッション終了までは、このサーバーだけがライブラリの範囲と共有キューに残ります。終了後に以前の範囲へ戻ります。',
serverAria: 'この Orbit セッションのサーバーを選択',
tipLan: '現在、ホームネットワーク用アドレスで接続しています。ネットワーク外のゲストは参加できません。開始前に設定で公開サーバーアドレスへ切り替えてください。',
tipRemote: '自宅ネットワーク外のゲストが参加するには、サーバーアドレスが公開到達可能である必要があります。内部専用サーバーの場合は開始前に切り替えてください。',
labelName: 'セッション名',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Start en økt — andre brukere på {{server}} lytter med og kan foreslå spor.',
fallbackServer: 'denne serveren',
labelServer: 'Øktserver',
helperServer: 'Bare denne serveren blir værende i bibliotekomfanget og den delte køen til økten avsluttes. Deretter gjenopprettes det forrige omfanget.',
serverAria: 'Velg server for denne Orbit-økten',
tipLan: 'Du er for øyeblikket koblet til via en hjemmenettverksadresse. Gjester utenfor nettverket kan ikke bli med — bytt til en offentlig serveradresse i innstillingene før du starter.',
tipRemote: 'For at gjester utenfor hjemmenettverket skal kunne bli med, må serveradressen være offentlig tilgjengelig. Hvis serveren din bare er intern, bytt før du starter.',
labelName: 'Øktnavn',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Start een sessie — andere gebruikers op {{server}} luisteren mee en kunnen nummers voorstellen.',
fallbackServer: 'deze server',
labelServer: 'Sessieserver',
helperServer: 'Tot het einde van de sessie blijven alleen deze server in je bibliotheekbereik en zijn nummers in de gedeelde wachtrij. Daarna wordt het vorige bereik hersteld.',
serverAria: 'Kies de server voor deze Orbit-sessie',
tipLan: 'Je bent momenteel verbonden via een thuisnetwerkadres. Gasten van buiten je netwerk kunnen niet deelnemen — schakel over naar een openbaar serveradres in de instellingen voordat je start.',
tipRemote: 'Zodat gasten van buiten je thuisnetwerk kunnen deelnemen, moet je serveradres publiek bereikbaar zijn. Als je server alleen intern is, schakel om voordat je start.',
labelName: 'Sessienaam',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Rozpocznij sesję — inni użytkownicy na {{server}} będą słuchać równocześnie i mogą proponować swoje utwory.',
fallbackServer: 'ten serwer',
labelServer: 'Serwer sesji',
helperServer: 'Do zakończenia sesji tylko ten serwer pozostaje w zakresie biblioteki i we wspólnej kolejce. Potem poprzedni zakres zostanie przywrócony.',
serverAria: 'Wybierz serwer dla tej sesji Orbit',
tipLan: "Jesteś obecnie połączony przez adres sieci domowej. Goście spoza twojej sieci nie będą mogli dołączyć — przełącz się na adres serwera publicznego w ustawieniach przez rozpoczęciem.",
tipRemote: 'By goście spoza twojej sieci domowej mogli dołączyć, twój adres serwera musi być publicznie dostępny. Jeżeli twój serwer jest jedynie wewnętrzny, zmień to przed rozpoczęciem.',
labelName: 'Nazwa sesji',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Pornește o sesiune — alți utilizatori de pe {{server}} vor asculta deodată și pot sugera piese.',
fallbackServer: 'acest server',
labelServer: 'Serverul sesiunii',
helperServer: 'Până la încheierea sesiunii, doar acest server rămâne în domeniul bibliotecii și în coada partajată. Apoi domeniul anterior este restaurat.',
serverAria: 'Alege serverul pentru această sesiune Orbit',
tipLan: "Ești conectat acum printr-o adresă de rețea locală. Invitații din afara rețelei tale nu pot intra — schimbă pe o adresă publică de server în setări înainte de a începe.",
tipRemote: 'Pentru ca oaspeții din afara rețelei să poată intra, serverul tău trebuie să fie accesibil public. Dacă serverul tău este doar intern, schimbă înainte de a începe.',
labelName: 'Numele sesiunii',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: 'Начни сессию — другие пользователи на {{server}} будут слушать вместе и смогут предлагать треки.',
fallbackServer: 'этот сервер',
labelServer: 'Сервер сессии',
helperServer: 'До конца сессии в области библиотеки и общей очереди остаётся только этот сервер. После завершения прежняя область восстановится.',
serverAria: 'Выберите сервер для этой сессии Orbit',
tipLan: 'Ты сейчас подключён через адрес домашней сети. Гости вне твоей сети не смогут присоединиться — переключись на публичный адрес сервера в настройках перед стартом.',
tipRemote: 'Чтобы гости вне домашней сети могли присоединиться, адрес сервера должен быть публично доступен. Если сервер только внутренний, переключись перед стартом.',
labelName: 'Название сессии',
+3
View File
@@ -20,6 +20,9 @@ export const orbit = {
heroTitleBrand: 'Orbit',
heroSub: '开始会话 — {{server}} 上的其他用户将一起收听并可以推荐曲目。',
fallbackServer: '此服务器',
labelServer: '会话服务器',
helperServer: '会话结束前,资料库范围和共享队列中只保留此服务器。结束后会恢复之前的范围。',
serverAria: '选择此 Orbit 会话使用的服务器',
tipLan: '你当前通过本地网络地址连接。网络外的访客无法加入 — 请在启动前切换到公共服务器地址。',
tipRemote: '为了让家庭网络外的访客能够加入,你的服务器地址必须公开可达。如果你的服务器仅限内部使用,请在启动前切换。',
labelName: '会话名称',