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:
Frank Stellmacher
2026-05-14 01:08:45 +02:00
committed by GitHub
parent 4dc46e176a
commit 3dc50a2ef0
11 changed files with 1080 additions and 983 deletions
+201
View File
@@ -0,0 +1,201 @@
import { createPlaylist, deletePlaylist } from '../../api/subsonicPlaylists';
import { getSong } from '../../api/subsonicLibrary';
import { songToTrack } from '../songToTrack';
import { useAuthStore } from '../../store/authStore';
import { useOrbitStore } from '../../store/orbitStore';
import { usePlayerStore } from '../../store/playerStore';
import {
makeInitialOrbitState,
orbitOutboxPlaylistName,
orbitSessionPlaylistName,
ORBIT_DEFAULT_MAX_USERS,
type OrbitQueueItem,
type OrbitSettings,
type OrbitState,
} from '../../api/orbit';
import { generateSessionId } from './helpers';
import { writeOrbitHeartbeat, writeOrbitState } from './remote';
export interface StartOrbitArgs {
/** Human-readable name the host chose. */
name: string;
/** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */
maxUsers?: number;
/**
* Pre-generated session id. Lets the caller (e.g. the start modal) show a
* stable share-link *before* the session is actually created. Falls back
* to a fresh id when omitted.
*/
sid?: string;
}
/**
* Host: create a new session.
*
* Creates both the canonical session playlist and the host's own outbox,
* seeds the state blob + heartbeat, binds the store, sets phase to `active`.
*
* Throws if the Navidrome server isn't available or lacks a logged-in user.
* On throw the store is left in the pre-call state — nothing partially bound.
*/
export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitState> {
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new Error('No active Navidrome server / user');
const store = useOrbitStore.getState();
if (store.phase !== 'idle') {
throw new Error(`Cannot start while phase is ${store.phase}`);
}
store.setPhase('starting');
let sessionPlaylistId: string | null = null;
let outboxPlaylistId: string | null = null;
try {
const sid = args.sid ?? generateSessionId();
const sessionName = orbitSessionPlaylistName(sid);
const outboxName = orbitOutboxPlaylistName(sid, username);
// Create both playlists. Navidrome's createPlaylist returns the created
// object with its new id.
const sessionPlaylist = await createPlaylist(sessionName);
sessionPlaylistId = sessionPlaylist.id;
const outboxPlaylist = await createPlaylist(outboxName);
outboxPlaylistId = outboxPlaylist.id;
// Seed state blob + heartbeat. We use updatePlaylistMeta instead of
// separate create-with-comment because Subsonic's createPlaylist doesn't
// take a comment argument.
const state = makeInitialOrbitState({
sid,
host: username,
name: args.name,
maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS,
});
await writeOrbitState(sessionPlaylistId, state);
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
// Bind local store — session is now live.
useOrbitStore.setState({
role: 'host',
sessionId: sid,
sessionPlaylistId,
outboxPlaylistId,
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
});
return state;
} catch (err) {
// Best-effort cleanup of anything we managed to create before the failure.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } }
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* ignore */ } }
useOrbitStore.getState().setPhase('idle');
throw err;
}
}
/**
* Host: end the session cleanly.
*
* Writes `ended: true` first so any poll-in-progress from a guest sees the
* signal, then deletes both playlists and resets the local store. Each step
* is best-effort; if something's already gone server-side we still zero out
* local state so the UI returns to idle.
*/
export async function endOrbitSession(): Promise<void> {
const { role, state, sessionPlaylistId, outboxPlaylistId } = useOrbitStore.getState();
if (role !== 'host') return;
// 1) Flip `ended` so guests notice on their next poll even if deletion fails.
if (sessionPlaylistId && state) {
try {
await writeOrbitState(sessionPlaylistId, { ...state, ended: true });
} catch { /* best-effort */ }
}
// 2) Delete both playlists. Order: outbox first — if session delete fails,
// a stale session playlist with ended=true is fine; a stale outbox without
// a session is noise.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } }
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* best-effort */ } }
// 3) Local teardown.
useOrbitStore.getState().reset();
}
/**
* Host-only: force an immediate shuffle of the upcoming play queue, bump
* `lastShuffle` so the automatic 15-min timer resets, and push the new
* state to Navidrome. Ignores the `autoShuffle` setting — this is an
* explicit user action.
*/
export async function triggerOrbitShuffleNow(): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
// 1) Shuffle the host's real play queue (upcoming only).
usePlayerStore.getState().shuffleUpcomingQueue();
// 2) Shuffle the OrbitState.queue (guest-facing suggestion history) +
// bump lastShuffle so the auto-shuffle timer restarts.
const now = Date.now();
const shuffled = store.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]];
}
const next: OrbitState = { ...store.state, queue: shuffled, lastShuffle: now };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push */ }
}
/**
* Host-only: update the session settings and immediately push to Navidrome
* so guests see the change on their next poll. No-op unless the caller is
* the current host with an active session.
*/
export async function updateOrbitSettings(patch: Partial<OrbitSettings>): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
const mergedSettings: OrbitSettings = {
...(store.state.settings ?? { autoApprove: true, autoShuffle: true }),
...patch,
};
const next: OrbitState = { ...store.state, settings: mergedSettings };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push the current state anyway */ }
}
/**
* Host: add a track to the active Orbit session directly, skipping the
* outbox/approval loop guests go through. The track lands in the host's
* own play queue immediately and is attributed to the host in the
* session's suggestion history. Host-authored queue items are filtered
* out of the tick-merge pipeline so the host-tick doesn't re-insert the
* same track once it notices the new entry in `OrbitState.queue`.
*/
export async function hostEnqueueToOrbit(trackId: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) {
throw new Error('Not hosting an active Orbit session');
}
const song = await getSong(trackId);
if (!song) throw new Error('Track not found');
const track = songToTrack(song);
usePlayerStore.getState().enqueue([track]);
const item: OrbitQueueItem = { trackId, addedBy: store.state.host, addedAt: Date.now() };
const next: OrbitState = { ...store.state, queue: [...store.state.queue, item] };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push the merged state anyway */ }
}