mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
+10
-3
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user