mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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)
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
import {
|
|
ORBIT_PLAYLIST_PREFIX,
|
|
ORBIT_STATE_MAX_BYTES,
|
|
type OrbitOutboxMeta,
|
|
type OrbitQueueItem,
|
|
type OrbitState,
|
|
} from '../../api/orbit';
|
|
|
|
/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */
|
|
export function generateSessionId(): string {
|
|
const bytes = new Uint8Array(4);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
/**
|
|
* Serialise the state blob for writing into a playlist comment. Emits a
|
|
* plain JSON string. Throws when the output exceeds `ORBIT_STATE_MAX_BYTES`
|
|
* — callers should trim optional fields (oldest queue entries / kicked
|
|
* usernames) and retry, rather than write something truncated.
|
|
*/
|
|
export function serialiseOrbitState(state: OrbitState): string {
|
|
const json = JSON.stringify(state);
|
|
// Encode-length check — emoji-heavy session names could inflate UTF-8 bytes
|
|
// beyond the string's .length count.
|
|
const byteLen = new TextEncoder().encode(json).length;
|
|
if (byteLen > ORBIT_STATE_MAX_BYTES) {
|
|
throw new OrbitStateTooLarge(byteLen);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
export class OrbitStateTooLarge extends Error {
|
|
constructor(public readonly bytes: number) {
|
|
super(`Orbit state blob (${bytes} bytes) exceeds ${ORBIT_STATE_MAX_BYTES} byte budget`);
|
|
this.name = 'OrbitStateTooLarge';
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Stable per-suggestion key across reshuffles — `addedBy`, `addedAt` and
|
|
* `trackId` are all immutable once the host sweep has written them.
|
|
* Shared between the host tick and the manual-approval UI.
|
|
*/
|
|
export const suggestionKey = (q: OrbitQueueItem): string =>
|
|
`${q.addedBy}:${q.addedAt}:${q.trackId}`;
|
|
|
|
/** Extract `<username>` from a filename matching `__psyorbit_<sid>_from_<username>__`. */
|
|
export function parseOutboxPlaylistName(name: string, sid: string): string | null {
|
|
const prefix = `${ORBIT_PLAYLIST_PREFIX}${sid}_from_`;
|
|
if (!name.startsWith(prefix) || !name.endsWith('__')) return null;
|
|
const user = name.slice(prefix.length, name.length - 2);
|
|
return user.length > 0 ? user : null;
|
|
}
|