From 2c95a3499c3c0501b3067f5f5e4bfc0066f9aaa7 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:28:39 +0200 Subject: [PATCH] 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. --- src/utils/orbit.ts | 8 +++ src/utils/orbit/cleanup.ts | 8 ++- src/utils/orbit/constants.ts | 11 ++++ src/utils/orbit/guest.ts | 7 +++ src/utils/orbit/host.ts | 85 ++++++++++++++++++++++++-- src/utils/orbit/lastSession.ts | 107 +++++++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 src/utils/orbit/lastSession.ts diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 5f1e7daa..ae93bc45 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -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, diff --git a/src/utils/orbit/cleanup.ts b/src/utils/orbit/cleanup.ts index 82762e0d..9e8d0b32 100644 --- a/src/utils/orbit/cleanup.ts +++ b/src/utils/orbit/cleanup.ts @@ -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 { 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 { } const sid = match[1]; const isOutbox = !!match[2]; - if (sid === currentSid) continue; + if (sid === currentSid || sid === reconnectSid) continue; let timestamp = 0; let ended = false; diff --git a/src/utils/orbit/constants.ts b/src/utils/orbit/constants.ts index cbbd2bde..0bddb79f 100644 --- a/src/utils/orbit/constants.ts +++ b/src/utils/orbit/constants.ts @@ -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 diff --git a/src/utils/orbit/guest.ts b/src/utils/orbit/guest.ts index 90951765..346cd633 100644 --- a/src/utils/orbit/guest.ts +++ b/src/utils/orbit/guest.ts @@ -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 { 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 { try { await deletePlaylist(outboxPlaylistId); } catch { /* best-effort */ } } + // Clean exit → drop the restart breadcrumb. + clearOrbitLastSession(); useOrbitStore.getState().reset(); } diff --git a/src/utils/orbit/host.ts b/src/utils/orbit/host.ts index 0934fafe..949a5532 100644 --- a/src/utils/orbit/host.ts +++ b/src/utils/orbit/host.ts @@ -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 { + 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(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 { 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(); } diff --git a/src/utils/orbit/lastSession.ts b/src/utils/orbit/lastSession.ts new file mode 100644 index 00000000..81ef0fc0 --- /dev/null +++ b/src/utils/orbit/lastSession.ts @@ -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; + 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(), + }); +}