rework queue persistence (#1862)

This commit is contained in:
jeffvli
2026-03-27 18:48:35 -07:00
parent d438c802a4
commit 5cdc45836f
2 changed files with 163 additions and 49 deletions
+22 -48
View File
@@ -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<PlayerState>()(
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<PlayerState>;
}
return persistedState as Partial<PlayerState>;
},
name: 'player-store',
partialize: (state) => {
@@ -1564,53 +1569,22 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
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<PlayerState>;
// 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<string, QueueSong> = {};
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,
},
),
);
+141 -1
View File
@@ -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<string, QueueSong> = {};
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<void> {
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<unknown> = {
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