diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c87d2a8..55072dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -671,6 +671,13 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * Ranged streaming of moov-at-end M4A files no longer races Symphonia's probe against the tail prefetch: a `RangedMp4ProbeGate` holds the probe thread until either the moov tail fetch completes or moov is confirmed in the already-downloaded prefix (fast-start). Previously the probe could start on a partial buffer and immediately return `format probe failed: end of stream`, falling through to the sequential-download fallback even when the track would have played fine. * The full-download fallback path now runs ISO-BMFF diagnostic checks on the completed ranged buffer before handing it to Symphonia: zero-hole detection (`mp4_suspect_zero_holes`) and completeness gating (`isobmff_buffer_looks_complete`) trigger an automatic HTTP refetch when the buffer looks sparse or structurally incomplete. +### Player — persisted queue capped to ±250-track window (QuotaExceededError fix) + +**By [@artplan1](https://github.com/artplan1), PR [#756](https://github.com/Psychotoxical/psysonic/pull/756)** + +* Playing or shuffling a large playlist (10 000+ tracks) serialised the entire queue to `localStorage` on every persisted `set`, triggering a `QuotaExceededError` storm that killed playback and stalled the main thread. Controlled test on a 10 509-track playlist: 9 quota errors before, 0 after. +* `partialize` now persists only a ±250-track window around the current position (≤ 501 tracks), remapping `queueIndex` into the slice. The authoritative full queue is recovered from the server via `getPlayQueue` on startup — no queue data is lost. + ## [1.45.0] - 2026-05-04 ## Added diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 3a6d2534..1a939eaf 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -205,6 +205,13 @@ const CONTRIBUTOR_ENTRIES = [ 'Search: queue pasted share links from live search (PR #551)', ], }, + { + github: 'artplan1', + since: '1.46.0', + contributions: [ + 'Player: cap persisted queue to ±250-track window — fixes QuotaExceededError on large playlists (PR #756)', + ], + }, { github: 'Psychotoxical', since: '1.0.0', diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index 6e65a077..7dedc932 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -1,10 +1,15 @@ /** - * Server play-queue persistence flush characterization (Phase F1 / PR 2c). + * Player store persistence: server play-queue flush (Phase F1 / PR 2c) and + * localStorage partialize windowing (PR #756). * * `flushPlayQueuePosition` is the synchronous-from-the-caller's-view path * that the playback heartbeat / close handler / `pause()` use to push the * current position to the Subsonic server so cross-device resume works. * + * `partialize` caps the localStorage queue to a ±250-track window around the + * current index, remapping `queueIndex` into the slice so the persisted + * snapshot stays self-consistent and within the browser storage quota. + * * Mocks `savePlayQueue` at the module boundary so we can assert the exact * args passed to the Subsonic API call. */ @@ -211,3 +216,73 @@ describe('flushPlayQueuePosition', () => { expect(posArg).toBe(12999); // Math.floor(12.9999 * 1000) }); }); + +// --------------------------------------------------------------------------- +// partialize: localStorage queue window (PR #756) +// --------------------------------------------------------------------------- + +function getPartialize() { + // zustand persist middleware exposes config (incl. partialize) via .persist + type PartializeFn = (state: ReturnType) => Record; + return (usePlayerStore as unknown as { persist: { getOptions(): { partialize: PartializeFn } } }) + .persist.getOptions().partialize; +} + +describe('partialize: localStorage queue window', () => { + it('caps a large queue to at most 501 tracks and puts the current track at index 250', () => { + const tracks = makeTracks(10509); + usePlayerStore.setState({ queue: tracks, queueIndex: 5000 }); + + const partial = getPartialize()(usePlayerStore.getState()); + + expect((partial.queue as unknown[]).length).toBe(501); + expect(partial.queueIndex).toBe(250); + // the track at the remapped index must be the original track[5000] + expect((partial.queue as { id: string }[])[250].id).toBe(tracks[5000].id); + }); + + it('does not shift the index when the current track is near the start (qi < 250)', () => { + const tracks = makeTracks(1000); + usePlayerStore.setState({ queue: tracks, queueIndex: 50 }); + + const partial = getPartialize()(usePlayerStore.getState()); + + // window starts at 0, index is unchanged + expect(partial.queueIndex).toBe(50); + expect((partial.queue as { id: string }[])[50].id).toBe(tracks[50].id); + // window extends 250 ahead: 0..300 = 301 tracks + expect((partial.queue as unknown[]).length).toBe(301); + }); + + it('handles a current track near the end of the queue correctly', () => { + const tracks = makeTracks(10509); + const lastIdx = 10508; + usePlayerStore.setState({ queue: tracks, queueIndex: lastIdx }); + + const partial = getPartialize()(usePlayerStore.getState()); + + // window: [10258, 10509) = 251 tracks; remapped index = 10508 - 10258 = 250 + expect((partial.queue as unknown[]).length).toBe(251); + expect(partial.queueIndex).toBe(250); + expect((partial.queue as { id: string }[])[250].id).toBe(tracks[lastIdx].id); + }); + + it('persists the entire queue unchanged when it is smaller than the window', () => { + const tracks = makeTracks(10); + usePlayerStore.setState({ queue: tracks, queueIndex: 5 }); + + const partial = getPartialize()(usePlayerStore.getState()); + + expect((partial.queue as unknown[]).length).toBe(10); + expect(partial.queueIndex).toBe(5); + }); + + it('handles an empty queue without throwing', () => { + usePlayerStore.setState({ queue: [], queueIndex: 0 }); + + const partial = getPartialize()(usePlayerStore.getState()); + + expect((partial.queue as unknown[]).length).toBe(0); + expect(partial.queueIndex).toBe(0); + }); +}); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index c355581f..3b842ae4 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -16,6 +16,9 @@ import { createTransportLightActions } from './transportLightActions'; import { createUiStateActions } from './uiStateActions'; import { createUndoRedoActions } from './undoRedoActions'; +// Half-width of the localStorage queue window (see partialize below). +const PERSIST_QUEUE_HALF = 250; + export const usePlayerStore = create()( persist( (set, get) => { @@ -78,23 +81,30 @@ export const usePlayerStore = create()( { name: 'psysonic-player', storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ - volume: state.volume, - repeatMode: state.repeatMode, - currentTrack: state.currentTrack, - queue: state.queue, - queueServerId: state.queueServerId, - queueIndex: state.queueIndex, - isQueueVisible: state.isQueueVisible, - // currentTime is intentionally NOT persisted here. - // handleAudioProgress fires every 100ms and each setState with a - // persisted field triggers a full JSON serialisation of the queue to - // localStorage. After ~10 minutes of Artist Radio the queue grows to - // 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's - // storage process to crash on macOS → black screen + audio stop. - // Resume position is recovered from Subsonic savePlayQueue (5s debounce). - lastfmLovedCache: state.lastfmLovedCache, - }), + partialize: (state) => { + // Persist only a window around the current track: the full queue + // (10k+ on big playlists) overflows the localStorage quota. + const qi = state.queueIndex; + const start = Math.max(0, qi - PERSIST_QUEUE_HALF); + const windowedQueue = state.queue.slice(start, qi + PERSIST_QUEUE_HALF + 1); + return { + volume: state.volume, + repeatMode: state.repeatMode, + currentTrack: state.currentTrack, + queue: windowedQueue, + queueServerId: state.queueServerId, + queueIndex: qi - start, // remap into the windowed slice + isQueueVisible: state.isQueueVisible, + // currentTime is intentionally NOT persisted here. + // handleAudioProgress fires every 100ms and each setState with a + // persisted field triggers a full JSON serialisation of the queue to + // localStorage. After ~10 minutes of Artist Radio the queue grows to + // 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's + // storage process to crash on macOS → black screen + audio stop. + // Resume position is recovered from Subsonic savePlayQueue (5s debounce). + lastfmLovedCache: state.lastfmLovedCache, + }; + }, } ) );