Files
Psychotoxical-psysonic/src/utils/orbit/stateMath.ts
T
Psychotoxical 2c9b2eeb46 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)
2026-06-22 13:33:09 +02:00

160 lines
6.4 KiB
TypeScript

import { useOrbitStore } from '../../store/orbitStore';
import type {
OrbitParticipant,
OrbitQueueItem,
OrbitState,
} 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;
if (!current) return null;
const next: OrbitState = { ...current, ...patch };
useOrbitStore.getState().setState(next);
return next;
}
/**
* Resolve the active auto-shuffle cadence in ms. Reads the host's configured
* preset from `state.settings.shuffleIntervalMin`; older sessions that lack
* the field fall back to 15 min so their tick cadence is unchanged.
*/
export function effectiveShuffleIntervalMs(state: Pick<OrbitState, 'settings'>): number {
const min = state.settings?.shuffleIntervalMin;
return typeof min === 'number' ? min * 60_000 : ORBIT_SHUFFLE_INTERVAL_MS;
}
/**
* Host helper — applies a Fisher-Yates shuffle to `state.queue` iff enough
* time has passed since the last shuffle. Pure, returns a new state object.
* `currentTrack` is never touched.
*/
export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState {
if (state.settings?.autoShuffle === false) return state;
if (nowMs - state.lastShuffle < effectiveShuffleIntervalMs(state)) return state;
if (state.queue.length < 2) {
// Still bump `lastShuffle` so the next eligible shuffle is one full
// interval away, preventing a tight retry loop right after a guest
// drops a single item in.
return { ...state, lastShuffle: nowMs };
}
const shuffled = state.queue.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return { ...state, queue: shuffled, lastShuffle: nowMs };
}
/** Drift between a guest's local playback and the host's estimated live position. */
export function computeOrbitDriftMs(state: OrbitState, guestPositionMs: number, nowMs: number = Date.now()): number {
const hostEstimated = state.positionMs + (state.isPlaying ? (nowMs - state.positionAt) : 0);
return guestPositionMs - hostEstimated;
}
export interface OutboxSnapshot {
user: string;
outboxPlaylistId: string;
/** Track IDs currently sitting in the outbox — these are the new suggestions. */
trackIds: string[];
/** Last heartbeat timestamp parsed from the outbox comment, or 0 if missing/broken. */
lastHeartbeat: number;
}
/**
* Fold sweep results into an updated `OrbitState`.
*
* - New queue items are appended to `state.queue`, with `addedBy` = user
* and `addedAt` = now. Host-authored tracks (host's own currentTrack
* progression) are handled elsewhere and don't flow through this path.
* - `participants` is rebuilt from scratch from the sweep heartbeats —
* anyone with a fresh heartbeat (< `ORBIT_HEARTBEAT_ALIVE_MS` old) and
* not in `kicked` counts as alive. Users that disappear from the sweep
* age out naturally.
*/
export function applyOutboxSnapshotsToState(
state: OrbitState,
snapshots: OutboxSnapshot[],
nowMs: number = Date.now(),
): OrbitState {
// ── Queue additions ──
// Guest outboxes are append-only from the host's POV — the host reads the
// same playlist every sweep, so we must dedupe against anything already in
// `state.queue` (or currently playing) by (user, trackId). Without this,
// every host tick re-adds every outbox entry and the pending-approval list
// balloons indefinitely. A user re-suggesting the same track after it
// lands/plays is a rare enough case to live with for now.
const existingKeys = new Set<string>(
state.queue.map(q => `${q.addedBy} ${q.trackId}`),
);
if (state.currentTrack) {
existingKeys.add(`${state.currentTrack.addedBy} ${state.currentTrack.trackId}`);
}
// Drop any new suggestion from a user the host has muted before the
// dedupe scan — they shouldn't count against the queue at all.
const blocked = new Set(state.suggestionBlocked ?? []);
const newItems: OrbitQueueItem[] = [];
for (const snap of snapshots) {
if (blocked.has(snap.user)) continue;
for (const trackId of snap.trackIds) {
const key = `${snap.user} ${trackId}`;
if (existingKeys.has(key)) continue;
existingKeys.add(key);
newItems.push({ trackId, addedBy: snap.user, addedAt: nowMs });
}
}
// ── Soft-removed list aging ──
// Drop entries older than the TTL so the list stays bounded and a long-
// expired marker doesn't kick a freshly-rejoined user back out.
const removed = (state.removed ?? []).filter(r => nowMs - r.at < ORBIT_REMOVED_TTL_MS);
const removedUsers = new Set(removed.map(r => r.user));
// ── Participants rebuild ──
// Soft-removed users stay out of `participants` even if their heartbeat is
// still fresh — gives them up to one read tick (~2.5s) to notice the
// `removed`-marker and tear down their guest hooks before the marker ages out.
const prev = new Map(state.participants.map(p => [p.user, p]));
const participants: OrbitParticipant[] = [];
for (const snap of snapshots) {
if (state.kicked.includes(snap.user)) continue;
if (removedUsers.has(snap.user)) continue;
const fresh = snap.lastHeartbeat > 0 && (nowMs - snap.lastHeartbeat) < ORBIT_HEARTBEAT_ALIVE_MS;
if (!fresh) continue;
const existing = prev.get(snap.user);
participants.push({
user: snap.user,
joinedAt: existing?.joinedAt ?? nowMs,
lastHeartbeat: snap.lastHeartbeat,
});
}
return {
...state,
queue: newItems.length > 0 ? capQueueHistory([...state.queue, ...newItems]) : state.queue,
participants,
removed,
};
}