src/renderer/store/utils.ts
Rama main · réplica de lectura del repositorio original.
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 { logFn } from '/@/shared/utils/logger';
type PlayerStorePersistedSlice = {
player?: unknown;
queue?: QueueData;
};
let playerStoreHydratedForPersistence = false;
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,
}),
);
}
export function setPlayerStoreHydratedForPersistence(value: boolean): void {
playerStoreHydratedForPersistence = value;
}
function hasStructuredPersistedMusicContext(player: unknown): boolean {
if (!player || typeof player !== 'object') return false;
const context = (player as { context?: unknown }).context;
// Legacy persisted queues predate playback context. Allow those through so older
// users do not silently lose their saved queue during the v4 migration path.
if (!context || typeof context !== 'object') return true;
const kind = (context as { kind?: unknown }).kind;
return kind === 'album' || kind === 'playlist';
}
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 && hasStructuredPersistedMusicContext(parsed.state?.player)) {
try {
queue = JSON.parse(queueRaw as string) as QueueData;
} catch {
queue = undefined;
}
} else if (
parsed.state?.queue &&
hasStructuredPersistedMusicContext(parsed.state?.player)
) {
// 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) => {
if (!playerStoreHydratedForPersistence) {
return;
}
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
* order, and not lead to an inconsistent state (e.g. multiple 'Favorite' keys)
* @param persistedState the persistent state
* @param currentState the current state
* @returns the a custom deep merge
*/
export const mergeOverridingColumns = <T>(persistedState: unknown, currentState: T) => {
return mergeWith(currentState, persistedState, (_original, persistent, key) => {
if (key === 'columns') {
return persistent;
}
return undefined;
});
};
export const idbStateStorage: StateStorage = {
getItem: async (name: string): Promise<null | string> => {
return (await get(name)) || null;
},
removeItem: async (name: string): Promise<void> => {
await del(name);
},
setItem: async (name: string, value: string): Promise<void> => {
await set(name, value);
},
};
const settingsKeys = [
'store_settings_autoDJ',
'store_settings_general',
'store_settings_lists',
'store_settings_hotkeys',
'store_settings_playback',
'store_settings_lyrics',
'store_settings_window',
'store_settings_font',
'store_settings_css',
'store_settings_remote',
'store_settings_queryBuilder',
'store_settings_tab',
];
export const splitSettingsStorage: StateStorage = {
getItem: (name: string): null | string => {
if (name !== 'store_settings') {
return localStorage.getItem(name);
}
// Read from all split keys and merge them
const keys = settingsKeys;
// Check if old single key exists (for migration)
const oldKeyRaw = localStorage.getItem('store_settings');
if (oldKeyRaw && !localStorage.getItem('store_settings_general')) {
// Only migrate if split keys don't exist yet
try {
const oldData = JSON.parse(oldKeyRaw);
const splitData: Record<string, unknown> = {};
const state = oldData.state || oldData;
if (state && typeof state === 'object') {
splitData.general = state.general;
splitData.lists = state.lists;
splitData.hotkeys = state.hotkeys;
splitData.playback = state.playback;
splitData.lyrics = state.lyrics;
splitData.window = state.window;
splitData.font = state.font;
splitData.css = state.css;
splitData.remote = state.remote;
splitData.queryBuilder = state.queryBuilder;
splitData.tab = state.tab;
// Save to new split keys
keys.forEach((key) => {
const keyName = key.replace('store_settings_', '');
if (splitData[keyName] !== undefined) {
localStorage.setItem(key, JSON.stringify(splitData[keyName]));
}
});
// Store version if it exists
if (oldData.version !== undefined) {
localStorage.setItem('store_settings_version', oldData.version.toString());
}
}
} catch (e) {
// If parsing fails, continue with reading from split keys
logFn.warn('Failed to migrate old settings format', { meta: { error: e } });
}
}
// Read from all split keys
const mergedState: Record<string, unknown> = {};
let hasData = false;
keys.forEach((key) => {
const value = localStorage.getItem(key);
if (value) {
try {
const keyName = key.replace('store_settings_', '');
mergedState[keyName] = JSON.parse(value);
hasData = true;
} catch (e) {
logFn.warn(`Failed to parse ${key}`, { meta: { error: e } });
}
}
});
if (!hasData) {
return null;
}
const versionKey = localStorage.getItem('store_settings_version');
const version = versionKey ? parseInt(versionKey, 10) : 14;
return JSON.stringify({
state: mergedState,
version,
});
},
removeItem: (name: string): void => {
if (name !== 'store_settings') {
localStorage.removeItem(name);
return;
}
// Remove all split keys
const keys = settingsKeys;
keys.forEach((key) => {
localStorage.removeItem(key);
});
// Also remove old key if it exists
localStorage.removeItem('store_settings');
},
setItem: (name: string, value: string): void => {
if (name !== 'store_settings') {
localStorage.setItem(name, value);
return;
}
try {
const data = JSON.parse(value);
const state = data.state || data;
const keys = settingsKeys.map((key) => ({
key,
value: state[key as keyof typeof state],
}));
keys.forEach(({ key, value: keyValue }) => {
if (keyValue !== undefined) {
localStorage.setItem(key, JSON.stringify(keyValue));
}
});
// Store version separately
if (data.version !== undefined) {
localStorage.setItem('store_settings_version', data.version.toString());
}
} catch (e) {
logFn.error('Failed to split settings storage', { meta: { error: e } });
localStorage.setItem(name, value);
}
},
};