mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(player): E.13 — extract loudness-backfill retry state (#576)
The two parallel maps that bound the per-track loudness backfill retries (`analysisBackfillInFlightByTrackId`, `analysisBackfillAttemptsByTrackId`) plus the `MAX_BACKFILL_ATTEMPTS_PER_TRACK` constant and the `resetLoudnessBackfillStateForTrackId` reseed helper move into `src/store/loudnessBackfillState.ts`. The new module exposes a thin API: - `isBackfillInFlight` / `getBackfillAttempts` (reads) - `markBackfillInFlight(trackId, nextAttempt)` (atomic flag + counter) - `clearBackfillInFlight` (after promise settles) - `resetBackfillAttempts` (after refresh-hit) - `resetLoudnessBackfillStateForTrackId` (full reset across both id forms) playerStore's five direct-access sites inside `refreshLoudnessForTrack` become API calls; the mutables are no longer reachable from outside the module. 13 focused tests pin atomicity, independence between tracks, the partial-clear shapes (flag-only / counter-only), and the two-form reseed expansion. playerStore 3263 → 3261 LOC (small line delta because the inflight flag + counter setup collapses to one call but the reset helper is no longer inline).
This commit is contained in:
committed by
GitHub
parent
85df3e42c1
commit
c88649836e
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, true> = {};
|
||||
const analysisBackfillAttemptsByTrackId: Record<string, number> = {};
|
||||
|
||||
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];
|
||||
}
|
||||
+14
-16
@@ -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<typeof setTimeout> | null = null;
|
||||
let seekTarget: number | null = null;
|
||||
let seekTargetSetAt = 0;
|
||||
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
|
||||
const analysisBackfillInFlightByTrackId: Record<string, true> = {};
|
||||
const analysisBackfillAttemptsByTrackId: Record<string, number> = {};
|
||||
const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2;
|
||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | 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<string, Promise<void>>();
|
||||
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user