fix(orbit): cleanup regex, state-blob budget, radio top-up lockout (#1155)

* fix(orbit): stop cleanup sweep from deleting live sessions on other devices

The orphan-cleanup regex anchored the trailing `__` inside the optional
`_from_…` group, so the canonical session playlist name
(`__psyorbit_<sid>__`) never matched. It then fell into the
"unrecognised → prune" branch, which deletes unconditionally and bypasses
the orphan-TTL grace that exists precisely to protect a session running on
the user's other device. Opening Psysonic on a second device wiped the
first device's live session out from under its guests.

Move the trailing `__` outside the optional group so both the session and
outbox names match; the existing sid/TTL/ended logic then applies as
intended. Add a behavioural test covering keep-fresh / prune-stale /
prune-ended / skip-current / outbox / corrupt / foreign-owner cases.

* fix(orbit): keep host publishing when the state blob grows past budget

OrbitState.queue (the suggestion/attribution history) was append-only at
the sweep-fold step and never bounded. On a long session it grew until the
serialised blob exceeded ORBIT_STATE_MAX_BYTES; serialiseOrbitState then
threw, writeOrbitState's caller swallowed the error and retried the same
over-budget state every tick, so the host silently stopped publishing and
guests froze into a host-timeout.

Two layers:
- Cap queue at ORBIT_QUEUE_HISTORY_LIMIT in applyOutboxSnapshotsToState,
  evicting oldest-by-addedAt (robust to the periodic queue shuffle). The
  dropped tracks have long since played, so their attribution/dedupe entry
  is dead weight.
- Add serialiseOrbitStateForWire: a budget-aware serialiser that sheds
  oldest history, then the play-queue tail, on a copy before giving up.
  writeOrbitState now uses it, so a transient over-budget tick degrades the
  published blob instead of taking the host offline. Local store state is
  untouched.

Tested: history cap eviction order, wire-trim byte budget + oldest-first
drop, play-queue fallback, and within-budget passthrough.

* fix(orbit): lock out the proactive radio top-up during a session

The ≤2-remaining radio top-up in runNext lacked the isInOrbitSession()
guard that its infinite-queue sibling has at both entry and resolution.
A guest who joined with residual radioAdded tracks (before the first
syncToHost replaces the queue) could fire it, appending up to 10 unrelated
radio tracks and trimming queue history — drifting the guest off the host's
playlist.

Add the guard at entry and re-check inside the resolution .then(), mirroring
the infinite-queue branch; the existing finally() still clears the fetching
flag. The queue-exhausted radio fallback already returns early in Orbit, so
only this mid-queue path was unguarded.

* docs(changelog): add Orbit cleanup / state-budget / radio fixes (#1155)

* docs(changelog): move Orbit fixes to bottom of Fixed section (#1155)
This commit is contained in:
Psychotoxical
2026-06-22 13:33:09 +02:00
committed by GitHub
parent bd43ea5240
commit 2c9b2eeb46
11 changed files with 475 additions and 7 deletions
+18
View File
@@ -206,6 +206,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
### Orbit — opening the app on another device could end a live session
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)**
* Starting Psysonic on a second device while you were hosting or in an Orbit session could delete that session's playlist, dropping your guests as if you had ended it. The start-up cleanup that clears leftover Orbit playlists now leaves a session that is still live on another device alone.
### Orbit — long sessions could quietly stop updating for guests
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)**
* During a long Orbit session the host could silently stop broadcasting, leaving guests frozen until they timed out. The shared session data is now kept within its size limit (trimming the oldest suggestion history first), so the host keeps updating instead of stalling.
### Orbit — radio could add unrelated tracks for guests
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)**
* A guest who joined while radio was playing could have unrelated radio tracks appended to their queue, drifting them out of sync with the host. Automatic radio top-up is now paused while you are in an Orbit session.
## [1.48.1] - 2026-06-15
## Fixed
+10 -3
View File
@@ -111,9 +111,12 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
}).catch(() => {}).finally(() => { setInfiniteQueueFetching(false); });
}
}
// Proactively top up radio tracks when ≤ 2 remain — always, regardless
// of infinite queue setting.
if (nextRef.radioAdded && !isRadioFetching()) {
// Proactively top up radio tracks when ≤ 2 remain — independent of the
// infinite-queue setting, but still skipped in Orbit: the radio top-up
// appends unrelated tracks and trims queue history, which would drift a
// guest off the host's playlist (same rationale as the infinite-queue
// branch above).
if (nextRef.radioAdded && !isRadioFetching() && !isInOrbitSession()) {
const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length;
if (remainingRadio <= 2) {
// H2: nextTrack may be a placeholder if its ref is still cold — empty
@@ -133,6 +136,10 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
setRadioFetching(true);
Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)])
.then(([similar, top]) => {
// Re-check — the user may have joined an Orbit session between
// scheduling this fetch and its resolution (mirrors the
// infinite-queue branch). The finally() still clears the flag.
if (isInOrbitSession()) return;
const existingIds = new Set(get().queueItems.map(r => r.trackId));
// Lead with similar (other artists) for variety; top tracks
// of the upcoming artist are only a fallback when similar
+83
View File
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Control points for the test.
const { inOrbit, getSimilarSongs2, getTopSongs } = vi.hoisted(() => ({
inOrbit: { value: false },
getSimilarSongs2: vi.fn(() => Promise.resolve([])),
getTopSongs: vi.fn(() => Promise.resolve([])),
}));
vi.mock('../api/subsonicArtists', () => ({ getSimilarSongs2, getTopSongs }));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(() => Promise.resolve()) }));
vi.mock('./orbitSession', () => ({ isInOrbitSession: () => inOrbit.value }));
vi.mock('./authStore', () => ({
useAuthStore: { getState: () => ({ infiniteQueueEnabled: false }) },
}));
vi.mock('./radioSessionState', () => ({
addRadioSessionSeen: vi.fn(),
getCurrentRadioArtistId: () => null,
hasRadioSessionSeen: () => false,
isRadioFetching: () => false,
setRadioFetching: vi.fn(),
}));
vi.mock('./infiniteQueueState', () => ({
isInfiniteQueueFetching: () => false,
setInfiniteQueueFetching: vi.fn(),
}));
vi.mock('./engineState', () => ({ setIsAudioPaused: vi.fn() }));
vi.mock('./skipStarRating', () => ({ applySkipStarOnManualNext: vi.fn() }));
vi.mock('../utils/library/queueTrackView', () => ({
resolveQueueTrack: (ref: { trackId: string }) => ({
id: ref.trackId,
artistId: 'a1',
artist: 'Artist',
}),
}));
vi.mock('../utils/playback/buildInfiniteQueueCandidates', () => ({
buildInfiniteQueueCandidates: vi.fn(() => Promise.resolve([])),
}));
vi.mock('../utils/playback/songToTrack', () => ({ songToTrack: (s: unknown) => s }));
vi.mock('../utils/playback/playbackServer', () => ({ ensureQueueServerPinned: () => null }));
vi.mock('../utils/library/queueTrackResolver', () => ({ seedQueueResolver: vi.fn() }));
vi.mock('../utils/library/queueItemRef', () => ({ toQueueItemRefs: () => [] }));
import { runNext } from './nextAction';
function fakeGet() {
// index 0 → next is the radioAdded ref at index 1; nothing radio ahead of it,
// so the ≤2-remaining proactive top-up is eligible.
const queueItems = [
{ trackId: 't0', radioAdded: true },
{ trackId: 't1', radioAdded: true },
{ trackId: 't2', radioAdded: false },
];
return {
queueItems,
queueIndex: 0,
repeatMode: 'off' as const,
currentTrack: { id: 't0', artistId: 'a1', artist: 'Artist', radioAdded: true },
playTrack: vi.fn(),
};
}
beforeEach(() => {
inOrbit.value = false;
getSimilarSongs2.mockClear();
getTopSongs.mockClear();
});
describe('runNext — radio proactive top-up Orbit lockout', () => {
it('fires the radio top-up when not in an Orbit session', () => {
const get = fakeGet as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(getSimilarSongs2).toHaveBeenCalledTimes(1);
});
it('skips the radio top-up while in an Orbit session', () => {
inOrbit.value = true;
const get = fakeGet as unknown as () => never;
runNext(vi.fn(), get, /* manual */ false);
expect(getSimilarSongs2).not.toHaveBeenCalled();
expect(getTopSongs).not.toHaveBeenCalled();
});
});
+156
View File
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ORBIT_PLAYLIST_PREFIX,
makeInitialOrbitState,
orbitOutboxPlaylistName,
orbitSessionPlaylistName,
type OrbitState,
} from '../../api/orbit';
import { ORBIT_ORPHAN_TTL_MS } from './constants';
const { getPlaylists, deletePlaylist } = vi.hoisted(() => ({
getPlaylists: vi.fn(),
deletePlaylist: vi.fn(),
}));
const { authState, orbitState } = vi.hoisted(() => ({
authState: { username: 'me' as string | undefined },
orbitState: { sessionId: null as string | null },
}));
vi.mock('../../api/subsonicPlaylists', () => ({ getPlaylists, deletePlaylist }));
vi.mock('../../store/authStore', () => ({
useAuthStore: {
getState: () => ({
getActiveServer: () => (authState.username ? { username: authState.username } : undefined),
}),
},
}));
vi.mock('../../store/orbitStore', () => ({
useOrbitStore: { getState: () => ({ sessionId: orbitState.sessionId }) },
}));
import { cleanupOrphanedOrbitPlaylists } from './cleanup';
type FakePlaylist = {
id: string;
name: string;
owner?: string;
comment?: string;
changed?: string;
};
/** A session-playlist comment whose heartbeat (`positionAt`) is `ageMs` old. */
function sessionComment(sid: string, ageMs: number, ended = false): string {
const state: OrbitState = makeInitialOrbitState({ sid, host: 'me', name: 'sesh' });
state.positionAt = Date.now() - ageMs;
if (ended) state.ended = true;
return JSON.stringify(state);
}
/** An outbox comment whose heartbeat `ts` is `ageMs` old. */
function outboxComment(ageMs: number): string {
return JSON.stringify({ ts: Date.now() - ageMs });
}
beforeEach(() => {
authState.username = 'me';
orbitState.sessionId = null;
deletePlaylist.mockReset().mockResolvedValue(undefined);
});
afterEach(() => {
getPlaylists.mockReset();
});
async function runWith(playlists: FakePlaylist[]): Promise<string[]> {
getPlaylists.mockResolvedValue(playlists);
await cleanupOrphanedOrbitPlaylists();
return deletePlaylist.mock.calls.map(c => c[0] as string);
}
describe('cleanupOrphanedOrbitPlaylists', () => {
it('keeps a fresh session playlist from another device (regression for the regex bug)', async () => {
const sid = 'aaaa1111';
const deleted = await runWith([
{ id: 'p1', name: orbitSessionPlaylistName(sid), owner: 'me', comment: sessionComment(sid, 1_000) },
]);
expect(deleted).toEqual([]);
});
it('deletes a stale session playlist past the orphan TTL', async () => {
const sid = 'bbbb2222';
const deleted = await runWith([
{
id: 'p2',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual(['p2']);
});
it('deletes a session the host explicitly ended even when fresh', async () => {
const sid = 'cccc3333';
const deleted = await runWith([
{
id: 'p3',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, 1_000, /* ended */ true),
},
]);
expect(deleted).toEqual(['p3']);
});
it('never touches this device\'s current session', async () => {
const sid = 'dddd4444';
orbitState.sessionId = sid;
const deleted = await runWith([
// Stale heartbeat, but it's *our* live session → must be skipped.
{
id: 'p4',
name: orbitSessionPlaylistName(sid),
owner: 'me',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual([]);
});
it('keeps a fresh outbox but prunes a stale one', async () => {
const sid = 'eeee5555';
const deleted = await runWith([
{ id: 'fresh', name: orbitOutboxPlaylistName(sid, 'bob'), owner: 'me', comment: outboxComment(1_000) },
{
id: 'stale',
name: orbitOutboxPlaylistName(sid, 'eve'),
owner: 'me',
comment: outboxComment(ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual(['stale']);
});
it('prunes an unrecognisable __psyorbit_* playlist', async () => {
const deleted = await runWith([
{ id: 'junk', name: `${ORBIT_PLAYLIST_PREFIX}not-a-real-name`, owner: 'me' },
]);
expect(deleted).toEqual(['junk']);
});
it('ignores playlists owned by another user', async () => {
const sid = 'ffff6666';
const deleted = await runWith([
{
id: 'foreign',
name: orbitSessionPlaylistName(sid),
owner: 'someone-else',
comment: sessionComment(sid, ORBIT_ORPHAN_TTL_MS + 60_000),
},
]);
expect(deleted).toEqual([]);
});
});
+6 -1
View File
@@ -25,7 +25,12 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
const TTL = ORBIT_ORPHAN_TTL_MS;
const currentSid = useOrbitStore.getState().sessionId;
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`);
// The trailing `__` is part of *both* the session name (`__psyorbit_<sid>__`)
// and the outbox name (`__psyorbit_<sid>_from_<user>__`), so it must sit
// outside the optional `_from_…` group. Keeping it inside (the old bug) meant
// the bare session name never matched and fell into the unconditional-prune
// branch below — deleting live sessions on the user's other devices.
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+)?__$`);
let deleted = 0;
for (const p of all) {
+11
View File
@@ -26,6 +26,17 @@ export const ORBIT_ORPHAN_TTL_MS = 5 * 60_000;
*/
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* Upper bound on `OrbitState.queue` (the suggestion/attribution history).
* The list is append-only at the sweep-fold step, so without a cap a long
* session grows it without limit until the serialised blob blows past
* `ORBIT_STATE_MAX_BYTES` and the host silently stops publishing. We keep the
* most-recently-added entries (oldest dropped first) — those cover the
* upcoming play queue, which is all the attribution lookup needs. The wire
* serialiser trims further per-write if the blob still overflows.
*/
export const ORBIT_QUEUE_HISTORY_LIMIT = 64;
/**
* How long a soft-`removed` marker stays in the state blob. Long enough for
* the affected guest's 2.5 s read tick to surface the modal even after a
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
ORBIT_STATE_MAX_BYTES,
makeInitialOrbitState,
parseOrbitState,
type OrbitQueueItem,
type OrbitState,
} from '../../api/orbit';
import { OrbitStateTooLarge, serialiseOrbitState, serialiseOrbitStateForWire } from './helpers';
function baseState(): OrbitState {
return makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' });
}
function makeQueue(n: number): OrbitQueueItem[] {
// addedAt == index, so "oldest" is the lowest addedAt.
return Array.from({ length: n }, (_, i) => ({
trackId: `track-${i}-${'x'.repeat(40)}`,
addedBy: `user${i % 10}`,
addedAt: i,
}));
}
function byteLen(s: string): number {
return new TextEncoder().encode(s).length;
}
describe('serialiseOrbitStateForWire', () => {
it('passes a within-budget state through untouched', () => {
const state = { ...baseState(), queue: makeQueue(5) };
expect(serialiseOrbitStateForWire(state)).toBe(serialiseOrbitState(state));
});
it('trims oldest suggestions until the blob fits the byte budget', () => {
const state = { ...baseState(), queue: makeQueue(300) };
// Sanity: the untrimmed state really is over budget.
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed).not.toBeNull();
const retained = parsed!.queue;
expect(retained.length).toBeGreaterThan(0);
expect(retained.length).toBeLessThan(300);
// The dropped entries are the oldest — every retained addedAt is a
// contiguous suffix ending at the newest (299).
const addedAts = retained.map(q => q.addedAt);
expect(Math.max(...addedAts)).toBe(299);
expect(Math.min(...addedAts)).toBe(300 - retained.length);
});
it('falls back to trimming the play queue once history is exhausted', () => {
const playQueue = Array.from({ length: 400 }, (_, i) => ({
trackId: `pq-${i}-${'y'.repeat(40)}`,
addedBy: `user${i % 10}`,
}));
const state: OrbitState = { ...baseState(), queue: [], playQueue, playQueueTotal: playQueue.length };
expect(() => serialiseOrbitState(state)).toThrow(OrbitStateTooLarge);
const wire = serialiseOrbitStateForWire(state);
expect(byteLen(wire)).toBeLessThanOrEqual(ORBIT_STATE_MAX_BYTES);
const parsed = parseOrbitState(JSON.parse(wire));
expect(parsed!.queue).toEqual([]);
expect((parsed!.playQueue ?? []).length).toBeLessThan(400);
});
});
+45
View File
@@ -37,6 +37,51 @@ export class OrbitStateTooLarge extends Error {
}
}
function trySerialise(state: OrbitState): string | null {
try {
return serialiseOrbitState(state);
} catch (e) {
if (e instanceof OrbitStateTooLarge) return null;
throw e;
}
}
/**
* Serialise the state blob for the wire, shedding the least-important data
* instead of throwing when it would exceed the byte budget. Without this a
* single over-budget tick makes `writeOrbitState` throw, the host swallows it
* and retries the same too-large state forever — guests freeze and time out.
*
* Sheds, in order: oldest attribution history (`queue`), then the tail of the
* published `playQueue`. Operates on copies — the caller's local store keeps
* full state; only the published blob shrinks, which is all guests consume.
* If even a minimal blob overflows (pathological session name / participant
* list) it throws `OrbitStateTooLarge` as before, so the caller still logs.
*/
export function serialiseOrbitStateForWire(state: OrbitState): string {
const direct = trySerialise(state);
if (direct !== null) return direct;
// Drop oldest suggestions first — they're the least useful for attribution.
const queue = [...state.queue].sort((a, b) => a.addedAt - b.addedAt);
while (queue.length > 0) {
queue.shift();
const out = trySerialise({ ...state, queue });
if (out !== null) return out;
}
// Queue exhausted and still too large — shorten the published play queue.
const playQueue = [...(state.playQueue ?? [])];
while (playQueue.length > 0) {
playQueue.pop();
const out = trySerialise({ ...state, queue: [], playQueue });
if (out !== null) return out;
}
// Even the minimal blob overflows — surface it so the host tick logs it.
return serialiseOrbitState({ ...state, queue: [], playQueue: [] });
}
export function serialiseOutboxMeta(meta: OrbitOutboxMeta): string {
return JSON.stringify(meta);
}
+2 -2
View File
@@ -5,7 +5,7 @@ import {
type OrbitOutboxMeta,
type OrbitState,
} from '../../api/orbit';
import { serialiseOrbitState, serialiseOutboxMeta } from './helpers';
import { serialiseOrbitStateForWire, serialiseOutboxMeta } from './helpers';
/** Pull + parse the canonical state from the session playlist. Null on miss or parse error. */
export async function readOrbitState(sessionPlaylistId: string): Promise<OrbitState | null> {
@@ -31,7 +31,7 @@ export async function writeOrbitState(
sessionPlaylistId: string,
state: OrbitState,
): Promise<void> {
const comment = serialiseOrbitState(state);
const comment = serialiseOrbitStateForWire(state);
const name = orbitSessionPlaylistName(state.sid);
await updatePlaylistMeta(sessionPlaylistId, name, comment, /* public */ true);
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../store/orbitStore', () => ({
useOrbitStore: { getState: () => ({ state: null, setState: vi.fn() }) },
}));
import { makeInitialOrbitState, type OrbitQueueItem, type OrbitState } from '../../api/orbit';
import { ORBIT_QUEUE_HISTORY_LIMIT } from './constants';
import { applyOutboxSnapshotsToState, type OutboxSnapshot } from './stateMath';
function stateWithQueue(queue: OrbitQueueItem[]): OrbitState {
return { ...makeInitialOrbitState({ sid: 'aaaa1111', host: 'host', name: 'sesh' }), queue };
}
describe('applyOutboxSnapshotsToState — queue history cap', () => {
it('drops the oldest entries when new suggestions push past the limit', () => {
// A full history (addedAt 0…limit-1) plus 10 brand-new suggestions.
const existing: OrbitQueueItem[] = Array.from({ length: ORBIT_QUEUE_HISTORY_LIMIT }, (_, i) => ({
trackId: `old-${i}`,
addedBy: 'old',
addedAt: i,
}));
const state = stateWithQueue(existing);
const now = 1_000_000;
const snapshots: OutboxSnapshot[] = [
{
user: 'bob',
outboxPlaylistId: 'ob',
trackIds: Array.from({ length: 10 }, (_, i) => `new-${i}`),
lastHeartbeat: now,
},
];
const next = applyOutboxSnapshotsToState(state, snapshots, now);
expect(next.queue.length).toBe(ORBIT_QUEUE_HISTORY_LIMIT);
// All 10 new suggestions survive…
for (let i = 0; i < 10; i++) {
expect(next.queue.some(q => q.trackId === `new-${i}`)).toBe(true);
}
// …and the 10 oldest were evicted.
for (let i = 0; i < 10; i++) {
expect(next.queue.some(q => q.trackId === `old-${i}`)).toBe(false);
}
// The youngest retained "old" entries are still present.
expect(next.queue.some(q => q.trackId === `old-${ORBIT_QUEUE_HISTORY_LIMIT - 1}`)).toBe(true);
});
it('leaves a sub-limit queue untouched', () => {
const state = stateWithQueue([{ trackId: 't0', addedBy: 'old', addedAt: 0 }]);
const now = 1_000_000;
const next = applyOutboxSnapshotsToState(
state,
[{ user: 'bob', outboxPlaylistId: 'ob', trackIds: ['t1'], lastHeartbeat: now }],
now,
);
expect(next.queue.map(q => q.trackId)).toEqual(['t0', 't1']);
});
});
+16 -1
View File
@@ -6,10 +6,25 @@ import type {
} from '../../api/orbit';
import {
ORBIT_HEARTBEAT_ALIVE_MS,
ORBIT_QUEUE_HISTORY_LIMIT,
ORBIT_REMOVED_TTL_MS,
ORBIT_SHUFFLE_INTERVAL_MS,
} from './constants';
/**
* Keep `OrbitState.queue` bounded. Drops the oldest suggestions (by `addedAt`)
* once the history exceeds the limit — `queue` is periodically shuffled, so a
* positional trim could discard recent entries; ordering by `addedAt` keeps
* the trim honest. The dropped tracks have long since played, so losing their
* attribution / dedupe entry is harmless.
*/
function capQueueHistory(queue: OrbitQueueItem[]): OrbitQueueItem[] {
if (queue.length <= ORBIT_QUEUE_HISTORY_LIMIT) return queue;
return [...queue]
.sort((a, b) => a.addedAt - b.addedAt)
.slice(queue.length - ORBIT_QUEUE_HISTORY_LIMIT);
}
/** Merge a patch into the store's state blob, keeping nullability. */
export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
const current = useOrbitStore.getState().state;
@@ -137,7 +152,7 @@ export function applyOutboxSnapshotsToState(
return {
...state,
queue: newItems.length > 0 ? [...state.queue, ...newItems] : state.queue,
queue: newItems.length > 0 ? capQueueHistory([...state.queue, ...newItems]) : state.queue,
participants,
removed,
};