feat(orbit): persist a reconnect breadcrumb and add host-resume

Sessions stay in-memory only; a tiny localStorage breadcrumb (session id,
playlist ids, role, server) is written on host-start / guest-join and wiped on
any clean exit, so a restart can offer to rejoin. Adds resumeOrbitSessionAsHost,
which rebinds a still-live session as host and rebuilds the in-memory
merged-suggestion set from the restored player queue so resuming never
re-enqueues already-queued tracks. The app-start orphan sweep now skips the
breadcrumb's session so a pending reconnect can't be pruned mid-flight.
This commit is contained in:
Psychotoxical
2026-06-07 22:28:39 +02:00
parent de191e45d5
commit 2c95a3499c
6 changed files with 221 additions and 5 deletions
+8
View File
@@ -22,6 +22,8 @@
export {
ORBIT_HEARTBEAT_ALIVE_MS,
ORBIT_ORPHAN_TTL_MS,
ORBIT_RECONNECT_COUNTDOWN_S,
ORBIT_RECONNECT_MAX_AGE_MS,
ORBIT_REMOVED_TTL_MS,
ORBIT_SHUFFLE_INTERVAL_MS,
} from './orbit/constants';
@@ -53,11 +55,17 @@ export {
export {
endOrbitSession,
hostEnqueueToOrbit,
resumeOrbitSessionAsHost,
startOrbitSession,
triggerOrbitShuffleNow,
updateOrbitSettings,
type StartOrbitArgs,
} from './orbit/host';
export {
clearOrbitLastSession,
readOrbitLastSession,
type OrbitLastSession,
} from './orbit/lastSession';
export {
kickOrbitParticipant,
removeOrbitParticipant,
+7 -1
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from '../../store/authStore';
import { useOrbitStore } from '../../store/orbitStore';
import { ORBIT_PLAYLIST_PREFIX, parseOrbitState } from '../../api/orbit';
import { ORBIT_ORPHAN_TTL_MS } from './constants';
import { readOrbitLastSessionSid } from './lastSession';
/**
* App-start sweep: delete our own __psyorbit_* playlists that no longer
@@ -24,6 +25,11 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
const now = Date.now();
const TTL = ORBIT_ORPHAN_TTL_MS;
const currentSid = useOrbitStore.getState().sessionId;
// Protect a session the reconnect prompt is about to offer (the local store
// is empty right after a restart, so `currentSid` alone wouldn't cover it).
// Order-independent: if reconnect declines/fails, the breadcrumb is wiped and
// the next sweep prunes it.
const reconnectSid = readOrbitLastSessionSid();
const nameRe = new RegExp(`^${ORBIT_PLAYLIST_PREFIX}([a-f0-9]+)(_from_.+__)?$`);
let deleted = 0;
@@ -41,7 +47,7 @@ export async function cleanupOrphanedOrbitPlaylists(): Promise<number> {
}
const sid = match[1];
const isOutbox = !!match[2];
if (sid === currentSid) continue;
if (sid === currentSid || sid === reconnectSid) continue;
let timestamp = 0;
let ended = false;
+11
View File
@@ -26,6 +26,17 @@ export const ORBIT_ORPHAN_TTL_MS = 5 * 60_000;
*/
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* Reconnect prompt (after an app restart mid-session): how long the countdown
* runs before auto-rejoining, and how stale the live session may be before we
* stop offering a reconnect at all. The age window is generous (a restart
* after a short break should still offer) but bounded so a long-dead session
* isn't resurrected; the app-start orphan sweep protects the breadcrumb's
* session for this whole window.
*/
export const ORBIT_RECONNECT_COUNTDOWN_S = 30;
export const ORBIT_RECONNECT_MAX_AGE_MS = 30 * 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
+7
View File
@@ -11,6 +11,7 @@ import {
} from '../../api/orbit';
import { suggestionKey } from './helpers';
import { findSessionPlaylistId, readOrbitState, writeOrbitHeartbeat } from './remote';
import { clearOrbitLastSession, persistCurrentOrbitSession } from './lastSession';
export class OrbitJoinError extends Error {
constructor(
@@ -96,6 +97,10 @@ export async function joinOrbitSession(sid: string): Promise<OrbitState> {
joinedAt: Date.now(),
});
// Drop a restart-survival breadcrumb so a crash/force-quit can offer to
// rejoin on next launch.
persistCurrentOrbitSession();
return state;
} catch (err) {
// Best-effort cleanup.
@@ -120,6 +125,8 @@ export async function leaveOrbitSession(): Promise<void> {
try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ }
}
// Clean exit → drop the restart breadcrumb.
clearOrbitLastSession();
useOrbitStore.getState().reset();
}
+81 -4
View File
@@ -1,4 +1,4 @@
import { createPlaylist, deletePlaylist } from '../../api/subsonicPlaylists';
import { createPlaylist, deletePlaylist, getPlaylists } from '../../api/subsonicPlaylists';
import { getSong } from '../../api/subsonicLibrary';
import { songToTrack } from '../playback/songToTrack';
import { useAuthStore } from '../../store/authStore';
@@ -13,8 +13,9 @@ import {
type OrbitSettings,
type OrbitState,
} from '../../api/orbit';
import { generateSessionId } from './helpers';
import { writeOrbitHeartbeat, writeOrbitState } from './remote';
import { generateSessionId, suggestionKey } from './helpers';
import { findSessionPlaylistId, readOrbitState, writeOrbitHeartbeat, writeOrbitState } from './remote';
import { clearOrbitLastSession, persistCurrentOrbitSession } from './lastSession';
export interface StartOrbitArgs {
/** Human-readable name the host chose. */
@@ -89,6 +90,10 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
joinedAt: Date.now(),
});
// Drop a restart-survival breadcrumb so a crash/force-quit can offer
// to resume hosting on next launch.
persistCurrentOrbitSession();
return state;
} catch (err) {
// Best-effort cleanup of anything we managed to create before the failure.
@@ -99,6 +104,77 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
}
}
/**
* Host: resume hosting an existing session after an app restart.
*
* Unlike {@link startOrbitSession} this creates no playlists — it re-binds the
* local store to a session that's still alive on the server. The host's own
* session + outbox playlists survive a quick restart (the orphan sweep only
* prunes them after `ORBIT_ORPHAN_TTL_MS`), and the play queue is restored from
* the persisted player store, so playback continues where it left off.
*
* Returns the re-read state on success. Throws on any gate failure (no user /
* not the host / session gone / ended) — the caller wipes the breadcrumb and
* stays idle.
*/
export async function resumeOrbitSessionAsHost(sid: string): 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 resume while phase is ${store.phase}`);
store.setPhase('starting');
try {
// 1) Session must still exist, be readable, not ended — and we must be its host.
const sessionPlaylistId = await findSessionPlaylistId(sid);
if (!sessionPlaylistId) throw new Error(`Session ${sid} not found on server`);
const state = await readOrbitState(sessionPlaylistId);
if (!state) throw new Error(`Session ${sid} has no valid state`);
if (state.ended) throw new Error(`Session ${sid} has ended`);
if (state.host !== username) throw new Error(`Not the host of session ${sid}`);
// 2) Re-locate (or recreate) our own outbox and refresh its heartbeat.
const outboxName = orbitOutboxPlaylistName(sid, username);
const existing = (await getPlaylists(true).catch(() => [])).find(p => p.name === outboxName);
const outboxPlaylistId = existing ? existing.id : (await createPlaylist(outboxName)).id;
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
// 3) Rebuild the in-memory merged-suggestion set (lost on restart) from the
// restored player queue so the resumed host tick won't re-enqueue tracks
// that are already queued. Anything already in the queue / current track
// counts as "already handled"; genuinely-pending suggestions stay pending.
const player = usePlayerStore.getState();
const inQueue = new Set<string>(player.queueItems.map(r => r.trackId));
if (player.currentTrack?.id) inQueue.add(player.currentTrack.id);
const mergedSuggestionKeys = state.queue
.filter(q => inQueue.has(q.trackId))
.map(suggestionKey);
// 4) Re-bind the store as host; the already-mounted host tick takes over.
useOrbitStore.setState({
role: 'host',
sessionId: sid,
sessionPlaylistId,
outboxPlaylistId,
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
mergedSuggestionKeys,
declinedSuggestionKeys: [],
pendingSuggestions: [],
});
persistCurrentOrbitSession();
return state;
} catch (err) {
useOrbitStore.getState().setPhase('idle');
throw err;
}
}
/**
* Host: end the session cleanly.
*
@@ -124,7 +200,8 @@ export async function endOrbitSession(): Promise<void> {
if (outboxPlaylistId) { try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } }
if (sessionPlaylistId) { try { await deletePlaylist(sessionPlaylistId); } catch { /* best-effort */ } }
// 3) Local teardown.
// 3) Local teardown. Clean exit → drop the restart breadcrumb.
clearOrbitLastSession();
useOrbitStore.getState().reset();
}
+107
View File
@@ -0,0 +1,107 @@
import { useOrbitStore, type OrbitRole } from '../../store/orbitStore';
import { useAuthStore } from '../../store/authStore';
/**
* Orbit — last-session breadcrumb (survives an app restart).
*
* The Orbit store itself is intentionally in-memory only (see orbitStore.ts):
* a session is transient and we never resurrect a stale local mirror. This
* module persists the one small thing we *do* need across a restart — enough
* to recognise "this client was in session X (as host/guest) on server Y" and
* offer a one-click rejoin on next launch.
*
* Written on host-start / guest-join, wiped on any clean exit (host end, guest
* leave, kick/remove/host-timeout, or the user declining the reconnect prompt).
* A process crash / force-quit leaves it in place — that's exactly the case the
* reconnect prompt exists for.
*/
const STORAGE_KEY = 'psysonic_orbit_last_session';
export interface OrbitLastSession {
/** Session id (8 hex). */
sid: string;
/** Navidrome playlist id of the canonical session playlist. */
sessionPlaylistId: string;
/** Navidrome playlist id of our own outbox. */
outboxPlaylistId: string;
/** Our role in the session. */
role: OrbitRole;
/** Human-readable session name (for the reconnect prompt copy). */
sessionName: string;
/** Host's Navidrome username (for the reconnect prompt copy). */
hostUsername: string;
/** Active server id at save time — reconnect is only offered on the same server. */
serverId: string;
/** Wall-clock ms the breadcrumb was written. */
savedAt: number;
}
export function saveOrbitLastSession(rec: OrbitLastSession): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(rec));
} catch {
/* quota / disabled storage — non-fatal, we just won't offer reconnect */
}
}
export function readOrbitLastSession(): OrbitLastSession | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw) as Partial<OrbitLastSession>;
if (
typeof o?.sid === 'string' &&
typeof o.sessionPlaylistId === 'string' &&
typeof o.outboxPlaylistId === 'string' &&
(o.role === 'host' || o.role === 'guest') &&
typeof o.sessionName === 'string' &&
typeof o.hostUsername === 'string' &&
typeof o.serverId === 'string' &&
typeof o.savedAt === 'number'
) {
return o as OrbitLastSession;
}
return null;
} catch {
return null;
}
}
/**
* Cheap sid-only read, used by the app-start orphan sweep to protect a
* pending-reconnect session from deletion regardless of effect ordering.
*/
export function readOrbitLastSessionSid(): string | null {
return readOrbitLastSession()?.sid ?? null;
}
export function clearOrbitLastSession(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* non-fatal */
}
}
/**
* Snapshot the currently-bound session into the breadcrumb. Called right after
* a host-start / guest-join binds the store. No-op unless a session is fully
* bound and an active server id is known.
*/
export function persistCurrentOrbitSession(): void {
const s = useOrbitStore.getState();
if (!s.role || !s.sessionId || !s.sessionPlaylistId || !s.outboxPlaylistId || !s.state) return;
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) return;
saveOrbitLastSession({
sid: s.sessionId,
sessionPlaylistId: s.sessionPlaylistId,
outboxPlaylistId: s.outboxPlaylistId,
role: s.role,
sessionName: s.state.name,
hostUsername: s.state.host,
serverId,
savedAt: Date.now(),
});
}