diff --git a/src/store/loudnessReseed.test.ts b/src/store/loudnessReseed.test.ts new file mode 100644 index 00000000..c9370f4a --- /dev/null +++ b/src/store/loudnessReseed.test.ts @@ -0,0 +1,150 @@ +/** + * `reseedLoudnessForTrackId` orchestrates a full analysis re-run for one + * track: invalidate waveform gen, clear loudness + backfill state, wipe + * server rows, kick a forced seed. The orchestration is what's worth + * pinning — each individual helper is already tested in its own module. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const authState = { + normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness', + loudnessTargetLufs: -14, + }; + const playerSnapshot: { + currentTrack: { id: string } | null; + updateReplayGainForCurrentTrack: ReturnType; + } = { + currentTrack: null, + updateReplayGainForCurrentTrack: vi.fn(), + }; + return { + authState, + playerSnapshot, + invokeMock: vi.fn(async (_cmd: string, _args?: Record) => undefined), + buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`), + bumpWaveformRefreshGenMock: vi.fn(), + clearLoudnessCacheMock: vi.fn(), + resetBackfillStateMock: vi.fn(), + playerSetStateMock: vi.fn(), + }; +}); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock })); +vi.mock('../api/subsonic', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock })); +vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } })); +vi.mock('./playerStore', () => ({ + usePlayerStore: { + getState: () => hoisted.playerSnapshot, + setState: hoisted.playerSetStateMock, + }, +})); +vi.mock('./waveformRefreshGen', () => ({ + bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock, +})); +vi.mock('./loudnessGainCache', () => ({ + clearLoudnessCacheStateForTrackId: hoisted.clearLoudnessCacheMock, +})); +vi.mock('./loudnessBackfillState', () => ({ + resetLoudnessBackfillStateForTrackId: hoisted.resetBackfillStateMock, +})); + +import { reseedLoudnessForTrackId } from './loudnessReseed'; + +beforeEach(() => { + hoisted.authState.normalizationEngine = 'loudness'; + hoisted.authState.loudnessTargetLufs = -14; + hoisted.playerSnapshot.currentTrack = null; + hoisted.invokeMock.mockReset(); + hoisted.invokeMock.mockResolvedValue(undefined); + hoisted.buildStreamUrlMock.mockClear(); + hoisted.bumpWaveformRefreshGenMock.mockClear(); + hoisted.clearLoudnessCacheMock.mockClear(); + hoisted.resetBackfillStateMock.mockClear(); + hoisted.playerSetStateMock.mockClear(); + hoisted.playerSnapshot.updateReplayGainForCurrentTrack = vi.fn(); +}); + +describe('reseedLoudnessForTrackId', () => { + it('is a no-op for empty trackId', async () => { + await reseedLoudnessForTrackId(''); + expect(hoisted.invokeMock).not.toHaveBeenCalled(); + expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled(); + }); + + it("is a no-op when normalization engine isn't loudness", async () => { + hoisted.authState.normalizationEngine = 'off'; + await reseedLoudnessForTrackId('t1'); + expect(hoisted.invokeMock).not.toHaveBeenCalled(); + expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled(); + }); + + it('runs the full reseed pipeline in order', async () => { + await reseedLoudnessForTrackId('t1'); + expect(hoisted.bumpWaveformRefreshGenMock).toHaveBeenCalledWith('t1'); + expect(hoisted.clearLoudnessCacheMock).toHaveBeenCalledWith('t1'); + expect(hoisted.resetBackfillStateMock).toHaveBeenCalledWith('t1'); + const invokeCalls = hoisted.invokeMock.mock.calls.map(c => c[0]); + expect(invokeCalls).toEqual([ + 'analysis_delete_waveform_for_track', + 'analysis_delete_loudness_for_track', + 'analysis_enqueue_seed_from_url', + ]); + expect(hoisted.invokeMock.mock.calls[2][1]).toEqual({ + trackId: 't1', + url: 'https://mock/stream/t1', + force: true, + }); + }); + + it('blanks the seekbar only when the reseed target is the current track', async () => { + hoisted.playerSnapshot.currentTrack = { id: 't1' }; + await reseedLoudnessForTrackId('t1'); + const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]); + expect(setStateCalls).toContainEqual({ waveformBins: null }); + }); + + it('does NOT blank the seekbar when reseeding a different track', async () => { + hoisted.playerSnapshot.currentTrack = { id: 'other' }; + await reseedLoudnessForTrackId('t1'); + const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]); + expect(setStateCalls).not.toContainEqual({ waveformBins: null }); + }); + + it('resets live normalization-state to placeholder values', async () => { + hoisted.authState.loudnessTargetLufs = -10; + await reseedLoudnessForTrackId('t1'); + const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]); + expect(setStateCalls).toContainEqual({ + normalizationNowDb: null, + normalizationTargetLufs: -10, + normalizationEngineLive: 'loudness', + }); + }); + + it('continues past errors in delete-waveform', async () => { + hoisted.invokeMock + .mockRejectedValueOnce(new Error('waveform delete failed')) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + await reseedLoudnessForTrackId('t1'); + expect(hoisted.invokeMock).toHaveBeenCalledTimes(3); + }); + + it('continues past errors in delete-loudness', async () => { + hoisted.invokeMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('loudness delete failed')) + .mockResolvedValueOnce(undefined); + await reseedLoudnessForTrackId('t1'); + expect(hoisted.invokeMock).toHaveBeenCalledTimes(3); + }); + + it('swallows the final enqueue-seed error', async () => { + hoisted.invokeMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('enqueue failed')); + await expect(reseedLoudnessForTrackId('t1')).resolves.toBeUndefined(); + }); +}); diff --git a/src/store/loudnessReseed.ts b/src/store/loudnessReseed.ts new file mode 100644 index 00000000..d1f85891 --- /dev/null +++ b/src/store/loudnessReseed.ts @@ -0,0 +1,66 @@ +import { invoke } from '@tauri-apps/api/core'; +import { buildStreamUrl } from '../api/subsonic'; +import { useAuthStore } from './authStore'; +import { usePlayerStore } from './playerStore'; +import { bumpWaveformRefreshGen } from './waveformRefreshGen'; +import { clearLoudnessCacheStateForTrackId } from './loudnessGainCache'; +import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState'; + +/** + * Tear down every cached piece of analysis for a track and re-enqueue a + * forced reseed. Used by the Settings „Re-analyse this track" action. + * + * Sequence: + * 1. Skip if loudness engine isn't active (re-analysis only makes sense + * when normalization actually consumes the result). + * 2. Invalidate the waveform refresh generation so any in-flight read + * for this id is discarded, and blank the seekbar bins immediately if + * the track is current. + * 3. Wipe both loudness-cache maps (both forms of the id) + the backfill + * retry state. + * 4. Reset the live normalization-state to placeholder values so the UI + * doesn't show stale dB until the new analysis lands. + * 5. Delete the on-disk waveform + loudness rows via Rust. + * 6. Re-issue `updateReplayGainForCurrentTrack` so the engine drops + * whatever gain it was holding for this track. + * 7. Enqueue a forced seed via `analysis_enqueue_seed_from_url`. + * + * Best-effort throughout — Rust errors are logged but never thrown. + */ +export async function reseedLoudnessForTrackId(trackId: string): Promise { + if (!trackId) return; + const auth = useAuthStore.getState(); + if (auth.normalizationEngine !== 'loudness') return; + bumpWaveformRefreshGen(trackId); + if (usePlayerStore.getState().currentTrack?.id === trackId) { + usePlayerStore.setState({ waveformBins: null }); + } + clearLoudnessCacheStateForTrackId(trackId); + resetLoudnessBackfillStateForTrackId(trackId); + usePlayerStore.setState({ + normalizationNowDb: null, + normalizationTargetLufs: auth.loudnessTargetLufs, + normalizationEngineLive: 'loudness', + }); + try { + await invoke('analysis_delete_waveform_for_track', { trackId }); + } catch (e) { + console.error('[psysonic] analysis_delete_waveform_for_track failed:', e); + } + try { + await invoke('analysis_delete_loudness_for_track', { trackId }); + } catch (e) { + console.error('[psysonic] analysis_delete_loudness_for_track failed:', e); + } + usePlayerStore.getState().updateReplayGainForCurrentTrack(); + const url = buildStreamUrl(trackId); + try { + await invoke('analysis_enqueue_seed_from_url', { + trackId, + url, + force: true, + }); + } catch (e) { + console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e); + } +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index bc2686ed..306ebca4 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -105,6 +105,15 @@ import { setGaplessPreloadingId, } from './gaplessPreloadState'; import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; +import { + SEEK_TARGET_GUARD_TIMEOUT_MS, + clearSeekTarget, + getSeekTarget, + getSeekTargetSetAt, + setSeekTarget, +} from './seekTargetState'; +import { tryAcquireTogglePlayLock } from './togglePlayLock'; +import { reseedLoudnessForTrackId } from './loudnessReseed'; // Re-export so TauriEventBridge + persistence test keep their existing // `from './playerStore'` imports. @@ -476,11 +485,6 @@ function queueUndoRestoreAudioEngine(opts: { // Debounce timer for seek slider drags. let seekDebounce: ReturnType | null = null; -// Target time of the last seek — blocks stale Rust progress ticks until the -// engine has actually caught up to the new position. -let seekTarget: number | null = null; -let seekTargetSetAt = 0; -const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000; // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. let seekFallbackRetryTimer: ReturnType | null = null; let seekFallbackRetryStartedAt = 0; @@ -499,16 +503,6 @@ const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0; let lastStoreProgressCommitAt = 0; -function setSeekTarget(seconds: number) { - seekTarget = seconds; - seekTargetSetAt = Date.now(); -} - -function clearSeekTarget() { - seekTarget = null; - seekTargetSetAt = 0; -} - function clearSeekFallbackRetry() { if (seekFallbackRetryTimer) { clearTimeout(seekFallbackRetryTimer); @@ -562,9 +556,6 @@ function scheduleSeekFallbackRetry(trackId: string, seconds: number) { }, SEEK_FALLBACK_RETRY_INTERVAL_MS); } -// Guard against rapid double-click play/pause sending two state transitions -// to the Rust backend before it has finished the previous one. -let togglePlayLock = false; // ── HTML5 Radio Player ──────────────────────────────────────────────────────── // Internet radio streams are played via a native