diff --git a/src/store/loudnessBackfillState.test.ts b/src/store/loudnessBackfillState.test.ts new file mode 100644 index 00000000..0e601d36 --- /dev/null +++ b/src/store/loudnessBackfillState.test.ts @@ -0,0 +1,91 @@ +/** + * Backfill state: two parallel maps that retry the per-track loudness + * analysis a bounded number of times. The interesting behaviours are the + * `markBackfillInFlight` atomicity (both flag + counter bump in one call) + * and the reseed reset that expands across the `stream:` / bare id forms + * via `loudnessCacheStateKeysForTrackId` (re-used from loudnessGainCache). + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { + MAX_BACKFILL_ATTEMPTS_PER_TRACK, + _resetBackfillStateForTest, + clearBackfillInFlight, + getBackfillAttempts, + isBackfillInFlight, + markBackfillInFlight, + resetBackfillAttempts, + resetLoudnessBackfillStateForTrackId, +} from './loudnessBackfillState'; + +afterEach(() => { + _resetBackfillStateForTest(); +}); + +describe('initial state', () => { + it('reports no inflight + 0 attempts for unknown tracks', () => { + expect(isBackfillInFlight('t1')).toBe(false); + expect(getBackfillAttempts('t1')).toBe(0); + }); +}); + +describe('markBackfillInFlight', () => { + it('atomically sets inflight flag and counter', () => { + markBackfillInFlight('t1', 1); + expect(isBackfillInFlight('t1')).toBe(true); + expect(getBackfillAttempts('t1')).toBe(1); + }); + + it('keeps tracks independent', () => { + markBackfillInFlight('a', 1); + markBackfillInFlight('b', 2); + expect(getBackfillAttempts('a')).toBe(1); + expect(getBackfillAttempts('b')).toBe(2); + clearBackfillInFlight('a'); + expect(isBackfillInFlight('a')).toBe(false); + expect(isBackfillInFlight('b')).toBe(true); + }); +}); + +describe('clearBackfillInFlight', () => { + it('clears the flag without touching the counter', () => { + markBackfillInFlight('t1', 1); + clearBackfillInFlight('t1'); + expect(isBackfillInFlight('t1')).toBe(false); + expect(getBackfillAttempts('t1')).toBe(1); // counter preserved + }); +}); + +describe('resetBackfillAttempts', () => { + it('zeros the counter without touching the inflight flag', () => { + markBackfillInFlight('t1', 2); + resetBackfillAttempts('t1'); + expect(getBackfillAttempts('t1')).toBe(0); + expect(isBackfillInFlight('t1')).toBe(true); + }); +}); + +describe('MAX_BACKFILL_ATTEMPTS_PER_TRACK', () => { + it('is the hard-coded threshold the runtime uses', () => { + expect(MAX_BACKFILL_ATTEMPTS_PER_TRACK).toBe(2); + }); +}); + +describe('resetLoudnessBackfillStateForTrackId', () => { + it('clears both maps for both id forms (bare + stream:)', () => { + markBackfillInFlight('t1', 1); + markBackfillInFlight('stream:t1', 2); + resetLoudnessBackfillStateForTrackId('t1'); + expect(isBackfillInFlight('t1')).toBe(false); + expect(isBackfillInFlight('stream:t1')).toBe(false); + expect(getBackfillAttempts('t1')).toBe(0); + expect(getBackfillAttempts('stream:t1')).toBe(0); + }); + + it('also works when invoked with the stream-prefixed form', () => { + markBackfillInFlight('t1', 1); + markBackfillInFlight('stream:t1', 2); + resetLoudnessBackfillStateForTrackId('stream:t1'); + expect(getBackfillAttempts('t1')).toBe(0); + expect(getBackfillAttempts('stream:t1')).toBe(0); + }); +}); diff --git a/src/store/loudnessBackfillState.ts b/src/store/loudnessBackfillState.ts new file mode 100644 index 00000000..1f147f3e --- /dev/null +++ b/src/store/loudnessBackfillState.ts @@ -0,0 +1,56 @@ +import { loudnessCacheStateKeysForTrackId } from './loudnessGainCache'; + +/** + * Bounded retry state for the per-track loudness backfill: each `refresh:miss` + * for a track in loudness mode enqueues an `analysis_enqueue_seed_from_url` + * job, but only if (a) no enqueue is already inflight for that id and + * (b) the per-track attempt counter is below `MAX_BACKFILL_ATTEMPTS_PER_TRACK`. + * A `refresh:hit` resets the counter so the next miss starts fresh. + * + * Both maps stay keyed by the raw track id passed by the caller — the + * `loudnessCacheStateKeysForTrackId` expansion only matters when clearing + * during a reseed (`resetLoudnessBackfillStateForTrackId`). + */ + +export const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2; + +const analysisBackfillInFlightByTrackId: Record = {}; +const analysisBackfillAttemptsByTrackId: Record = {}; + +export function isBackfillInFlight(trackId: string): boolean { + return Boolean(analysisBackfillInFlightByTrackId[trackId]); +} + +export function getBackfillAttempts(trackId: string): number { + return analysisBackfillAttemptsByTrackId[trackId] ?? 0; +} + +/** Atomic: flag the track inflight AND bump the attempt counter to `nextAttempt`. */ +export function markBackfillInFlight(trackId: string, nextAttempt: number): void { + analysisBackfillInFlightByTrackId[trackId] = true; + analysisBackfillAttemptsByTrackId[trackId] = nextAttempt; +} + +/** Clear the inflight flag (called from the `.finally` of the enqueue promise). */ +export function clearBackfillInFlight(trackId: string): void { + delete analysisBackfillInFlightByTrackId[trackId]; +} + +/** Reset the attempt counter to 0 — called after a `refresh:hit`. */ +export function resetBackfillAttempts(trackId: string): void { + analysisBackfillAttemptsByTrackId[trackId] = 0; +} + +/** Full reset for both maps across the bare + `stream:` id forms — used during a reseed. */ +export function resetLoudnessBackfillStateForTrackId(trackId: string): void { + for (const k of loudnessCacheStateKeysForTrackId(trackId)) { + delete analysisBackfillInFlightByTrackId[k]; + analysisBackfillAttemptsByTrackId[k] = 0; + } +} + +/** Test-only: wipe both maps so each spec starts clean. */ +export function _resetBackfillStateForTest(): void { + for (const k of Object.keys(analysisBackfillInFlightByTrackId)) delete analysisBackfillInFlightByTrackId[k]; + for (const k of Object.keys(analysisBackfillAttemptsByTrackId)) delete analysisBackfillAttemptsByTrackId[k]; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 883238fb..13f12c35 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -75,6 +75,15 @@ import { } from './waveformRefreshGen'; import { touchHotCacheOnPlayback } from './hotCacheTouch'; import { applySkipStarOnManualNext } from './skipStarRating'; +import { + MAX_BACKFILL_ATTEMPTS_PER_TRACK, + clearBackfillInFlight, + getBackfillAttempts, + isBackfillInFlight, + markBackfillInFlight, + resetBackfillAttempts, + resetLoudnessBackfillStateForTrackId, +} from './loudnessBackfillState'; // Re-export the playback-progress public surface so existing call sites // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, @@ -447,9 +456,6 @@ let seekDebounce: ReturnType | null = null; let seekTarget: number | null = null; let seekTargetSetAt = 0; const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000; -const analysisBackfillInFlightByTrackId: Record = {}; -const analysisBackfillAttemptsByTrackId: Record = {}; -const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2; // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. let seekFallbackRetryTimer: ReturnType | null = null; let seekFallbackRetryStartedAt = 0; @@ -613,13 +619,6 @@ let lastGaplessSwitchTime = 0; * preload + current-track completion can stack two SQLite reads + two state writes. */ const loudnessRefreshInflight = new Map>(); -function resetLoudnessBackfillStateForTrackId(trackId: string) { - for (const k of loudnessCacheStateKeysForTrackId(trackId)) { - delete analysisBackfillInFlightByTrackId[k]; - analysisBackfillAttemptsByTrackId[k] = 0; - } -} - async function reseedLoudnessForTrackId(trackId: string) { if (!trackId) return; const auth = useAuthStore.getState(); @@ -718,9 +717,9 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): forgetLoudnessGain(trackId); emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null }); const auth = useAuthStore.getState(); - const attempts = analysisBackfillAttemptsByTrackId[trackId] ?? 0; + const attempts = getBackfillAttempts(trackId); if (auth.normalizationEngine === 'loudness' - && !analysisBackfillInFlightByTrackId[trackId] + && !isBackfillInFlight(trackId) && attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) { if (!isTrackInsideLoudnessBackfillWindow(trackId)) { emitNormalizationDebug('backfill:skipped-outside-window', { @@ -730,8 +729,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): }); return; } - analysisBackfillInFlightByTrackId[trackId] = true; - analysisBackfillAttemptsByTrackId[trackId] = attempts + 1; + markBackfillInFlight(trackId, attempts + 1); const url = buildStreamUrl(trackId); emitNormalizationDebug('backfill:enqueue', { trackId, @@ -742,7 +740,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): .then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 })) .catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) })) .finally(() => { - delete analysisBackfillInFlightByTrackId[trackId]; + clearBackfillInFlight(trackId); }); } else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) { emitNormalizationDebug('backfill:throttled', { trackId, attempts }); @@ -757,7 +755,7 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean): return; } markLoudnessStable(trackId, row.recommendedGainDb); - analysisBackfillAttemptsByTrackId[trackId] = 0; + resetBackfillAttempts(trackId); emitNormalizationDebug('refresh:hit', { trackId, row }); usePlayerStore.setState({ normalizationDbgSource: 'refresh:hit',