mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(orbit): I.6 — split utils/orbit.ts 989 → 77 LOC across 10 modules (#678)
* refactor(orbit): extract constants, helpers, and state math First extraction step of the utils/orbit.ts split. Pure, no-I/O code moves into utils/orbit/ submodules; utils/orbit.ts becomes a re-export shim so every call site outside this directory keeps importing from '../utils/orbit' unchanged. - utils/orbit/constants.ts: ORBIT_HEARTBEAT_ALIVE_MS, ORBIT_ORPHAN_TTL_MS, ORBIT_SHUFFLE_INTERVAL_MS, ORBIT_REMOVED_TTL_MS. - utils/orbit/helpers.ts: generateSessionId, serialiseOrbitState + OrbitStateTooLarge, serialiseOutboxMeta, suggestionKey, parseOutboxPlaylistName. - utils/orbit/stateMath.ts: patchOrbitState, maybeShuffleQueue, effectiveShuffleIntervalMs, computeOrbitDriftMs, applyOutboxSnapshotsToState, OutboxSnapshot type. utils/orbit.ts: 989 → 806 LOC. * refactor(orbit): extract remote I/O + share link Pull readOrbitState / writeOrbitState / writeOrbitHeartbeat / findSessionPlaylistId into utils/orbit/remote.ts. Pull parseOrbitShareLink / buildOrbitShareLink + OrbitShareLink into utils/orbit/shareLink.ts. Re-exported from the orbit.ts shim. utils/orbit.ts: 806 → 721 LOC. * refactor(orbit): extract host lifecycle Pull startOrbitSession, endOrbitSession, triggerOrbitShuffleNow, updateOrbitSettings, hostEnqueueToOrbit (and the StartOrbitArgs interface) into utils/orbit/host.ts. Re-exported from the orbit.ts shim. utils/orbit.ts: 721 → 546 LOC. * refactor(orbit): extract host moderation Pull kickOrbitParticipant, removeOrbitParticipant, and setOrbitSuggestionBlocked into utils/orbit/moderation.ts. Re-exported from the orbit.ts shim. utils/orbit.ts: 546 → 422 LOC. * refactor(orbit): extract guest lifecycle + suggest pipeline Pull joinOrbitSession / leaveOrbitSession (+ OrbitJoinError), the suggest gate (evaluateOrbitSuggestGate / OrbitSuggestGateReason / OrbitSuggestBlockedError / suggestOrbitTrack), and the host-side approve / decline reactions into utils/orbit/guest.ts. Re-exported from the orbit.ts shim. utils/orbit.ts: 422 → 244 LOC. * refactor(orbit): extract sweep + cleanup, collapse shim Pull sweepGuestOutboxes (+ private listGuestOutboxes / readOutbox) into utils/orbit/sweep.ts and cleanupOrphanedOrbitPlaylists into utils/orbit/cleanup.ts. utils/orbit.ts collapses to a pure re-export shim — every external import path stays the same, the file just lists which symbol lives in which submodule. utils/orbit.ts: 244 → 77 LOC. Full split: 989 LOC monolith → 10 single-concern modules, none over 210 LOC.
This commit is contained in:
committed by
GitHub
parent
4dc46e176a
commit
3dc50a2ef0
@@ -0,0 +1,144 @@
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import type {
|
||||
OrbitParticipant,
|
||||
OrbitQueueItem,
|
||||
OrbitState,
|
||||
} from '../../api/orbit';
|
||||
import {
|
||||
ORBIT_HEARTBEAT_ALIVE_MS,
|
||||
ORBIT_REMOVED_TTL_MS,
|
||||
ORBIT_SHUFFLE_INTERVAL_MS,
|
||||
} from './constants';
|
||||
|
||||
/** 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 ? [...state.queue, ...newItems] : state.queue,
|
||||
participants,
|
||||
removed,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user