mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
fix(player): cap persisted queue window (#756)
Playing/shuffling a big playlist set `queue` to ~10k `Track` objects. zustand `persist` then `JSON.stringify`'d the whole queue into `localStorage` on every persisted `set` → `QuotaExceededError` storm: playback never started and the main thread stalled on each write. `partialize` now persists only a ±250-track window around `queueIndex` (remapping the index into the slice) instead of the entire queue. The authoritative full queue lives server-side — synced via Subsonic `savePlayQueue` and restored through `getPlayQueue`. localStorage is only a fast local cache, so bounding it doesn't lose the queue; it just restores a ±250 window instantly while the full queue rehydrates from the server. Controlled before/after on a 10,509-track playlist: - before: QuotaExceededError ×9, play/shuffle dead - after: QuotaExceeded = 0, playback works
This commit is contained in:
@@ -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.
|
* 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.
|
* 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
|
## [1.45.0] - 2026-05-04
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -205,6 +205,13 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Search: queue pasted share links from live search (PR #551)',
|
'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',
|
github: 'Psychotoxical',
|
||||||
since: '1.0.0',
|
since: '1.0.0',
|
||||||
|
|||||||
@@ -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
|
* `flushPlayQueuePosition` is the synchronous-from-the-caller's-view path
|
||||||
* that the playback heartbeat / close handler / `pause()` use to push the
|
* that the playback heartbeat / close handler / `pause()` use to push the
|
||||||
* current position to the Subsonic server so cross-device resume works.
|
* 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
|
* Mocks `savePlayQueue` at the module boundary so we can assert the exact
|
||||||
* args passed to the Subsonic API call.
|
* args passed to the Subsonic API call.
|
||||||
*/
|
*/
|
||||||
@@ -211,3 +216,73 @@ describe('flushPlayQueuePosition', () => {
|
|||||||
expect(posArg).toBe(12999); // Math.floor(12.9999 * 1000)
|
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<typeof usePlayerStore.getState>) => Record<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+27
-17
@@ -16,6 +16,9 @@ import { createTransportLightActions } from './transportLightActions';
|
|||||||
import { createUiStateActions } from './uiStateActions';
|
import { createUiStateActions } from './uiStateActions';
|
||||||
import { createUndoRedoActions } from './undoRedoActions';
|
import { createUndoRedoActions } from './undoRedoActions';
|
||||||
|
|
||||||
|
// Half-width of the localStorage queue window (see partialize below).
|
||||||
|
const PERSIST_QUEUE_HALF = 250;
|
||||||
|
|
||||||
export const usePlayerStore = create<PlayerState>()(
|
export const usePlayerStore = create<PlayerState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => {
|
(set, get) => {
|
||||||
@@ -78,23 +81,30 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
{
|
{
|
||||||
name: 'psysonic-player',
|
name: 'psysonic-player',
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => {
|
||||||
volume: state.volume,
|
// Persist only a window around the current track: the full queue
|
||||||
repeatMode: state.repeatMode,
|
// (10k+ on big playlists) overflows the localStorage quota.
|
||||||
currentTrack: state.currentTrack,
|
const qi = state.queueIndex;
|
||||||
queue: state.queue,
|
const start = Math.max(0, qi - PERSIST_QUEUE_HALF);
|
||||||
queueServerId: state.queueServerId,
|
const windowedQueue = state.queue.slice(start, qi + PERSIST_QUEUE_HALF + 1);
|
||||||
queueIndex: state.queueIndex,
|
return {
|
||||||
isQueueVisible: state.isQueueVisible,
|
volume: state.volume,
|
||||||
// currentTime is intentionally NOT persisted here.
|
repeatMode: state.repeatMode,
|
||||||
// handleAudioProgress fires every 100ms and each setState with a
|
currentTrack: state.currentTrack,
|
||||||
// persisted field triggers a full JSON serialisation of the queue to
|
queue: windowedQueue,
|
||||||
// localStorage. After ~10 minutes of Artist Radio the queue grows to
|
queueServerId: state.queueServerId,
|
||||||
// 50+ tracks; 6 000+ synchronous SQLite writes cause WKWebView's
|
queueIndex: qi - start, // remap into the windowed slice
|
||||||
// storage process to crash on macOS → black screen + audio stop.
|
isQueueVisible: state.isQueueVisible,
|
||||||
// Resume position is recovered from Subsonic savePlayQueue (5s debounce).
|
// currentTime is intentionally NOT persisted here.
|
||||||
lastfmLovedCache: state.lastfmLovedCache,
|
// 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user