mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
+23
@@ -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
@@ -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
@@ -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 */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user