Files
psysonic/src/utils/orbit.ts
T
Psychotoxical 67385c7cef chore(orbit): hide and auto-reap technical session playlists
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:23:36 +02:00

893 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createPlaylist,
updatePlaylist,
updatePlaylistMeta,
deletePlaylist,
getPlaylist,
getPlaylists,
getSong,
} from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import {
makeInitialOrbitState,
orbitOutboxPlaylistName,
orbitSessionPlaylistName,
parseOrbitState,
ORBIT_DEFAULT_MAX_USERS,
ORBIT_PLAYLIST_PREFIX,
ORBIT_STATE_MAX_BYTES,
type OrbitOutboxMeta,
type OrbitParticipant,
type OrbitQueueItem,
type OrbitState,
} from '../api/orbit';
/**
* Orbit — host-side lifecycle primitives.
*
* Phase 2 scope: creating / ending a session, serialising state into the
* canonical playlist comment, writing a heartbeat into the host's own
* outbox. No guest-side logic here.
*
* All functions talk to Navidrome through the existing Subsonic wrappers;
* no new transport work.
*/
// ── ID generation ───────────────────────────────────────────────────────
/** 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('');
}
/**
* Turn a human session name into a URL-safe slug. Ignores non-ASCII
* characters so the output is stable across locales and safe in a
* `psysonic2://` link. Returns an empty string for names that slugify
* to nothing — callers should fall back to a slug-less link in that case.
*/
export function slugifyOrbitName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
// ── Serialisation ───────────────────────────────────────────────────────
/**
* 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 serialiseOutboxMeta(meta: OrbitOutboxMeta): string {
return JSON.stringify(meta);
}
// ── Remote reads ────────────────────────────────────────────────────────
/** Pull + parse the canonical state from the session playlist. Null on miss or parse error. */
export async function readOrbitState(sessionPlaylistId: string): Promise<OrbitState | null> {
try {
const { playlist } = await getPlaylist(sessionPlaylistId);
if (!playlist.comment) return null;
let raw: unknown;
try { raw = JSON.parse(playlist.comment); } catch { return null; }
return parseOrbitState(raw);
} catch { return null; }
}
// ── Remote writes ───────────────────────────────────────────────────────
/**
* Write the state blob into the session playlist's comment.
*
* NOTE (design doc "known rough edges"): `updatePlaylist.view` with name +
* comment MUST preserve the track list. Confirmed to work on Navidrome via
* observation in PR #256 (playlist-editor); if a future Navidrome release
* ever changes that, we need to switch to `updatePlaylist` with the full
* track list echoed back.
*/
export async function writeOrbitState(
sessionPlaylistId: string,
state: OrbitState,
): Promise<void> {
const comment = serialiseOrbitState(state);
const name = orbitSessionPlaylistName(state.sid);
await updatePlaylistMeta(sessionPlaylistId, name, comment, /* public */ true);
}
/**
* Write a heartbeat into the given outbox playlist's comment. Host keeps one
* for symmetry + to feed its own presence into the participants pipeline
* (used from Phase 4 onwards when guests look for host liveness).
*/
export async function writeOrbitHeartbeat(
outboxPlaylistId: string,
outboxName: string,
): Promise<void> {
const meta: OrbitOutboxMeta = { ts: Date.now() };
await updatePlaylistMeta(outboxPlaylistId, outboxName, serialiseOutboxMeta(meta), /* public */ true);
}
// ── Host lifecycle ──────────────────────────────────────────────────────
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();
}
// ── Store helpers used by the tick hook ────────────────────────────────
/** 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;
}
/**
* 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.
*/
/**
* 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 */ }
}
export async function updateOrbitSettings(patch: Partial<import('../api/orbit').OrbitSettings>): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
const mergedSettings: import('../api/orbit').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 */ }
}
// ── Share link ──────────────────────────────────────────────────────────
export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/';
export interface OrbitShareLink {
/** Base URL of the Navidrome server (decoded). */
serverBase: string;
/** Session id (8 hex chars). */
sid: string;
}
/**
* Parse a `psysonic2://orbit/<server-b64>/<sid>` link. Returns null on any
* shape mismatch — the caller decides what to do (show error toast etc.).
* Accepts both the `psysonic2://` prefix and a bare string if the OS-level
* handler has already stripped the scheme.
*/
export function parseOrbitShareLink(url: string): OrbitShareLink | null {
if (!url) return null;
const stripped = url.startsWith(ORBIT_SHARE_SCHEME)
? url.slice(ORBIT_SHARE_SCHEME.length)
: url.startsWith('orbit/') ? url.slice('orbit/'.length) : null;
if (stripped == null) return null;
const slash = stripped.indexOf('/');
if (slash <= 0) return null;
const serverB64 = stripped.slice(0, slash);
const tail = stripped.slice(slash + 1).replace(/\/+$/, '');
// Tail is either `<sid>` or `<slug>-<sid>` — the SID is always the
// terminal 8-hex group. The slug is purely cosmetic for the sender.
const m = tail.match(/(?:^|-)([0-9a-f]{8})$/i);
if (!m) return null;
const sid = m[1].toLowerCase();
let serverBase: string;
try {
serverBase = atob(serverB64);
} catch { return null; }
try { new URL(serverBase); } catch { return null; }
return { serverBase, sid };
}
/**
* Build a share link for a live session. When `slug` is provided (and
* non-empty) it is prepended to the SID for a friendlier-looking URL
* — the parser strips it on the receiving side.
*/
export function buildOrbitShareLink(serverBase: string, sid: string, slug?: string): string {
const tail = slug && slug.length > 0 ? `${slug}-${sid}` : sid;
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${tail}`;
}
// ── Playlist lookup ─────────────────────────────────────────────────────
/**
* Find the Navidrome playlist id of a session given its session id.
* Scans the user's visible playlist list — Navidrome exposes public
* playlists from other users, so a guest can find the host's session.
*/
export async function findSessionPlaylistId(sid: string): Promise<string | null> {
const target = orbitSessionPlaylistName(sid);
try {
const all = await getPlaylists(true);
const hit = all.find(p => p.name === target);
return hit?.id ?? null;
} catch { return null; }
}
// ── Guest lifecycle ─────────────────────────────────────────────────────
export class OrbitJoinError extends Error {
constructor(
public readonly reason: 'not-found' | 'ended' | 'full' | 'kicked' | 'no-user' | 'server-error',
message: string,
) {
super(message);
this.name = 'OrbitJoinError';
}
}
/**
* Guest: join an existing session by id.
*
* Assumes the user is already authenticated against the correct Navidrome
* server — the caller's UI layer handles the magic-sharing flow when the
* encoded server in the share link doesn't match the active one.
*
* Side effects on success:
* - creates this user's outbox playlist and writes a first heartbeat
* - binds `useOrbitStore` to the session (role = guest, phase = active)
* - populates the store's `state` mirror with the last-known blob
*
* Throws `OrbitJoinError` on any gate failure; caller shows an error
* modal and does nothing else.
*/
export async function joinOrbitSession(sid: string): Promise<OrbitState> {
const server = useAuthStore.getState().getActiveServer();
const username = server?.username;
if (!username) throw new OrbitJoinError('no-user', 'No active Navidrome server / user');
const store = useOrbitStore.getState();
if (store.phase !== 'idle') {
throw new OrbitJoinError('server-error', `Cannot join while phase is ${store.phase}`);
}
store.setPhase('joining');
let outboxPlaylistId: string | null = null;
try {
// 1) Locate the session playlist and read its state blob.
const sessionPlaylistId = await findSessionPlaylistId(sid);
if (!sessionPlaylistId) throw new OrbitJoinError('not-found', `Session ${sid} not found on server`);
const state = await readOrbitState(sessionPlaylistId);
if (!state) throw new OrbitJoinError('not-found', `Session ${sid} has no valid state`);
if (state.ended) throw new OrbitJoinError('ended', `Session ${sid} has ended`);
// 2) Gate: not kicked, not full. Note: host isn't in `participants` itself,
// so `maxUsers` counts guests only.
if (state.kicked.includes(username)) {
throw new OrbitJoinError('kicked', `You were removed from session ${sid}`);
}
const alreadyInside = state.participants.some(p => p.user === username);
if (!alreadyInside && state.participants.length >= state.maxUsers) {
throw new OrbitJoinError('full', `Session ${sid} is full (${state.maxUsers}/${state.maxUsers})`);
}
// 3) Create our outbox + first heartbeat.
const outboxName = orbitOutboxPlaylistName(sid, username);
// Guard against a stale outbox from a previous abandoned join attempt —
// if one exists under the same name, reuse its id instead of creating
// a duplicate (Navidrome allows duplicate names but it'd leak).
const existing = (await getPlaylists(true).catch(() => [])).find(p => p.name === outboxName);
if (existing) {
outboxPlaylistId = existing.id;
} else {
const outbox = await createPlaylist(outboxName);
outboxPlaylistId = outbox.id;
}
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
// 4) Bind the local store. The host's next poll will register us in
// `participants` — we don't self-mutate the canonical state.
useOrbitStore.setState({
role: 'guest',
sessionId: sid,
sessionPlaylistId,
outboxPlaylistId,
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
});
return state;
} catch (err) {
// Best-effort cleanup.
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* ignore */ } }
useOrbitStore.getState().setPhase('idle');
throw err;
}
}
/**
* Guest: leave a session voluntarily.
*
* Deletes our outbox (so the host stops counting us after its next sweep)
* and resets the local store. Best-effort on each step. Does NOT touch the
* canonical session playlist — that's the host's property.
*/
export async function leaveOrbitSession(): Promise<void> {
const { role, outboxPlaylistId } = useOrbitStore.getState();
if (role !== 'guest') return;
if (outboxPlaylistId) {
try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ }
}
useOrbitStore.getState().reset();
}
// ── Track pipeline ──────────────────────────────────────────────────────
/**
* Guest: suggest a track to the session.
*
* Appends the track to our own outbox playlist. The host's next sweep will
* consume it and publish the authoritative queue update in the state blob.
* No state mutation here — the guest never touches canonical state.
*/
export async function suggestOrbitTrack(trackId: string): Promise<void> {
const { role, outboxPlaylistId, sessionId } = useOrbitStore.getState();
if (role !== 'guest') throw new Error('Not joined to a session as a guest');
if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound');
// Read current outbox contents and append — createPlaylist.view with
// playlistId replaces songs wholesale, so we need to carry the existing
// list along.
const { songs } = await getPlaylist(outboxPlaylistId);
const nextIds = [...songs.map(s => s.id), trackId];
await updatePlaylist(outboxPlaylistId, nextIds, songs.length);
}
/**
* 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`.
*/
/**
* App-start sweep: delete our own __psyorbit_* playlists that no longer
* belong to a live session. "Live" means either this device's current
* session (never touch) or one whose heartbeat is less than
* `ORBIT_ORPHAN_TTL_MS` old (could be a session on another device of
* ours). Anything older — including unparseable / comment-less entries —
* is a leftover from a crash / force-close / network blip and gets
* removed so it doesn't clutter the Navidrome playlist view.
*
* Runs best-effort; individual failures are swallowed. Returns the count
* of playlists actually deleted, for logging.
*/
export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
const username = useAuthStore.getState().getActiveServer()?.username;
if (!username) return 0;
const all = await getPlaylists(true).catch(() => [] as Awaited<ReturnType<typeof getPlaylists>>);
const now = Date.now();
const TTL = ORBIT_ORPHAN_TTL_MS;
const currentSid = useOrbitStore.getState().sessionId;
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`);
let deleted = 0;
for (const p of all) {
if (!p.name.startsWith(ORBIT_PLAYLIST_PREFIX)) continue;
// Only touch our own — Navidrome rejects deletes on foreign playlists anyway.
if (p.owner && p.owner !== username) continue;
const match = p.name.match(nameRe);
// Not one we recognise — assume corrupt, prune.
if (!match) {
try { await deletePlaylist(p.id); deleted++; } catch { /* best-effort */ }
continue;
}
const sid = match[1];
const isOutbox = !!match[2];
if (sid === currentSid) continue;
let timestamp = 0;
let ended = false;
if (p.comment) {
try {
const parsed = JSON.parse(p.comment);
if (isOutbox) {
if (parsed && typeof parsed.ts === 'number') timestamp = parsed.ts;
} else {
const state = parseOrbitState(parsed);
if (state) {
timestamp = state.positionAt ?? 0;
ended = state.ended === true;
}
}
} catch { /* unparseable → treat as dead */ }
}
// Fall back to Navidrome's `changed` timestamp when there's no
// orbit-authored heartbeat in the comment — saves us from deleting a
// playlist that was just created seconds ago.
if (timestamp === 0 && p.changed) {
const parsed = Date.parse(p.changed);
if (!isNaN(parsed)) timestamp = parsed;
}
const stale = timestamp === 0 || (now - timestamp > TTL);
if (ended || stale) {
try { await deletePlaylist(p.id); deleted++; } catch { /* best-effort */ }
}
}
return deleted;
}
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 */ }
}
// ── Host-side outbox sweep ──────────────────────────────────────────────
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;
}
/** Extract `<username>` from a filename matching `__psyorbit_<sid>_from_<username>__`. */
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;
}
/**
* Host: list all guest outbox playlists for the current session.
* Skips the host's own outbox — that's heartbeat-only, not a suggestion channel.
*/
async function listGuestOutboxes(sid: string, hostUsername: string): Promise<Array<{ id: string; name: string; user: string }>> {
const all = await getPlaylists(true).catch(() => []);
const result: Array<{ id: string; name: string; user: string }> = [];
for (const p of all) {
const user = parseOutboxPlaylistName(p.name, sid);
if (!user || user === hostUsername) continue;
result.push({ id: p.id, name: p.name, user });
}
return result;
}
/**
* Host: read one outbox's contents (suggested tracks + heartbeat ts).
*/
async function readOutbox(playlistId: string): Promise<{ trackIds: string[]; lastHeartbeat: number }> {
try {
const { playlist, songs } = await getPlaylist(playlistId);
let ts = 0;
if (playlist.comment) {
try {
const meta = JSON.parse(playlist.comment) as Partial<OrbitOutboxMeta>;
if (typeof meta.ts === 'number') ts = meta.ts;
} catch { /* malformed — treat as no heartbeat */ }
}
return { trackIds: songs.map(s => s.id), lastHeartbeat: ts };
} catch {
return { trackIds: [], lastHeartbeat: 0 };
}
}
/**
* Host: sweep every guest outbox once.
*
* - Collects suggested track IDs from each outbox (returns them so the
* caller can wire them into the state queue with `addedBy` = user).
* - Captures the latest heartbeat ts per user for the participants list.
* - Clears the outbox track list after reading — a single-pass consume
* semantic: once the host has seen a track, the guest doesn't need to
* show it as "pending" any longer. The outbox's heartbeat comment is
* left untouched because the guest's own heartbeat hook keeps refreshing it.
*
* Returns a list of snapshots, one per live guest outbox. Errors on
* individual outboxes are swallowed — best-effort.
*/
export async function sweepGuestOutboxes(sid: string, hostUsername: string): Promise<OutboxSnapshot[]> {
const outboxes = await listGuestOutboxes(sid, hostUsername);
const snaps: OutboxSnapshot[] = [];
for (const ob of outboxes) {
const { trackIds, lastHeartbeat } = await readOutbox(ob.id);
snaps.push({ user: ob.user, outboxPlaylistId: ob.id, trackIds, lastHeartbeat });
if (trackIds.length > 0) {
// Clear the outbox tracks. Leaves the heartbeat comment untouched.
try { await updatePlaylist(ob.id, [], trackIds.length); } catch { /* best-effort */ }
}
}
return snaps;
}
// ── State-blob construction from sweep results ─────────────────────────
/** How long we consider a heartbeat still fresh. Longer than the guest tick so a single missed beat is tolerated. */
export const ORBIT_HEARTBEAT_ALIVE_MS = 30_000;
/**
* Grace window for the app-start orphan sweep. Has to be comfortably
* larger than the outbox heartbeat interval (10 s) and the session state
* tick (2.5 s) so a session running on the user's other device doesn't
* get deleted on a transient slow tick. 2× ALIVE window is the minimum
* sane value.
*/
export const ORBIT_ORPHAN_TTL_MS = 60_000;
/** Shuffle cadence — queue is reshuffled once every interval. */
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* How long a soft-`removed` marker stays in the state blob. Long enough for
* the affected guest's 2.5 s read tick to surface the modal even after a
* one-tick miss; short enough that the marker doesn't bloat state if the
* guest never reconnects.
*/
export const ORBIT_REMOVED_TTL_MS = 60_000;
/**
* 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 < ORBIT_SHUFFLE_INTERVAL_MS) return state;
if (state.queue.length < 2) {
// Still bump `lastShuffle` so the next eligible shuffle is 15 min 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;
}
// ── Host-side moderation ────────────────────────────────────────────────
/**
* Host: kick a participant by username.
*
* Appends the user to `kicked`, removes them from `participants`, deletes
* their outbox playlist (so a fresh re-create is recognised as a fresh
* attempt the gate blocks), and writes the new state immediately so the
* kicked guest notices on their very next poll rather than waiting for
* the regular sweep tick.
*
* Ignored if not the host, or if the session isn't active.
*/
export async function kickOrbitParticipant(username: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
const state = store.state;
const sessionPlaylistId = store.sessionPlaylistId;
const sid = store.sessionId;
if (!state || !sessionPlaylistId || !sid) return;
if (username === state.host) return; // host can't self-kick
if (state.kicked.includes(username)) return; // already kicked
// 1) Delete the victim's outbox, best-effort. Finding it by name avoids
// carrying outbox ids in the state blob just for this operation.
const outboxName = orbitOutboxPlaylistName(sid, username);
try {
const all = await getPlaylists(true);
const hit = all.find(p => p.name === outboxName);
if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ }
// 2) Update state: append kick, drop from participants. Also strip any
// pending soft-`removed` marker for the same user — the permanent ban
// supersedes it.
const nextState: OrbitState = {
...state,
kicked: [...state.kicked, username],
participants: state.participants.filter(p => p.user !== username),
removed: (state.removed ?? []).filter(r => r.user !== username),
};
useOrbitStore.getState().setState(nextState);
try {
await writeOrbitState(sessionPlaylistId, nextState);
} catch { /* best-effort; next host tick will retry via its normal push */ }
}
/**
* Host: soft-remove a participant by username.
*
* Like `kickOrbitParticipant`, but does NOT add the user to `kicked` —
* instead writes a short-lived entry to `removed`. The affected guest sees
* it on their next state-read tick and is shown a "you were removed" exit
* modal, but they are free to re-join immediately via the invite link.
*
* The marker ages out after `ORBIT_REMOVED_TTL_MS` in `applyOutboxSnapshotsToState`.
*
* Ignored if not the host, target is the host, target is permanently
* kicked, or the session isn't active.
*/
export async function removeOrbitParticipant(username: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
const state = store.state;
const sessionPlaylistId = store.sessionPlaylistId;
const sid = store.sessionId;
if (!state || !sessionPlaylistId || !sid) return;
if (username === state.host) return;
if (state.kicked.includes(username)) return;
// 1) Delete outbox so the guest's next heartbeat-write hits a missing
// playlist (they'll create a new one on rejoin via joinOrbitSession).
const outboxName = orbitOutboxPlaylistName(sid, username);
try {
const all = await getPlaylists(true);
const hit = all.find(p => p.name === outboxName);
if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ }
// 2) Update state: drop from participants, append fresh `removed` marker.
// Filter any prior marker for the same user so we always carry the latest ts.
const now = Date.now();
const nextState: OrbitState = {
...state,
participants: state.participants.filter(p => p.user !== username),
removed: [
...(state.removed ?? []).filter(r => r.user !== username),
{ user: username, at: now },
],
};
useOrbitStore.getState().setState(nextState);
try {
await writeOrbitState(sessionPlaylistId, nextState);
} catch { /* best-effort */ }
}
/**
* 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 ──
const newItems: OrbitQueueItem[] = [];
for (const snap of snapshots) {
for (const trackId of snap.trackIds) {
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,
};
}