mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user