Files
psysonic/src/store/authStoreRehydrate.ts
T

273 lines
12 KiB
TypeScript

import { IS_LINUX } from '../utils/platform';
import { sanitizeHiResCrossfadeResampleHz } from '../utils/audio/hiResCrossfadeResample';
import {
sanitizeAutodjOverlapCapMode,
sanitizeAutodjOverlapCapSec,
} from '../utils/playback/autodjOverlapCap';
import {
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
clampStoredLoudnessPreAnalysisAttenuationRefDb,
} from '../utils/audio/loudnessPreAnalysisSlider';
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from './authStoreDefaults';
import {
clampMixFilterMinStars,
clampRandomMixSize,
clampLibraryGridMaxColumns,
sanitizeLoudnessLufsPreset,
sanitizeLoudnessPreAnalysisFromStorage,
sanitizeSkipStarCounts,
} from './authStoreHelpers';
import type {
AuthState,
DiscordCoverSource,
DurationMode,
LyricsSourceConfig,
QueueDisplayMode,
SeekbarStyle,
WindowButtonStyle,
} from './authStoreTypes';
import { migrateLegacyLastfm, sanitizeAccounts } from '../music-network';
/**
* Computes the post-rehydration patch for the auth store. Runs all
* legacy-shape migrations + numeric sanitization that the persist
* middleware can't express declaratively. The caller (the store's
* `onRehydrateStorage` callback) applies the returned partial via
* `useAuthStore.setState`.
*
* Side effects this function takes: deletes obsolete legacy fields
* directly off the rehydrated state object (`animationMode`,
* `reducedAnimations`) so they don't sit as cruft in localStorage,
* and writes the one-shot Linux smooth-scroll migration sentinel.
*/
export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState> {
// Drop removed preload-next-track settings from legacy persist blobs.
delete (state as { preloadMode?: unknown }).preloadMode;
delete (state as { preloadCustomSeconds?: unknown }).preloadCustomSeconds;
// Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time).
// Only for an *existing* persisted state (upgrade from a build without
// lyricsSources). Fresh installs have no persisted state → keep the
// all-off default (issue #810); don't resurrect the old on-by-default set.
let lyricsSourcesMigrated: { lyricsSources?: LyricsSourceConfig[] } = {};
try {
const raw = JSON.parse(localStorage.getItem('psysonic-auth') ?? '{}') as { state?: Record<string, unknown> };
if (raw?.state && !raw.state.lyricsSources) {
const serverFirst = (raw?.state?.lyricsServerFirst as boolean | undefined) ?? true;
const neteaseOn = (raw?.state?.enableNeteaselyrics as boolean | undefined) ?? false;
const migrated: LyricsSourceConfig[] = serverFirst
? [{ id: 'server', enabled: true }, { id: 'lrclib', enabled: true }, { id: 'netease', enabled: neteaseOn }]
: [{ id: 'lrclib', enabled: true }, { id: 'server', enabled: true }, { id: 'netease', enabled: neteaseOn }];
lyricsSourcesMigrated = { lyricsSources: migrated };
}
} catch { /* ignore */ }
// Migrate legacy `lyricsMode` ('standard' | 'lyricsplus') → `youLyPlusEnabled`
// (one-time). Existing users keep YouLyPlus on iff they were on lyricsplus
// mode; the legacy field is then stripped so it doesn't sit as cruft.
let youLyPlusMigrated: { youLyPlusEnabled?: boolean } = {};
const legacyLyricsMode = (state as { lyricsMode?: unknown }).lyricsMode;
if (legacyLyricsMode === 'lyricsplus' || legacyLyricsMode === 'standard') {
youLyPlusMigrated = { youLyPlusEnabled: legacyLyricsMode === 'lyricsplus' };
}
delete (state as { lyricsMode?: unknown }).lyricsMode;
// One-time: older builds could persist smooth=false as the default. Force smooth on once
// so updates do not leave users on discrete scrolling; after this flag exists, only an
// explicit toggle in Settings may turn it off (persisted in psysonic-auth).
const wheelSmoothMigrationKey = 'psysonic-linux-webkit-smooth-v1';
let wheelSmoothOneTime: { linuxWebkitKineticScroll?: boolean } = {};
if (IS_LINUX) {
try {
if (!localStorage.getItem(wheelSmoothMigrationKey)) {
wheelSmoothOneTime = { linuxWebkitKineticScroll: true };
localStorage.setItem(wheelSmoothMigrationKey, '1');
}
} catch { /* ignore */ }
}
// 'waveform' style was renamed to 'truewave' (with 'pseudowave' added
// as the deterministic legacy variant). Any persisted value that is
// not a valid SeekbarStyle (legacy 'waveform', undefined, tampered
// strings) lands on the new bins-based default — otherwise the
// dispatcher's switch finds no match and the seekbar renders blank.
const VALID_SEEKBAR_STYLES = new Set<string>([
'truewave', 'pseudowave', 'linedot', 'bar', 'thick',
'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape',
]);
const seekbarStyleMigrated = VALID_SEEKBAR_STYLES.has(state.seekbarStyle as string)
? {}
: { seekbarStyle: 'truewave' as SeekbarStyle };
// Unknown / missing / tampered window-button style falls back to the
// default 'dots' so the title bar never renders an unstyled data-attr.
const VALID_WINDOW_BUTTON_STYLES = new Set<string>([
'dots', 'dotsGlyph', 'flat', 'pill', 'outline', 'glyph',
]);
const windowButtonStyleMigrated = VALID_WINDOW_BUTTON_STYLES.has(
(state as { windowButtonStyle?: unknown }).windowButtonStyle as string,
)
? {}
: { windowButtonStyle: 'dots' as WindowButtonStyle };
// Garbage / null / undefined / missing key from a legacy or tampered persist
// payload maps back to 'total' so the duration chip never receives an
// unknown mode (would render an empty label).
const VALID_QUEUE_DURATION_MODES = new Set<string>(['total', 'remaining', 'eta']);
const queueDurationDisplayModeMigrated = VALID_QUEUE_DURATION_MODES.has(
(state as { queueDurationDisplayMode?: unknown }).queueDurationDisplayMode as string,
)
? {}
: { queueDurationDisplayMode: 'total' as DurationMode };
// Missing key (pre-feature persist) / garbage maps to 'queue' — the default
// mode, which lists only upcoming tracks.
const VALID_QUEUE_DISPLAY_MODES = new Set<string>(['playlist', 'queue', 'timeline']);
const queueDisplayModeMigrated = VALID_QUEUE_DISPLAY_MODES.has(
(state as { queueDisplayMode?: unknown }).queueDisplayMode as string,
)
? {}
: { queueDisplayMode: 'queue' as QueueDisplayMode };
const VALID_WAYLAND_TEXT_PROFILE = new Set<string>(['balanced', 'sharp', 'gpu', 'minimal']);
const rawWaylandProfile = (state as { linuxWaylandTextRenderProfile?: unknown }).linuxWaylandTextRenderProfile;
const linuxWaylandTextRenderProfileMigrated = VALID_WAYLAND_TEXT_PROFILE.has(rawWaylandProfile as string)
? {}
: { linuxWaylandTextRenderProfile: 'sharp' as const };
// The `animationMode` 3-state setting was removed; users on `'reduced'`
// or `'static'` collapse onto the former `'full'` path automatically as
// soon as the field is gone from the store. Strip the persisted field
// so it doesn't sit in localStorage as cruft.
delete (state as { animationMode?: unknown }).animationMode;
// The earlier `reducedAnimations: boolean` predecessor likewise loses
// its meaning; clear it for the same reason.
delete (state as { reducedAnimations?: unknown }).reducedAnimations;
const st = state as {
loudnessTargetLufs?: unknown;
loudnessPreAnalysisAttenuationDb?: unknown;
loudnessPreIsRefV1?: unknown;
};
const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12);
const rawN = st.loudnessPreAnalysisAttenuationDb;
const n = typeof rawN === 'number' ? rawN : Number(rawN);
const preSan =
st.loudnessPreIsRefV1 === true
? sanitizeLoudnessPreAnalysisFromStorage(rawN)
: (Number.isFinite(n)
? clampStoredLoudnessPreAnalysisAttenuationRefDb(
n - (targetSan - LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS),
)
: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB);
// Migrate enableAppleMusicCoversDiscord boolean → discordCoverSource enum.
let discordCoverSourceMigrated: { discordCoverSource?: DiscordCoverSource } = {};
const legacyAppleCovers = (state as { enableAppleMusicCoversDiscord?: unknown }).enableAppleMusicCoversDiscord;
if (legacyAppleCovers === true && (!state.discordCoverSource || state.discordCoverSource === 'none')) {
discordCoverSourceMigrated = { discordCoverSource: 'apple' };
}
// One-time: legacy unified `maxCacheMb` cap removed from Settings (offline + IDB covers).
const maxCacheMbMigrationKey = 'psysonic-max-cache-mb-removed-v1';
let maxCacheMbMigrated: { maxCacheMb?: number } = {};
try {
if (!localStorage.getItem(maxCacheMbMigrationKey)) {
maxCacheMbMigrated = { maxCacheMb: 0 };
localStorage.setItem(maxCacheMbMigrationKey, '1');
}
} catch { /* ignore */ }
// Music Network: one-time migration of the legacy flat lastfm* fields into the
// accounts[] model. Runs exactly once (guarded by a sentinel) so a later
// disconnect can't resurrect the account from the still-present legacy fields.
// Subsequent rehydrates only sanitize the persisted account list.
const musicNetworkMigrationKey = 'psysonic-music-network-migrated-v1';
let musicNetworkMigrated: Partial<AuthState> = {
musicNetworkAccounts: sanitizeAccounts(
(state as { musicNetworkAccounts?: unknown }).musicNetworkAccounts,
),
};
try {
if (!localStorage.getItem(musicNetworkMigrationKey)) {
// The legacy lastfm* fields no longer exist on AuthState; read them off the
// persisted blob (present on upgrade) via a cast.
const legacy = state as unknown as {
lastfmSessionKey?: string;
lastfmUsername?: string;
scrobblingEnabled?: boolean;
};
const migrated = migrateLegacyLastfm(
{
lastfmSessionKey: legacy.lastfmSessionKey,
lastfmUsername: legacy.lastfmUsername,
scrobblingEnabled: legacy.scrobblingEnabled,
},
() => crypto.randomUUID(),
);
musicNetworkMigrated = {
musicNetworkAccounts: migrated.accounts,
enrichmentPrimaryId: migrated.enrichmentPrimaryId,
scrobblingMasterEnabled: migrated.scrobblingMasterEnabled,
};
localStorage.setItem(musicNetworkMigrationKey, '1');
}
} catch { /* ignore */ }
// Strip the legacy flat lastfm* fields from the persisted blob (spec §6.1.3).
// The migration above maps them into accounts[]; the sentinel guards
// re-migration, so these now sit as pure cruft. Drop them on every rehydrate.
for (const k of ['lastfmApiKey', 'lastfmApiSecret', 'lastfmSessionKey', 'lastfmUsername', 'lastfmSessionError', 'scrobblingEnabled']) {
delete (state as unknown as Record<string, unknown>)[k];
}
let mediaDirMigrated: { mediaDir?: string } = {};
const stMedia = state as { mediaDir?: unknown; offlineDownloadDir?: string; hotCacheDownloadDir?: string };
if (!stMedia.mediaDir || (typeof stMedia.mediaDir === 'string' && stMedia.mediaDir.trim() === '')) {
const offline = (stMedia.offlineDownloadDir ?? '').trim();
const hot = (stMedia.hotCacheDownloadDir ?? '').trim();
if (offline && (!hot || offline === hot)) {
mediaDirMigrated = { mediaDir: offline };
} else if (hot) {
mediaDirMigrated = { mediaDir: hot };
}
}
return {
...mediaDirMigrated,
...musicNetworkMigrated,
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
randomMixSize: clampRandomMixSize(state.randomMixSize as number),
libraryGridMaxColumns: clampLibraryGridMaxColumns(
(state as { libraryGridMaxColumns?: unknown }).libraryGridMaxColumns,
),
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
loudnessTargetLufs: targetSan,
loudnessPreAnalysisAttenuationDb: preSan,
loudnessPreIsRefV1: true,
hiResCrossfadeResampleHz: sanitizeHiResCrossfadeResampleHz(
(state as { hiResCrossfadeResampleHz?: unknown }).hiResCrossfadeResampleHz,
),
autodjOverlapCapMode: sanitizeAutodjOverlapCapMode(
(state as { autodjOverlapCapMode?: unknown }).autodjOverlapCapMode,
),
autodjOverlapCapSec: sanitizeAutodjOverlapCapSec(
(state as { autodjOverlapCapSec?: unknown }).autodjOverlapCapSec,
),
...lyricsSourcesMigrated,
...youLyPlusMigrated,
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...windowButtonStyleMigrated,
...queueDurationDisplayModeMigrated,
...queueDisplayModeMigrated,
...linuxWaylandTextRenderProfileMigrated,
...discordCoverSourceMigrated,
...maxCacheMbMigrated,
};
}