diff --git a/src/store/hotCacheTouch.test.ts b/src/store/hotCacheTouch.test.ts new file mode 100644 index 00000000..c46f4a01 --- /dev/null +++ b/src/store/hotCacheTouch.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { touchPlayedMock } = vi.hoisted(() => ({ + touchPlayedMock: vi.fn(), +})); + +vi.mock('./hotCacheStore', () => ({ + useHotCacheStore: { getState: () => ({ touchPlayed: touchPlayedMock }) }, +})); + +import { touchHotCacheOnPlayback } from './hotCacheTouch'; + +beforeEach(() => { + touchPlayedMock.mockClear(); +}); + +describe('touchHotCacheOnPlayback', () => { + it('forwards a populated id pair to the hot-cache store', () => { + touchHotCacheOnPlayback('t1', 'srv'); + expect(touchPlayedMock).toHaveBeenCalledWith('t1', 'srv'); + }); + + it('skips when the trackId is empty', () => { + touchHotCacheOnPlayback('', 'srv'); + expect(touchPlayedMock).not.toHaveBeenCalled(); + }); + + it('skips when the serverId is empty', () => { + touchHotCacheOnPlayback('t1', ''); + expect(touchPlayedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/hotCacheTouch.ts b/src/store/hotCacheTouch.ts new file mode 100644 index 00000000..78895891 --- /dev/null +++ b/src/store/hotCacheTouch.ts @@ -0,0 +1,14 @@ +import { useHotCacheStore } from './hotCacheStore'; + +/** + * Mark a track as recently played for the hot-cache LRU. Called from every + * `audio_play` entry point — cold start, gapless switch, queue rewrite, + * radio next — so the hot cache promotes frequently-played tracks even when + * playback bounced through different code paths. The empty-id guards keep + * dev-time crashes (e.g. unauthenticated state, server still resolving) + * from surfacing as cache writes against a meaningless key. + */ +export function touchHotCacheOnPlayback(trackId: string, serverId: string): void { + if (!trackId || !serverId) return; + useHotCacheStore.getState().touchPlayed(trackId, serverId); +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index ec91f368..1aa559b5 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -69,6 +69,11 @@ import { invokeAudioSetNormalizationDeduped, invokeAudioUpdateReplayGainDeduped, } from './normalizationIpcDedupe'; +import { + bumpWaveformRefreshGen, + getWaveformRefreshGen, +} from './waveformRefreshGen'; +import { touchHotCacheOnPlayback } from './hotCacheTouch'; // Re-export the playback-progress public surface so existing call sites // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, @@ -630,22 +635,6 @@ radioAudio.addEventListener('suspend', () => { // Used to suppress ghost-commands from stale IPC arriving after the switch. let lastGaplessSwitchTime = 0; -function touchHotCacheOnPlayback(trackId: string, serverId: string) { - if (!trackId || !serverId) return; - useHotCacheStore.getState().touchPlayed(trackId, serverId); -} - -/** Last-write-wins generation per track: avoids applying a stale empty waveform read when - * `analysis:waveform-updated` bumps gen after SQLite commit while an older `analysis_get_waveform_for_track` - * is still in flight. Gen is bumped only on explicit invalidation (waveform-updated, analysis storage), - * not on every `refreshWaveformForTrack` call — otherwise bursts (Lucky Mix, queue) cancel each other. */ -const waveformRefreshGenByTrackId: Record = {}; - -function bumpWaveformRefreshGen(trackId: string) { - if (!trackId) return; - waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1; -} - /** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The * analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in * parallel for every full-track analysis completion; without coalescing, gapless @@ -701,10 +690,10 @@ async function reseedLoudnessForTrackId(trackId: string) { async function refreshWaveformForTrack(trackId: string) { if (!trackId) return; - const gen = waveformRefreshGenByTrackId[trackId] ?? 0; + const gen = getWaveformRefreshGen(trackId); try { const row = await invoke('analysis_get_waveform_for_track', { trackId }); - if ((waveformRefreshGenByTrackId[trackId] ?? 0) !== gen) return; + if (getWaveformRefreshGen(trackId) !== gen) return; // Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour). if (usePlayerStore.getState().currentTrack?.id !== trackId) return; const bins = row ? coerceWaveformBins(row.bins) : null; diff --git a/src/store/waveformRefreshGen.test.ts b/src/store/waveformRefreshGen.test.ts new file mode 100644 index 00000000..2d864ccf --- /dev/null +++ b/src/store/waveformRefreshGen.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + _resetWaveformRefreshGenForTest, + bumpWaveformRefreshGen, + getWaveformRefreshGen, +} from './waveformRefreshGen'; + +afterEach(() => { + _resetWaveformRefreshGenForTest(); +}); + +describe('waveformRefreshGen', () => { + it('returns 0 for an unknown track', () => { + expect(getWaveformRefreshGen('missing')).toBe(0); + }); + + it('increments the per-track generation on each bump', () => { + bumpWaveformRefreshGen('t1'); + expect(getWaveformRefreshGen('t1')).toBe(1); + bumpWaveformRefreshGen('t1'); + expect(getWaveformRefreshGen('t1')).toBe(2); + }); + + it('keeps tracks independent', () => { + bumpWaveformRefreshGen('a'); + bumpWaveformRefreshGen('a'); + bumpWaveformRefreshGen('b'); + expect(getWaveformRefreshGen('a')).toBe(2); + expect(getWaveformRefreshGen('b')).toBe(1); + }); + + it('is a no-op for an empty trackId', () => { + bumpWaveformRefreshGen(''); + expect(getWaveformRefreshGen('')).toBe(0); + }); + + it('captures the stale-result guard pattern: a snapshot is invalidated by a later bump', () => { + bumpWaveformRefreshGen('t1'); + const snapshot = getWaveformRefreshGen('t1'); + expect(snapshot).toBe(1); + bumpWaveformRefreshGen('t1'); + expect(getWaveformRefreshGen('t1')).not.toBe(snapshot); + }); +}); diff --git a/src/store/waveformRefreshGen.ts b/src/store/waveformRefreshGen.ts new file mode 100644 index 00000000..c0c13b3a --- /dev/null +++ b/src/store/waveformRefreshGen.ts @@ -0,0 +1,32 @@ +/** + * Last-write-wins generation counter per track. Avoids applying a stale + * empty waveform read when `analysis:waveform-updated` bumps the gen after + * SQLite commit while an older `analysis_get_waveform_for_track` is still + * in flight. Gen is bumped only on explicit invalidation (waveform-updated, + * analysis storage), not on every `refreshWaveformForTrack` call — + * otherwise bursts (Lucky Mix, queue) cancel each other. + * + * Typical usage: + * + * const gen = getWaveformRefreshGen(trackId); + * const row = await invoke('analysis_get_waveform_for_track', { trackId }); + * if (getWaveformRefreshGen(trackId) !== gen) return; // stale result + */ + +const waveformRefreshGenByTrackId: Record = {}; + +export function bumpWaveformRefreshGen(trackId: string): void { + if (!trackId) return; + waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1; +} + +export function getWaveformRefreshGen(trackId: string): number { + return waveformRefreshGenByTrackId[trackId] ?? 0; +} + +/** Test-only: wipe the per-track generations so each spec starts fresh. */ +export function _resetWaveformRefreshGenForTest(): void { + for (const k of Object.keys(waveformRefreshGenByTrackId)) { + delete waveformRefreshGenByTrackId[k]; + } +}