chore(orbit): hide and auto-reap technical session playlists

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 12:23:36 +02:00
parent 23edac69ef
commit 67385c7cef
3 changed files with 116 additions and 7 deletions
+23
View File
@@ -69,6 +69,8 @@ import OrbitSessionBar from './components/OrbitSessionBar';
import OrbitStartTrigger from './components/OrbitStartTrigger'; import OrbitStartTrigger from './components/OrbitStartTrigger';
import { useOrbitHost } from './hooks/useOrbitHost'; import { useOrbitHost } from './hooks/useOrbitHost';
import { useOrbitGuest } from './hooks/useOrbitGuest'; import { useOrbitGuest } from './hooks/useOrbitGuest';
import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit';
import { useOrbitStore } from './store/orbitStore';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
import { version } from '../package.json'; import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useConnectionStatus } from './hooks/useConnectionStatus';
@@ -245,6 +247,14 @@ function AppShell() {
}; };
}, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]); }, [isLoggedIn, activeServerId, setMusicFolders, setEntityRatingSupport]);
// Orbit orphan sweep — delete our own leftover session / outbox playlists
// from crashed or force-closed sessions so they don't clutter the ND
// playlist view. Runs once per login; safe and best-effort.
useEffect(() => {
if (!isLoggedIn || !activeServerId) return;
void cleanupOrphanedOrbitPlaylists();
}, [isLoggedIn, activeServerId]);
// Reset scroll position on route change (main viewport is overlay scroll) // Reset scroll position on route change (main viewport is overlay scroll)
useEffect(() => { useEffect(() => {
document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 });
@@ -952,6 +962,19 @@ function TauriEventBridge() {
await invoke('pause_rendering').catch(() => {}); await invoke('pause_rendering').catch(() => {});
await getCurrentWindow().hide(); await getCurrentWindow().hide();
} else { } else {
// Clean up an active Orbit session before we go down — leaving
// the session playlists behind would litter the server. Capped at
// 1500 ms so a slow server can't keep the app hanging on quit; the
// next launch's orphan sweep is the safety net for anything that
// didn't make it out in time.
const role = useOrbitStore.getState().role;
if (role === 'host' || role === 'guest') {
const teardown = role === 'host' ? endOrbitSession() : leaveOrbitSession();
await Promise.race([
teardown.catch(() => {}),
new Promise(r => setTimeout(r, 1500)),
]);
}
await invoke('exit_app'); await invoke('exit_app');
} }
}); });
+7 -2
View File
@@ -994,9 +994,14 @@ export function buildDownloadUrl(id: string): string {
} }
// ─── Playlists ──────────────────────────────────────────────── // ─── Playlists ────────────────────────────────────────────────
export async function getPlaylists(): Promise<SubsonicPlaylist[]> { export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view'); const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view');
return data.playlists?.playlist ?? []; const all = data.playlists?.playlist ?? [];
// Orbit session + outbox playlists are technical internals. They're `public`
// so guests can reach them, which means they leak into every UI picker and
// even into the Navidrome web client. Filter them out of every UI call;
// orbit's own sweep passes `includeOrbit=true`.
return includeOrbit ? all : all.filter(p => !p.name.startsWith('__psyorbit_'));
} }
export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> { export async function getPlaylist(id: string): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] }> {
+86 -5
View File
@@ -365,7 +365,7 @@ export function buildOrbitShareLink(serverBase: string, sid: string, slug?: stri
export async function findSessionPlaylistId(sid: string): Promise<string | null> { export async function findSessionPlaylistId(sid: string): Promise<string | null> {
const target = orbitSessionPlaylistName(sid); const target = orbitSessionPlaylistName(sid);
try { try {
const all = await getPlaylists(); const all = await getPlaylists(true);
const hit = all.find(p => p.name === target); const hit = all.find(p => p.name === target);
return hit?.id ?? null; return hit?.id ?? null;
} catch { return null; } } catch { return null; }
@@ -435,7 +435,7 @@ export async function joinOrbitSession(sid: string): Promise<OrbitState> {
// Guard against a stale outbox from a previous abandoned join attempt — // Guard against a stale outbox from a previous abandoned join attempt —
// if one exists under the same name, reuse its id instead of creating // if one exists under the same name, reuse its id instead of creating
// a duplicate (Navidrome allows duplicate names but it'd leak). // a duplicate (Navidrome allows duplicate names but it'd leak).
const existing = (await getPlaylists().catch(() => [])).find(p => p.name === outboxName); const existing = (await getPlaylists(true).catch(() => [])).find(p => p.name === outboxName);
if (existing) { if (existing) {
outboxPlaylistId = existing.id; outboxPlaylistId = existing.id;
} else { } else {
@@ -514,6 +514,78 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
* out of the tick-merge pipeline so the host-tick doesn't re-insert the * 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`. * 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> { export async function hostEnqueueToOrbit(trackId: string): Promise<void> {
const store = useOrbitStore.getState(); const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) { if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) {
@@ -557,7 +629,7 @@ function parseOutboxPlaylistName(name: string, sid: string): string | null {
* Skips the host's own outbox — that's heartbeat-only, not a suggestion channel. * 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 }>> { async function listGuestOutboxes(sid: string, hostUsername: string): Promise<Array<{ id: string; name: string; user: string }>> {
const all = await getPlaylists().catch(() => []); const all = await getPlaylists(true).catch(() => []);
const result: Array<{ id: string; name: string; user: string }> = []; const result: Array<{ id: string; name: string; user: string }> = [];
for (const p of all) { for (const p of all) {
const user = parseOutboxPlaylistName(p.name, sid); const user = parseOutboxPlaylistName(p.name, sid);
@@ -619,6 +691,15 @@ export async function sweepGuestOutboxes(sid: string, hostUsername: string): Pro
/** How long we consider a heartbeat still fresh. Longer than the guest tick so a single missed beat is tolerated. */ /** 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; 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. */ /** Shuffle cadence — queue is reshuffled once every interval. */
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000; export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
@@ -684,7 +765,7 @@ export async function kickOrbitParticipant(username: string): Promise<void> {
// carrying outbox ids in the state blob just for this operation. // carrying outbox ids in the state blob just for this operation.
const outboxName = orbitOutboxPlaylistName(sid, username); const outboxName = orbitOutboxPlaylistName(sid, username);
try { try {
const all = await getPlaylists(); const all = await getPlaylists(true);
const hit = all.find(p => p.name === outboxName); const hit = all.find(p => p.name === outboxName);
if (hit) await deletePlaylist(hit.id); if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ } } catch { /* best-effort */ }
@@ -731,7 +812,7 @@ export async function removeOrbitParticipant(username: string): Promise<void> {
// playlist (they'll create a new one on rejoin via joinOrbitSession). // playlist (they'll create a new one on rejoin via joinOrbitSession).
const outboxName = orbitOutboxPlaylistName(sid, username); const outboxName = orbitOutboxPlaylistName(sid, username);
try { try {
const all = await getPlaylists(); const all = await getPlaylists(true);
const hit = all.find(p => p.name === outboxName); const hit = all.find(p => p.name === outboxName);
if (hit) await deletePlaylist(hit.id); if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ } } catch { /* best-effort */ }