From 5cdc45836f58aca373bb77d958f06ba26afcbfdb Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 27 Mar 2026 18:48:35 -0700 Subject: [PATCH] rework queue persistence (#1862) --- src/renderer/store/player.store.ts | 70 +++++--------- src/renderer/store/utils.ts | 142 ++++++++++++++++++++++++++++- 2 files changed, 163 insertions(+), 49 deletions(-) diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index e94885f4e..a8c68a536 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -1,6 +1,6 @@ import merge from 'lodash/merge'; import { nanoid } from 'nanoid'; -import { createJSONStorage, persist, subscribeWithSelector } from 'zustand/middleware'; +import { persist, subscribeWithSelector } from 'zustand/middleware'; import { immer } from 'zustand/middleware/immer'; import { useShallow } from 'zustand/react/shallow'; import { createWithEqualityFn } from 'zustand/traditional'; @@ -12,7 +12,7 @@ import { setTimestamp as setTimestampStore, useTimestampStoreBase, } from '/@/renderer/store/timestamp.store'; -import { idbStateStorage } from '/@/renderer/store/utils'; +import { migratePlayerStorePersist, playerStoreStorage } from '/@/renderer/store/utils'; import { shuffleInPlace } from '/@/renderer/utils/shuffle'; import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types'; import { @@ -1543,12 +1543,17 @@ export const usePlayerStoreBase = createWithEqualityFn()( merge: (persistedState: any, currentState: any) => { return merge(currentState, persistedState); }, - migrate: (persistedState, version) => { - if (version <= 3) { + migrate: async (persistedState, oldVersion) => { + if (oldVersion < 3) { return {} as PlayerState; } - return persistedState; + if (oldVersion === 3) { + await migratePlayerStorePersist('player-store'); + return persistedState as Partial; + } + + return persistedState as Partial; }, name: 'player-store', partialize: (state) => { @@ -1564,53 +1569,22 @@ export const usePlayerStoreBase = createWithEqualityFn()( excludedPlayerKeys.push('index'); } - // Filter top-level state entries - const filteredStateEntries = Object.entries(state).filter(([key]) => { - // Exclude queue if shouldRestorePlayQueue is false - if (!shouldRestorePlayQueue && key === 'queue') { - return false; - } - return true; - }); + const player = Object.fromEntries( + Object.entries(state.player).filter( + ([key]) => !excludedPlayerKeys.includes(key), + ), + ) as typeof state.player; - const filteredState = Object.fromEntries( - filteredStateEntries, - ) as Partial; - - // Filter player object - if (filteredState.player) { - filteredState.player = Object.fromEntries( - Object.entries(filteredState.player).filter( - ([key]) => !excludedPlayerKeys.includes(key), - ), - ) as typeof filteredState.player; + if (!shouldRestorePlayQueue) { + return { player }; } - if (filteredState.queue) { - const allQueueIds = new Set([ - ...(filteredState.queue.default || []), - // shuffled now contains indexes, not uniqueIds, so we don't include it here - ]); - - const songs = filteredState.queue.songs || {}; - const cleanedSongs: Record = {}; - - for (const [id, song] of Object.entries(songs)) { - if (allQueueIds.has(id)) { - cleanedSongs[id] = song; - } - } - - filteredState.queue = { - ...filteredState.queue, - songs: cleanedSongs, - }; - } - - return filteredState; + // Queue pruning and IDB writes are handled in `playerStoreStorage` so we only + // serialize the large queue when the queue slice reference actually changes. + return { player, queue: state.queue }; }, - storage: createJSONStorage(() => idbStateStorage), - version: 3, + storage: playerStoreStorage, + version: 4, }, ), ); diff --git a/src/renderer/store/utils.ts b/src/renderer/store/utils.ts index 40aa00426..b400206d6 100644 --- a/src/renderer/store/utils.ts +++ b/src/renderer/store/utils.ts @@ -1,6 +1,146 @@ +import type { QueueData, QueueSong } from '/@/shared/types/domain-types'; +import type { PersistStorage, StateStorage } from 'zustand/middleware'; + import { del, get, set } from 'idb-keyval'; import mergeWith from 'lodash/mergeWith'; -import { StateStorage } from 'zustand/middleware'; + +type PlayerStorePersistedSlice = { + player?: unknown; + queue?: QueueData; +}; + +export function cleanQueueForPersistence(queue: QueueData): QueueData { + const allQueueIds = new Set(queue.default || []); + const songs = queue.songs || {}; + const cleanedSongs: Record = {}; + + for (const [id, song] of Object.entries(songs)) { + if (allQueueIds.has(id)) { + cleanedSongs[id] = song; + } + } + + return { + ...queue, + songs: cleanedSongs, + }; +} + +// Migrate from v3 to v4 to handle queue migration +export async function migratePlayerStorePersist(storeName: string): Promise { + const mainRaw = await get(storeName); + if (!mainRaw) { + return; + } + + let parsed: { state?: { player?: unknown; queue?: QueueData }; version?: number }; + try { + parsed = JSON.parse(mainRaw as string); + } catch { + return; + } + + const embeddedQueue = parsed.state?.queue; + if (embeddedQueue === undefined) { + return; + } + + const queueKey = `${storeName}-queue`; + const queueSeparateRaw = await get(queueKey); + + if (!queueSeparateRaw) { + const cleaned = cleanQueueForPersistence(embeddedQueue); + await set(queueKey, JSON.stringify(cleaned)); + } + + await set( + storeName, + JSON.stringify({ + state: { player: parsed.state?.player }, + version: parsed.version, + }), + ); +} + +function playerStoreQueueKey(storeName: string): string { + return `${storeName}-queue`; +} + +let lastPersistedPlayerQueueRef: QueueData | undefined; + +export const playerStoreStorage: PersistStorage = { + getItem: async (name) => { + const mainRaw = await get(name); + if (!mainRaw) { + return null; + } + + let parsed: { state?: { player?: unknown; queue?: QueueData }; version?: number }; + try { + parsed = JSON.parse(mainRaw as string); + } catch { + return null; + } + + const version = parsed.version; + let queue: QueueData | undefined; + const queueRaw = await get(playerStoreQueueKey(name)); + + if (queueRaw) { + try { + queue = JSON.parse(queueRaw as string) as QueueData; + } catch { + queue = undefined; + } + } else if (parsed.state?.queue) { + // Fallback to legacy format if queue is not found + queue = parsed.state.queue; + } + + return { + state: { + player: parsed.state?.player, + queue, + } satisfies PlayerStorePersistedSlice, + version, + }; + }, + + removeItem: async (name) => { + lastPersistedPlayerQueueRef = undefined; + await del(name); + await del(playerStoreQueueKey(name)); + }, + + setItem: async (name, value) => { + const { state: rawState, version } = value; + const state = rawState as PlayerStorePersistedSlice; + const player = state.player; + + await set( + name, + JSON.stringify({ + state: { player }, + version, + }), + ); + + if (state.queue === undefined) { + lastPersistedPlayerQueueRef = undefined; + await del(playerStoreQueueKey(name)); + return; + } + + if (state.queue === lastPersistedPlayerQueueRef) { + return; + } + + const cleaned = cleanQueueForPersistence(state.queue); + await set(playerStoreQueueKey(name), JSON.stringify(cleaned)); + lastPersistedPlayerQueueRef = state.queue; + }, +}; + /** * A custom deep merger that will replace all 'columns' items with the persistent * state, instead of the default merge behavior. This is important to preserve the user's