diff --git a/CHANGELOG.md b/CHANGELOG.md index 0595df94..ea575e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -221,41 +221,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse. * Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags. -### Orbit — opening the app on another device could end a live session +### Orbit — session reliability fixes -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)** +**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)** -* Starting Psysonic on a second device while you were hosting or in an Orbit session could delete that session's playlist, dropping your guests as if you had ended it. The start-up cleanup that clears leftover Orbit playlists now leaves a session that is still live on another device alone. - -### Orbit — long sessions could quietly stop updating for guests - -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)** - -* During a long Orbit session the host could silently stop broadcasting, leaving guests frozen until they timed out. The shared session data is now kept within its size limit (trimming the oldest suggestion history first), so the host keeps updating instead of stalling. - -### Orbit — radio could add unrelated tracks for guests - -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)** - -* A guest who joined while radio was playing could have unrelated radio tracks appended to their queue, drifting them out of sync with the host. Automatic radio top-up is now paused while you are in an Orbit session. - -### Orbit — auto-approve could switch on unexpectedly in older sessions - -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1157](https://github.com/Psychotoxical/psysonic/pull/1157)** - -* In a long-running Orbit session created by an older build, toggling auto-shuffle could silently also turn on auto-approve, so guest suggestions started playing without the host approving them. The two settings are now independent again. - -### Orbit — sessions could go over their guest limit - -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1157](https://github.com/Psychotoxical/psysonic/pull/1157)** - -* When several people joined at the same moment, a session could end up with more guests than its limit allowed. The host now enforces the limit, keeping the earliest joiners. - -### Orbit — an accepted suggestion could occasionally be dropped - -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1157](https://github.com/Psychotoxical/psysonic/pull/1157)** - -* Under load the host's updates could overlap and overwrite each other, occasionally losing a guest suggestion that had just been picked up. Host updates are now serialised so none are lost. +* Opening Psysonic on a second device no longer deletes a session that is still live on another device. +* Long sessions keep updating for guests instead of silently stalling once the shared state grew too large. +* Radio no longer adds unrelated tracks to a guest's queue mid-session. +* Auto-shuffle and auto-approve are independent again — toggling one in an older session no longer flips the other. +* A session is kept within its guest limit even when several people join at once. +* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server. +* Pasted invites are rejected unless they point at a normal http/https server address. ## [1.48.1] - 2026-06-15 diff --git a/src/components/OrbitExitModal.tsx b/src/components/OrbitExitModal.tsx index e9ee4dd8..811860b4 100644 --- a/src/components/OrbitExitModal.tsx +++ b/src/components/OrbitExitModal.tsx @@ -19,7 +19,6 @@ export default function OrbitExitModal() { const { t } = useTranslation(); const phase = useOrbitStore(s => s.phase); const errorMessage = useOrbitStore(s => s.errorMessage); - const role = useOrbitStore(s => s.role); const sessionName = useOrbitStore(s => s.state?.name); const hostName = useOrbitStore(s => s.state?.host); @@ -31,7 +30,10 @@ export default function OrbitExitModal() { const onOk = async () => { try { - if (role === 'guest') await leaveOrbitSession(); + // Read role fresh from the store — the keydown effect binds once per + // open (deps [isOpen]) and would otherwise act on a stale role if it + // changed while the modal was up. + if (useOrbitStore.getState().role === 'guest') await leaveOrbitSession(); else useOrbitStore.getState().reset(); } catch { useOrbitStore.getState().reset(); diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index 018a01d2..2c3c216e 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -9,7 +9,13 @@ import { applyOrbitTransitionSettings, saveGuestTransitionsOnce, restoreGuestTransitions, + ensureTrackInOutbox, + planPendingResends, + forgetPendingSuggestion, + resetPendingResendState, } from '../utils/orbit'; +import { showToast } from '../utils/ui/toast'; +import i18n from '../i18n'; import { estimateLivePosition, type OrbitState } from '../api/orbit'; import { pushOrbitEvent } from '../utils/orbitDiag'; import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat'; @@ -220,6 +226,26 @@ export function useOrbitGuest(): void { for (const q of (state.playQueue ?? [])) landed.add(q.trackId); if (state.currentTrack) landed.add(state.currentTrack.trackId); useOrbitStore.getState().reconcilePendingSuggestions(landed); + landed.forEach(forgetPendingSuggestion); + + // Mitigate the outbox lost-update race: a suggestion the host hasn't + // recorded (absent from state.queue, where every *received* submission + // lands) past a grace window was likely wiped by a racing sweep-clear + // — re-send it (the host dedupes, so this is idempotent). Give up + + // toast on ones that never land so the row doesn't hang forever. + const stillPending = useOrbitStore.getState().pendingSuggestions; + if (stillPending.length > 0 && outboxPlaylistId) { + const recorded = new Set(state.queue.map(q => q.trackId)); + const plan = planPendingResends(stillPending, recorded); + for (const trackId of plan.resend) { + void ensureTrackInOutbox(outboxPlaylistId, trackId).catch(() => {}); + } + if (plan.giveUp.length > 0) { + useOrbitStore.getState().reconcilePendingSuggestions(new Set(plan.giveUp)); + plan.giveUp.forEach(forgetPendingSuggestion); + showToast(i18n.t('orbit.toastSuggestLost'), 3500, 'error'); + } + } } // Host signalled session end: surface via `phase`, let the UI handle @@ -374,6 +400,7 @@ export function useOrbitGuest(): void { if (timer !== null) window.clearTimeout(timer); // Leaving / session ended → give the user their own transition prefs back. restoreGuestTransitions(); + resetPendingResendState(); }; }, [active, sessionPlaylistId]); diff --git a/src/locales/de/orbit.ts b/src/locales/de/orbit.ts index 19e3ae4a..d8a0b63b 100644 --- a/src/locales/de/orbit.ts +++ b/src/locales/de/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Jetzt mischen', toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen', toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange', + toastSuggestLost: 'Deine Suggestion hat den Host womöglich nicht erreicht', toastShuffled: 'Warteschlange gemischt', toastJoined: 'Session beigetreten', toastLoginFirst: 'Erst anmelden, um einer Session beizutreten', diff --git a/src/locales/en/orbit.ts b/src/locales/en/orbit.ts index 49a72332..cd08d81a 100644 --- a/src/locales/en/orbit.ts +++ b/src/locales/en/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Shuffle now', toastSuggested: '{{user}} suggested "{{title}}"', toastSuggestedMany: '{{count}} new suggestions in the queue', + toastSuggestLost: 'Your suggestion may not have reached the host', toastShuffled: 'Queue shuffled', toastJoined: 'Joined session', toastLoginFirst: 'Log in before joining a session', diff --git a/src/locales/es/orbit.ts b/src/locales/es/orbit.ts index fbc27850..01cc2615 100644 --- a/src/locales/es/orbit.ts +++ b/src/locales/es/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Mezclar ahora', toastSuggested: '{{user}} sugirió "{{title}}"', toastSuggestedMany: '{{count}} nuevas sugerencias en la cola', + toastSuggestLost: 'Es posible que tu sugerencia no haya llegado al anfitrión', toastShuffled: 'Cola mezclada', toastJoined: 'Te has unido a la sesión', toastLoginFirst: 'Inicia sesión antes de unirte a una sesión', diff --git a/src/locales/fr/orbit.ts b/src/locales/fr/orbit.ts index 7caa47f7..ac8f7d3e 100644 --- a/src/locales/fr/orbit.ts +++ b/src/locales/fr/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Mélanger maintenant', toastSuggested: '{{user}} a suggéré « {{title}} »', toastSuggestedMany: '{{count}} nouvelles suggestions dans la file', + toastSuggestLost: 'Votre suggestion n\'a peut-être pas atteint l\'hôte', toastShuffled: 'File mélangée', toastJoined: 'Session rejointe', toastLoginFirst: 'Connecte-toi avant de rejoindre une session', diff --git a/src/locales/hu/orbit.ts b/src/locales/hu/orbit.ts index c6e751cf..92bd8f4e 100644 --- a/src/locales/hu/orbit.ts +++ b/src/locales/hu/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Keverés most', toastSuggested: '{{user}} javasolta: „{{title}}"', toastSuggestedMany: '{{count}} új javaslat a sorban', + toastSuggestLost: 'A javaslatod lehet, hogy nem jutott el a házigazdához', toastShuffled: 'A sor megkeverve', toastJoined: 'Csatlakoztál a munkamenethez', toastLoginFirst: 'Jelentkezz be, mielőtt csatlakozol egy munkamenethez', diff --git a/src/locales/ja/orbit.ts b/src/locales/ja/orbit.ts index 78795bbe..b9107548 100644 --- a/src/locales/ja/orbit.ts +++ b/src/locales/ja/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: '今すぐシャッフル', toastSuggested: '{{user}} が "{{title}}" を提案しました', toastSuggestedMany: 'キューに {{count}} 件の新しい提案', + toastSuggestLost: 'あなたの提案がホストに届かなかった可能性があります', toastShuffled: 'キューをシャッフルしました', toastJoined: 'セッションに参加しました', toastLoginFirst: 'セッション参加前にログインしてください', diff --git a/src/locales/nb/orbit.ts b/src/locales/nb/orbit.ts index 673e8274..3aa3180e 100644 --- a/src/locales/nb/orbit.ts +++ b/src/locales/nb/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Stokk nå', toastSuggested: '{{user}} foreslo "{{title}}"', toastSuggestedMany: '{{count}} nye forslag i køen', + toastSuggestLost: 'Forslaget ditt nådde kanskje ikke verten', toastShuffled: 'Kø stokket', toastJoined: 'Ble med i økt', toastLoginFirst: 'Logg inn før du blir med i en økt', diff --git a/src/locales/nl/orbit.ts b/src/locales/nl/orbit.ts index 63553dfa..0eb2238a 100644 --- a/src/locales/nl/orbit.ts +++ b/src/locales/nl/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Nu shufflen', toastSuggested: '{{user}} stelde "{{title}}" voor', toastSuggestedMany: '{{count}} nieuwe suggesties in de wachtrij', + toastSuggestLost: 'Je suggestie heeft de host mogelijk niet bereikt', toastShuffled: 'Wachtrij geshuffled', toastJoined: 'Deelgenomen aan sessie', toastLoginFirst: 'Log eerst in voordat je aan een sessie deelneemt', diff --git a/src/locales/ro/orbit.ts b/src/locales/ro/orbit.ts index deec6dc6..0619209f 100644 --- a/src/locales/ro/orbit.ts +++ b/src/locales/ro/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: 'Amestecă acum', toastSuggested: '{{user}} a sugerat "{{title}}"', toastSuggestedMany: '{{count}} noi sugestii în coadă', + toastSuggestLost: 'Sugestia ta s-ar putea să nu fi ajuns la gazdă', toastShuffled: 'Coadă amestecată', toastJoined: 'A intrat în sesiune', toastLoginFirst: 'Log in before joining a session', diff --git a/src/locales/ru/orbit.ts b/src/locales/ru/orbit.ts index 20b5c6ca..7144b461 100644 --- a/src/locales/ru/orbit.ts +++ b/src/locales/ru/orbit.ts @@ -145,6 +145,7 @@ export const orbit = { settingShuffleNow: 'Перемешать сейчас', toastSuggested: '{{user}} предложил «{{title}}»', toastSuggestedMany: '{{count}} новых предложений в очереди', + toastSuggestLost: 'Возможно, ваше предложение не дошло до хоста', toastShuffled: 'Очередь перемешана', toastJoined: 'Присоединился к сессии', toastLoginFirst: 'Войди, прежде чем присоединяться к сессии', diff --git a/src/locales/zh/orbit.ts b/src/locales/zh/orbit.ts index faa818c7..fa0e77d2 100644 --- a/src/locales/zh/orbit.ts +++ b/src/locales/zh/orbit.ts @@ -143,6 +143,7 @@ export const orbit = { settingShuffleNow: '立即洗牌', toastSuggested: '{{user}} 推荐了"{{title}}"', toastSuggestedMany: '队列中有 {{count}} 个新建议', + toastSuggestLost: '你的建议可能未送达主持人', toastShuffled: '队列已洗牌', toastJoined: '已加入会话', toastLoginFirst: '加入会话前请先登录', diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index de379cdf..70b7f9ca 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -73,6 +73,7 @@ export { export { approveOrbitSuggestion, declineOrbitSuggestion, + ensureTrackInOutbox, evaluateOrbitSuggestGate, joinOrbitSession, leaveOrbitSession, @@ -81,5 +82,10 @@ export { suggestOrbitTrack, type OrbitSuggestGateReason, } from './orbit/guest'; +export { + forgetPendingSuggestion, + planPendingResends, + resetPendingResendState, +} from './orbit/pendingResend'; export { sweepGuestOutboxes } from './orbit/sweep'; export { cleanupOrphanedOrbitPlaylists } from './orbit/cleanup'; diff --git a/src/utils/orbit/guest.ts b/src/utils/orbit/guest.ts index 90951765..7e3f1143 100644 --- a/src/utils/orbit/guest.ts +++ b/src/utils/orbit/guest.ts @@ -10,6 +10,7 @@ import { type OrbitState, } from '../../api/orbit'; import { suggestionKey } from './helpers'; +import { notePendingSuggestion } from './pendingResend'; import { findSessionPlaylistId, readOrbitState, writeOrbitHeartbeat } from './remote'; export class OrbitJoinError extends Error { @@ -74,7 +75,18 @@ export async function joinOrbitSession(sid: string): Promise { // Guard against a stale outbox from a previous abandoned join attempt — // if one exists under the same name, reuse its id instead of creating // a duplicate (Navidrome allows duplicate names but it'd leak). - const existing = (await getPlaylists(true).catch(() => [])).find(p => p.name === outboxName); + // A *transient* getPlaylists failure must not make us create a duplicate + // outbox — distinguish "lookup failed" (undefined) from "genuinely absent" + // (null) and retry only on failure before falling back to create. + const lookupOutbox = async () => { + try { + return (await getPlaylists(true)).find(p => p.name === outboxName) ?? null; + } catch { + return undefined; + } + }; + let existing = await lookupOutbox(); + if (existing === undefined) existing = await lookupOutbox(); if (existing) { outboxPlaylistId = existing.id; } else { @@ -165,17 +177,27 @@ export async function suggestOrbitTrack(trackId: string): Promise { if (role !== 'guest') throw new Error('Not joined to a session as a guest'); if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound'); - // Read current outbox contents and append — createPlaylist.view with - // playlistId replaces songs wholesale, so we need to carry the existing - // list along. - const { songs } = await getPlaylist(outboxPlaylistId); - const nextIds = [...songs.map(s => s.id), trackId]; - await updatePlaylist(outboxPlaylistId, nextIds, songs.length); + await ensureTrackInOutbox(outboxPlaylistId, trackId); // Record the suggestion locally so the UI can surface it as "waiting on // host" until the host's next sweep merges it into the shared queue. - // Drained by the guest tick's reconcilePendingSuggestions call. + // Drained by the guest tick's reconcilePendingSuggestions call; tracked for + // lost-update re-send by notePendingSuggestion. useOrbitStore.getState().addPendingSuggestion(trackId); + notePendingSuggestion(trackId); +} + +/** + * Append a track to an outbox playlist unless it's already there. Subsonic's + * playlist update replaces the song list wholesale, so we carry the existing + * ids along. Shared by the initial suggest and the guest tick's lost-update + * re-send (where a no-op append means the host already cleared + recorded it). + */ +export async function ensureTrackInOutbox(outboxPlaylistId: string, trackId: string): Promise { + const { songs } = await getPlaylist(outboxPlaylistId); + const ids = songs.map(s => s.id); + if (ids.includes(trackId)) return; + await updatePlaylist(outboxPlaylistId, [...ids, trackId], ids.length); } /** diff --git a/src/utils/orbit/pendingResend.test.ts b/src/utils/orbit/pendingResend.test.ts new file mode 100644 index 00000000..16064a1d --- /dev/null +++ b/src/utils/orbit/pendingResend.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + forgetPendingSuggestion, + notePendingSuggestion, + ORBIT_SUGGESTION_GIVE_UP_MS, + ORBIT_SUGGESTION_RESEND_GRACE_MS, + pendingResendTrackedCount, + planPendingResends, + resetPendingResendState, +} from './pendingResend'; + +const T0 = 1_000_000; +const GRACE = ORBIT_SUGGESTION_RESEND_GRACE_MS; + +beforeEach(() => { + resetPendingResendState(); +}); + +describe('planPendingResends', () => { + it('does nothing within the grace window', () => { + notePendingSuggestion('t1', T0); + const plan = planPendingResends(['t1'], new Set(), T0 + GRACE - 1); + expect(plan).toEqual({ resend: [], giveUp: [] }); + }); + + it('re-sends once per grace window when the host has not recorded it', () => { + notePendingSuggestion('t1', T0); + // First window elapsed → one re-send. + expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 1).resend).toEqual(['t1']); + // Still inside the second window → no further re-send. + expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 100).resend).toEqual([]); + // Second window elapsed → re-send again. + expect(planPendingResends(['t1'], new Set(), T0 + GRACE * 2 + 1).resend).toEqual(['t1']); + }); + + it('leaves a host-recorded suggestion alone even past the grace window', () => { + notePendingSuggestion('t1', T0); + const plan = planPendingResends(['t1'], new Set(['t1']), T0 + GRACE * 3); + expect(plan).toEqual({ resend: [], giveUp: [] }); + }); + + it('gives up once past the give-up window', () => { + notePendingSuggestion('t1', T0); + const plan = planPendingResends(['t1'], new Set(), T0 + ORBIT_SUGGESTION_GIVE_UP_MS + 1); + expect(plan.giveUp).toEqual(['t1']); + expect(plan.resend).toEqual([]); + }); + + it('seeds tracking for a suggestion it sees for the first time, acting next tick', () => { + // No prior note — first plan call just starts the clock. + const first = planPendingResends(['t1'], new Set(), T0); + expect(first).toEqual({ resend: [], giveUp: [] }); + expect(pendingResendTrackedCount()).toBe(1); + // A grace window after that seed → re-send. + expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 1).resend).toEqual(['t1']); + }); + + it('forget + reset clear tracking', () => { + notePendingSuggestion('t1', T0); + notePendingSuggestion('t2', T0); + expect(pendingResendTrackedCount()).toBe(2); + forgetPendingSuggestion('t1'); + expect(pendingResendTrackedCount()).toBe(1); + resetPendingResendState(); + expect(pendingResendTrackedCount()).toBe(0); + }); +}); diff --git a/src/utils/orbit/pendingResend.ts b/src/utils/orbit/pendingResend.ts new file mode 100644 index 00000000..4229659c --- /dev/null +++ b/src/utils/orbit/pendingResend.ts @@ -0,0 +1,98 @@ +/** + * Guest-side mitigation for the outbox lost-update race. + * + * A track a guest appends to its outbox can be wiped by a concurrent host + * sweep-clear (`updatePlaylist(outbox, [], n)`) before the host actually + * recorded it — the suggestion is lost AND stays stuck on "waiting on host" + * forever, because the guest only clears it once it reaches the host's play + * queue. The read-modify-write on a Subsonic playlist can't be made atomic, so + * instead we recover: + * + * - A pending suggestion the host has NOT recorded (absent from + * `state.queue`, the suggestion history every received submission lands in) + * past a grace window is re-sent. The host dedupes by (user, trackId), so + * re-sending is idempotent — a slow-but-not-lost suggestion just no-ops. + * - Past the give-up window it is dropped so the UI stops hanging (host gone, + * guest muted, or over the cap). + * + * Recorded-but-not-yet-merged suggestions (manual-approval mode) are left + * alone — the host has them, they're legitimately waiting. + */ + +/** ~3 host ticks: long enough that a normal-latency suggestion isn't re-sent. */ +export const ORBIT_SUGGESTION_RESEND_GRACE_MS = 7_500; +/** Stop re-sending / waiting once a suggestion is this old and still unrecorded. */ +export const ORBIT_SUGGESTION_GIVE_UP_MS = 45_000; + +interface PendingMeta { + addedAt: number; + resends: number; +} + +// Module-level so it survives guest-hook remounts within a single session. +const meta = new Map(); + +/** Start tracking a suggestion the moment it's submitted. Idempotent. */ +export function notePendingSuggestion(trackId: string, now: number = Date.now()): void { + if (!meta.has(trackId)) meta.set(trackId, { addedAt: now, resends: 0 }); +} + +/** Stop tracking a suggestion (it landed, was given up, or the user left). */ +export function forgetPendingSuggestion(trackId: string): void { + meta.delete(trackId); +} + +/** Clear all tracking — call on leaving / ending a session. */ +export function resetPendingResendState(): void { + meta.clear(); +} + +/** Test-only: number of suggestions currently tracked. */ +export function pendingResendTrackedCount(): number { + return meta.size; +} + +export interface PendingResendPlan { + /** Re-append these to the outbox — the host hasn't recorded them yet. */ + resend: string[]; + /** Drop these from the pending UI — they never reached the host. */ + giveUp: string[]; +} + +/** + * Decide which still-pending suggestions to re-send and which to give up on. + * `recordedByHost` = trackIds in the host's `state.queue` (received, so not + * lost — leave them alone even if not yet merged into the play queue). + * + * Mutates the internal retry bookkeeping; returns the actions for this tick. + */ +export function planPendingResends( + pending: readonly string[], + recordedByHost: ReadonlySet, + now: number = Date.now(), +): PendingResendPlan { + const resend: string[] = []; + const giveUp: string[] = []; + for (const trackId of pending) { + let m = meta.get(trackId); + if (!m) { + // First time we see it here (e.g. submitted before notePending ran) — + // start the clock, act next tick. + m = { addedAt: now, resends: 0 }; + meta.set(trackId, m); + continue; + } + if (recordedByHost.has(trackId)) continue; // host has it — not lost + const age = now - m.addedAt; + if (age > ORBIT_SUGGESTION_GIVE_UP_MS) { + giveUp.push(trackId); + continue; + } + // One re-send per grace window, paced by how many we've already done. + if (age > ORBIT_SUGGESTION_RESEND_GRACE_MS * (m.resends + 1)) { + m.resends += 1; + resend.push(trackId); + } + } + return { resend, giveUp }; +} diff --git a/src/utils/orbit/shareLink.test.ts b/src/utils/orbit/shareLink.test.ts new file mode 100644 index 00000000..80676ac9 --- /dev/null +++ b/src/utils/orbit/shareLink.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { buildOrbitShareLink, parseOrbitShareLink } from './shareLink'; + +describe('parseOrbitShareLink', () => { + it('round-trips an https invite', () => { + const link = buildOrbitShareLink('https://music.example.com', 'aaaa1111'); + expect(parseOrbitShareLink(link)).toEqual({ + serverBase: 'https://music.example.com', + sid: 'aaaa1111', + }); + }); + + it('accepts a plain http server', () => { + const link = buildOrbitShareLink('http://192.168.1.10:4533', 'bbbb2222'); + expect(parseOrbitShareLink(link)?.serverBase).toBe('http://192.168.1.10:4533'); + }); + + it('rejects http-prefixed but non-http(s) schemes that slip past url normalization', () => { + // normalizeShareServerUrl only prepends http:// when the string doesn't + // already start with "http", so these reach the parser unchanged — the + // protocol allow-list is what rejects them. + for (const bad of ['httpx://evil.example', 'https-phish://evil.example']) { + const link = buildOrbitShareLink(bad, 'cccc3333'); + expect(parseOrbitShareLink(link)).toBeNull(); + } + }); + + it('rejects a non-URL server and empty input', () => { + expect(parseOrbitShareLink(buildOrbitShareLink('not a url', 'dddd4444'))).toBeNull(); + expect(parseOrbitShareLink('')).toBeNull(); + expect(parseOrbitShareLink('garbage-not-a-share-string')).toBeNull(); + }); +}); diff --git a/src/utils/orbit/shareLink.ts b/src/utils/orbit/shareLink.ts index d8784ba5..a0cbc5c1 100644 --- a/src/utils/orbit/shareLink.ts +++ b/src/utils/orbit/shareLink.ts @@ -16,7 +16,11 @@ export function parseOrbitShareLink(text: string): OrbitShareLink | null { if (!text) return null; const payload = decodeOrbitSharePayloadFromText(text); if (!payload) return null; - try { new URL(payload.srv); } catch { return null; } + let url: URL; + try { url = new URL(payload.srv); } catch { return null; } + // Only http(s) — a pasted invite must not smuggle file:/javascript:/etc. + // Neutralized downstream by the known-server match too, but reject early. + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; return { serverBase: payload.srv, sid: payload.sid }; }