refactor(player): E.19 — extract analysis-refresh helpers cluster (#583)

Two thematic cuts in one PR:

  - `src/store/waveformRefresh.ts` — `refreshWaveformForTrack` plus its
    `WaveformCachePayload` type. Fetches the cached waveform row and
    applies bins to the player store, guarded by both the refresh
    generation snapshot and the current-track check so a stale read
    can't overwrite fresh data.
  - `src/store/loudnessRefresh.ts` — `refreshLoudnessForTrack` plus the
    `loudnessRefreshInflight` coalescing map and `LoudnessCachePayload`
    type. Orchestrates the loudness fetch: dedupe concurrent calls by
    (trackId, syncEngine, target), distinguish hit vs miss, enqueue
    bounded backfill, suppress stale-target results by recursive retry.

Both file-private; no caller-side changes outside playerStore's own
imports. Imports of helpers that were only used by these two functions
get cleaned up out of playerStore (coerceWaveformBins, getBackfillAttempts,
forgetLoudnessGain, redactSubsonicUrlForLog, LOUDNESS_BACKFILL_WINDOW_AHEAD,
isTrackInsideLoudnessBackfillWindow, etc.).

20 tests across the two modules pin the orchestration: gen + current-track
guards on waveform; coalesce + hit/miss/backfill/stale-target/sync-flag
branches on loudness.

playerStore 3139 → 2983 LOC — first sub-3000 milestone for Phase E.
This commit is contained in:
Frank Stellmacher
2026-05-12 16:15:34 +02:00
committed by GitHub
parent 4c64844349
commit 7dc4888a06
5 changed files with 492 additions and 162 deletions
+196
View File
@@ -0,0 +1,196 @@
/**
* `refreshLoudnessForTrack` orchestrates the loudness analysis fetch:
* coalesce concurrent calls, distinguish hit vs miss, enqueue backfill
* within bounds, suppress stale-target results. The individual helpers
* (cache, backfill state, window predicate, debug emit) are tested in
* their own modules — this file pins the orchestration only.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => {
const auth = {
loudnessTargetLufs: -14,
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
};
const playerState = {
queue: [] as Array<{ id: string }>,
queueIndex: 0,
currentTrack: null as { id: string } | null,
updateReplayGainForCurrentTrack: vi.fn(),
};
return {
auth,
playerState,
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
redactMock: vi.fn((s: string) => s),
playerSetStateMock: vi.fn(),
emitDebugMock: vi.fn(),
forgetLoudnessMock: vi.fn(),
markLoudnessStableMock: vi.fn(),
getBackfillAttemptsMock: vi.fn(() => 0),
isBackfillInFlightMock: vi.fn(() => false),
markBackfillInFlightMock: vi.fn(),
clearBackfillInFlightMock: vi.fn(),
resetBackfillAttemptsMock: vi.fn(),
isTrackInsideWindowMock: vi.fn(() => true),
};
});
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('../api/subsonic', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
vi.mock('../utils/redactSubsonicUrl', () => ({ redactSubsonicUrlForLog: hoisted.redactMock }));
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
vi.mock('./playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerState,
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('./normalizationDebug', () => ({ emitNormalizationDebug: hoisted.emitDebugMock }));
vi.mock('./loudnessGainCache', () => ({
forgetLoudnessGain: hoisted.forgetLoudnessMock,
markLoudnessStable: hoisted.markLoudnessStableMock,
}));
vi.mock('./loudnessBackfillState', () => ({
MAX_BACKFILL_ATTEMPTS_PER_TRACK: 2,
clearBackfillInFlight: hoisted.clearBackfillInFlightMock,
getBackfillAttempts: hoisted.getBackfillAttemptsMock,
isBackfillInFlight: hoisted.isBackfillInFlightMock,
markBackfillInFlight: hoisted.markBackfillInFlightMock,
resetBackfillAttempts: hoisted.resetBackfillAttemptsMock,
}));
vi.mock('./loudnessBackfillWindow', () => ({
LOUDNESS_BACKFILL_WINDOW_AHEAD: 5,
isTrackInsideLoudnessBackfillWindow: hoisted.isTrackInsideWindowMock,
}));
import {
_resetLoudnessRefreshInflightForTest,
refreshLoudnessForTrack,
} from './loudnessRefresh';
beforeEach(() => {
_resetLoudnessRefreshInflightForTest();
hoisted.auth.loudnessTargetLufs = -14;
hoisted.auth.normalizationEngine = 'loudness';
hoisted.playerState.queue = [];
hoisted.playerState.queueIndex = 0;
hoisted.playerState.currentTrack = null;
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(null);
hoisted.playerSetStateMock.mockClear();
hoisted.emitDebugMock.mockClear();
hoisted.forgetLoudnessMock.mockClear();
hoisted.markLoudnessStableMock.mockClear();
hoisted.markBackfillInFlightMock.mockClear();
hoisted.clearBackfillInFlightMock.mockClear();
hoisted.resetBackfillAttemptsMock.mockClear();
hoisted.getBackfillAttemptsMock.mockReset();
hoisted.getBackfillAttemptsMock.mockReturnValue(0);
hoisted.isBackfillInFlightMock.mockReset();
hoisted.isBackfillInFlightMock.mockReturnValue(false);
hoisted.isTrackInsideWindowMock.mockReset();
hoisted.isTrackInsideWindowMock.mockReturnValue(true);
hoisted.playerState.updateReplayGainForCurrentTrack = vi.fn();
});
describe('refreshLoudnessForTrack', () => {
it('is a no-op for empty trackId', async () => {
await refreshLoudnessForTrack('');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
});
it('coalesces concurrent calls for the same key into one inflight promise', async () => {
hoisted.invokeMock.mockResolvedValue(null);
const p1 = refreshLoudnessForTrack('t1');
const p2 = refreshLoudnessForTrack('t1');
await Promise.all([p1, p2]);
// One analysis_get_loudness_for_track call shared between both awaiters.
const getCalls = hoisted.invokeMock.mock.calls.filter(c => c[0] === 'analysis_get_loudness_for_track');
expect(getCalls).toHaveLength(1);
});
it('marks loudness stable on a hit row', async () => {
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 123 });
await refreshLoudnessForTrack('t1');
expect(hoisted.markLoudnessStableMock).toHaveBeenCalledWith('t1', -7);
expect(hoisted.resetBackfillAttemptsMock).toHaveBeenCalledWith('t1');
});
it('forgets the cached value on a miss row', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
await refreshLoudnessForTrack('t1');
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
});
it('enqueues a backfill when conditions are met (loudness engine, not inflight, attempts < max, in window)', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).toHaveBeenCalledWith('t1', 1);
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
expect(enqueueCall).toBeDefined();
});
it('skips backfill when outside the prefetch window', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
hoisted.isTrackInsideWindowMock.mockReturnValueOnce(false);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
const enqueueCall = hoisted.invokeMock.mock.calls.find(c => c[0] === 'analysis_enqueue_seed_from_url');
expect(enqueueCall).toBeUndefined();
});
it('skips backfill when attempts already at max', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
hoisted.getBackfillAttemptsMock.mockReturnValueOnce(2);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
const throttledCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'backfill:throttled');
expect(throttledCalls.length).toBeGreaterThan(0);
});
it('skips backfill when already inflight', async () => {
hoisted.invokeMock.mockResolvedValueOnce(null);
hoisted.isBackfillInFlightMock.mockReturnValueOnce(true);
await refreshLoudnessForTrack('t1');
expect(hoisted.markBackfillInFlightMock).not.toHaveBeenCalled();
});
it('discards results and retries when the LUFS target changes mid-flight', async () => {
hoisted.invokeMock.mockImplementationOnce(async () => {
hoisted.auth.loudnessTargetLufs = -10; // target changes during await
return { recommendedGainDb: -5, targetLufs: -14, updatedAt: 1 };
});
hoisted.invokeMock.mockResolvedValueOnce(null); // retry returns miss
await refreshLoudnessForTrack('t1');
// markLoudnessStable should NOT have been called from the first invocation —
// result is discarded because target changed.
expect(hoisted.markLoudnessStableMock).not.toHaveBeenCalled();
const staleCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:stale-target');
expect(staleCalls).toHaveLength(1);
// Drain pending recursive retries spawned via `void refreshLoudnessForTrack(...)`
// so they don't bleed into the next test's mock queue.
for (let i = 0; i < 10; i++) await Promise.resolve();
});
it('skips engine update when syncPlayingEngine is false', async () => {
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
await refreshLoudnessForTrack('t1', { syncPlayingEngine: false });
expect(hoisted.playerState.updateReplayGainForCurrentTrack).not.toHaveBeenCalled();
});
it('calls updateReplayGainForCurrentTrack by default on hit', async () => {
hoisted.invokeMock.mockResolvedValueOnce({ recommendedGainDb: -7, targetLufs: -14, updatedAt: 1 });
await refreshLoudnessForTrack('t1');
expect(hoisted.playerState.updateReplayGainForCurrentTrack).toHaveBeenCalledTimes(1);
});
it('forgets cache + emits refresh:error on a thrown invoke', async () => {
hoisted.invokeMock.mockRejectedValueOnce(new Error('rust busy'));
await refreshLoudnessForTrack('t1');
expect(hoisted.forgetLoudnessMock).toHaveBeenCalledWith('t1');
const errCalls = hoisted.emitDebugMock.mock.calls.filter(c => c[0] === 'refresh:error');
expect(errCalls).toHaveLength(1);
});
});
+146
View File
@@ -0,0 +1,146 @@
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { emitNormalizationDebug } from './normalizationDebug';
import {
forgetLoudnessGain,
markLoudnessStable,
} from './loudnessGainCache';
import {
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
clearBackfillInFlight,
getBackfillAttempts,
isBackfillInFlight,
markBackfillInFlight,
resetBackfillAttempts,
} from './loudnessBackfillState';
import {
LOUDNESS_BACKFILL_WINDOW_AHEAD,
isTrackInsideLoudnessBackfillWindow,
} from './loudnessBackfillWindow';
/** Subsonic-server loudness-cache row as Rust hands it back. */
type LoudnessCachePayload = {
integratedLufs: number;
truePeak: number;
recommendedGainDb: number;
targetLufs: number;
updatedAt: number;
};
/**
* 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 preload + current-track completion can
* stack two SQLite reads + two state writes.
*/
const loudnessRefreshInflight = new Map<string, Promise<void>>();
/**
* Fetch the loudness gain for `trackId` from Rust and apply it to the
* loudness-gain cache + player-store debug fields. When `syncPlayingEngine`
* is false (default true), the engine is NOT asked to update its
* replay-gain — used when prefetching neighbour tracks.
*
* Coalesces by (trackId, syncEngine, target) so concurrent calls share a
* single inflight promise.
*/
export async function refreshLoudnessForTrack(
trackId: string,
opts?: { syncPlayingEngine?: boolean },
): Promise<void> {
if (!trackId) return;
const syncEngine = opts?.syncPlayingEngine !== false;
const target = useAuthStore.getState().loudnessTargetLufs;
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`;
const existing = loudnessRefreshInflight.get(inflightKey);
if (existing) return existing;
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
.finally(() => { loudnessRefreshInflight.delete(inflightKey); });
loudnessRefreshInflight.set(inflightKey, job);
return job;
}
async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise<void> {
emitNormalizationDebug('refresh:start', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: requestedTarget,
});
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine });
return;
}
if (!row || !Number.isFinite(row.recommendedGainDb)) {
forgetLoudnessGain(trackId);
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
const auth = useAuthStore.getState();
const attempts = getBackfillAttempts(trackId);
if (auth.normalizationEngine === 'loudness'
&& !isBackfillInFlight(trackId)
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
const live = usePlayerStore.getState();
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) {
emitNormalizationDebug('backfill:skipped-outside-window', {
trackId,
queueIndex: live.queueIndex,
aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD,
});
return;
}
markBackfillInFlight(trackId, attempts + 1);
const url = buildStreamUrl(trackId);
emitNormalizationDebug('backfill:enqueue', {
trackId,
url: redactSubsonicUrlForLog(url),
attempt: attempts + 1,
});
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
.finally(() => {
clearBackfillInFlight(trackId);
});
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
}
usePlayerStore.setState({
normalizationDbgSource: 'refresh:miss',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
});
return;
}
markLoudnessStable(trackId, row.recommendedGainDb);
resetBackfillAttempts(trackId);
emitNormalizationDebug('refresh:hit', { trackId, row });
usePlayerStore.setState({
normalizationDbgSource: 'refresh:hit',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: row.recommendedGainDb,
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
});
if (syncEngine) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
} catch {
forgetLoudnessGain(trackId);
emitNormalizationDebug('refresh:error', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
}
}
/** Test-only: drop pending refresh promises so each spec starts clean. */
export function _resetLoudnessRefreshInflightForTest(): void {
loudnessRefreshInflight.clear();
}
+6 -162
View File
@@ -6,7 +6,6 @@ import { showToast } from '../utils/toast';
import i18n from '../i18n';
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic';
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
@@ -30,7 +29,7 @@ import {
sameQueueTrackId,
shallowCloneQueueTracks,
} from '../utils/queueIdentity';
import { coerceWaveformBins, waveformBlobLenOk } from '../utils/waveformParse';
import { waveformBlobLenOk } from '../utils/waveformParse';
import { normalizationAlmostEqual } from '../utils/normalizationCompare';
import { isRecoverableSeekError } from '../utils/seekErrors';
import {
@@ -49,13 +48,11 @@ import { emitNormalizationDebug } from './normalizationDebug';
import { isInOrbitSession } from './orbitSession';
import {
clearLoudnessCacheStateForTrackId,
forgetLoudnessGain,
getCachedLoudnessGain,
hasStableLoudness,
isReplayGainActive,
loudnessCacheStateKeysForTrackId,
loudnessGainDbForEngineBind,
markLoudnessStable,
setCachedLoudnessGain,
} from './loudnessGainCache';
import {
@@ -69,26 +66,11 @@ import {
invokeAudioSetNormalizationDeduped,
invokeAudioUpdateReplayGainDeduped,
} from './normalizationIpcDedupe';
import {
bumpWaveformRefreshGen,
getWaveformRefreshGen,
} from './waveformRefreshGen';
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
import { applySkipStarOnManualNext } from './skipStarRating';
import {
MAX_BACKFILL_ATTEMPTS_PER_TRACK,
clearBackfillInFlight,
getBackfillAttempts,
isBackfillInFlight,
markBackfillInFlight,
resetBackfillAttempts,
resetLoudnessBackfillStateForTrackId,
} from './loudnessBackfillState';
import {
LOUDNESS_BACKFILL_WINDOW_AHEAD,
collectLoudnessBackfillWindowTrackIds,
isTrackInsideLoudnessBackfillWindow,
} from './loudnessBackfillWindow';
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
import {
flushPlayQueuePosition,
flushQueueSyncToServer,
@@ -114,6 +96,8 @@ import {
} from './seekTargetState';
import { tryAcquireTogglePlayLock } from './togglePlayLock';
import { reseedLoudnessForTrackId } from './loudnessReseed';
import { refreshWaveformForTrack } from './waveformRefresh';
import { refreshLoudnessForTrack } from './loudnessRefresh';
// Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports.
@@ -356,24 +340,6 @@ export interface PlayerState {
closeSongInfo: () => void;
}
type WaveformCachePayload = {
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
bins: number[] | Uint8Array;
binCount: number;
isPartial: boolean;
knownUntilSec: number;
durationSec: number;
updatedAt: number;
};
type LoudnessCachePayload = {
integratedLufs: number;
truePeak: number;
recommendedGainDb: number;
targetLufs: number;
updatedAt: number;
};
type NormalizationStatePayload = {
engine: 'off' | 'replaygain' | 'loudness' | string;
currentGainDb: number | null;
@@ -625,128 +591,6 @@ radioAudio.addEventListener('suspend', () => {
clearRadioReconnectTimer();
});
/** 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
* preload + current-track completion can stack two SQLite reads + two state writes. */
const loudnessRefreshInflight = new Map<string, Promise<void>>();
async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return;
const gen = getWaveformRefreshGen(trackId);
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
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;
if (!bins || bins.length === 0) {
usePlayerStore.setState({
waveformBins: null,
});
return;
}
usePlayerStore.setState({
waveformBins: bins,
});
} catch {
// best-effort; seekbar falls back to placeholder waveform
}
}
/** When `syncPlayingEngine` is false, only update the loudness gain cache (e.g. queue neighbour) — do not call `audio_update_replay_gain` for the already-playing track. */
async function refreshLoudnessForTrack(
trackId: string,
opts?: { syncPlayingEngine?: boolean },
): Promise<void> {
if (!trackId) return;
const syncEngine = opts?.syncPlayingEngine !== false;
const target = useAuthStore.getState().loudnessTargetLufs;
const inflightKey = `${trackId}|${syncEngine ? 'sync' : 'no-sync'}|${target}`;
const existing = loudnessRefreshInflight.get(inflightKey);
if (existing) return existing;
const job = (async () => { await runRefreshLoudnessForTrack(trackId, syncEngine); })()
.finally(() => { loudnessRefreshInflight.delete(inflightKey); });
loudnessRefreshInflight.set(inflightKey, job);
return job;
}
async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): Promise<void> {
emitNormalizationDebug('refresh:start', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const requestedTarget = useAuthStore.getState().loudnessTargetLufs;
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: requestedTarget,
});
if (useAuthStore.getState().loudnessTargetLufs !== requestedTarget) {
emitNormalizationDebug('refresh:stale-target', { trackId, requestedTarget });
void refreshLoudnessForTrack(trackId, { syncPlayingEngine: syncEngine });
return;
}
if (!row || !Number.isFinite(row.recommendedGainDb)) {
forgetLoudnessGain(trackId);
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
const auth = useAuthStore.getState();
const attempts = getBackfillAttempts(trackId);
if (auth.normalizationEngine === 'loudness'
&& !isBackfillInFlight(trackId)
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
const live = usePlayerStore.getState();
if (!isTrackInsideLoudnessBackfillWindow(trackId, live.queue, live.queueIndex, live.currentTrack)) {
emitNormalizationDebug('backfill:skipped-outside-window', {
trackId,
queueIndex: live.queueIndex,
aheadWindow: LOUDNESS_BACKFILL_WINDOW_AHEAD,
});
return;
}
markBackfillInFlight(trackId, attempts + 1);
const url = buildStreamUrl(trackId);
emitNormalizationDebug('backfill:enqueue', {
trackId,
url: redactSubsonicUrlForLog(url),
attempt: attempts + 1,
});
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
.finally(() => {
clearBackfillInFlight(trackId);
});
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
}
usePlayerStore.setState({
normalizationDbgSource: 'refresh:miss',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
});
return;
}
markLoudnessStable(trackId, row.recommendedGainDb);
resetBackfillAttempts(trackId);
emitNormalizationDebug('refresh:hit', { trackId, row });
usePlayerStore.setState({
normalizationDbgSource: 'refresh:hit',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: row.recommendedGainDb,
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
});
if (syncEngine) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
} catch {
forgetLoudnessGain(trackId);
emitNormalizationDebug('refresh:error', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
}
}
function prefetchLoudnessForEnqueuedTracks(
mergedQueue: Track[],
queueIndex: number,
+99
View File
@@ -0,0 +1,99 @@
/**
* `refreshWaveformForTrack` fetches an analysis row from Rust and applies
* it to the player store — but only if the refresh generation hasn't been
* bumped meanwhile and the track is still current. The tests pin both
* guards and the success / null-row / empty-bins / error branches.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as unknown),
coerceWaveformBinsMock: vi.fn((bins: unknown) => {
if (bins == null) return null;
if (Array.isArray(bins) && bins.length === 0) return null;
return bins as number[];
}),
playerSnapshot: {
currentTrack: null as { id: string } | null,
},
playerSetStateMock: vi.fn(),
gen: 0,
getGenMock: vi.fn(() => hoisted.gen),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
vi.mock('../utils/waveformParse', () => ({ coerceWaveformBins: hoisted.coerceWaveformBinsMock }));
vi.mock('./playerStore', () => ({
usePlayerStore: {
getState: () => hoisted.playerSnapshot,
setState: hoisted.playerSetStateMock,
},
}));
vi.mock('./waveformRefreshGen', () => ({
getWaveformRefreshGen: hoisted.getGenMock,
}));
import { refreshWaveformForTrack } from './waveformRefresh';
beforeEach(() => {
hoisted.invokeMock.mockReset();
hoisted.invokeMock.mockResolvedValue(null);
hoisted.coerceWaveformBinsMock.mockClear();
hoisted.playerSetStateMock.mockClear();
hoisted.playerSnapshot.currentTrack = null;
hoisted.gen = 0;
});
describe('refreshWaveformForTrack', () => {
it('is a no-op for empty trackId', async () => {
await refreshWaveformForTrack('');
expect(hoisted.invokeMock).not.toHaveBeenCalled();
});
it('discards results when the gen has been bumped since the call started', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockImplementationOnce(async () => {
hoisted.gen = 99; // simulate concurrent bump
return { bins: [1, 2, 3] };
});
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
});
it('skips when the track is no longer current after the fetch', async () => {
hoisted.playerSnapshot.currentTrack = { id: 'other' };
hoisted.invokeMock.mockResolvedValueOnce({ bins: [1, 2, 3] });
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
});
it('blanks bins when the row is null', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockResolvedValueOnce(null);
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
});
it('blanks bins when coerceWaveformBins returns null (invalid shape)', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockResolvedValueOnce({ bins: 'garbage' });
hoisted.coerceWaveformBinsMock.mockReturnValueOnce(null);
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: null });
});
it('applies the coerced bins on a clean fetch', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockResolvedValueOnce({ bins: [10, 20, 30] });
hoisted.coerceWaveformBinsMock.mockReturnValueOnce([10, 20, 30]);
await refreshWaveformForTrack('t1');
expect(hoisted.playerSetStateMock).toHaveBeenCalledWith({ waveformBins: [10, 20, 30] });
});
it('swallows fetch errors silently (placeholder waveform stays)', async () => {
hoisted.playerSnapshot.currentTrack = { id: 't1' };
hoisted.invokeMock.mockRejectedValueOnce(new Error('boom'));
await expect(refreshWaveformForTrack('t1')).resolves.toBeUndefined();
expect(hoisted.playerSetStateMock).not.toHaveBeenCalled();
});
});
+45
View File
@@ -0,0 +1,45 @@
import { invoke } from '@tauri-apps/api/core';
import { coerceWaveformBins } from '../utils/waveformParse';
import { usePlayerStore } from './playerStore';
import { getWaveformRefreshGen } from './waveformRefreshGen';
/** Subsonic-server waveform-cache row as Rust hands it back. */
export type WaveformCachePayload = {
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
bins: number[] | Uint8Array;
binCount: number;
isPartial: boolean;
knownUntilSec: number;
durationSec: number;
updatedAt: number;
};
/**
* Fetch the cached waveform row for `trackId` from Rust and apply its bins
* to the player store — but only if (a) the refresh generation snapshot
* still matches (no newer invalidation has fired meanwhile) and (b) the
* track is still the current one. Best-effort: any failure leaves the
* seekbar with the placeholder waveform.
*/
export async function refreshWaveformForTrack(trackId: string): Promise<void> {
if (!trackId) return;
const gen = getWaveformRefreshGen(trackId);
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
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;
if (!bins || bins.length === 0) {
usePlayerStore.setState({
waveformBins: null,
});
return;
}
usePlayerStore.setState({
waveformBins: bins,
});
} catch {
// best-effort; seekbar falls back to placeholder waveform
}
}