mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
2c9b2eeb46
* 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)
160 lines
6.4 KiB
TypeScript
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,
|
|
};
|
|
}
|