mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
fix(analysis): native library backfill coordinator for advanced strategy (#881)
* feat(analysis): native library backfill coordinator for advanced strategy Move advanced analytics scheduling from the webview loop into a Rust background worker (configure + spawn_blocking batch/enqueue), matching cover backfill. Scan hash+BPM gap tracks instead of the full library; suppress low-priority analysis UI events; limit loudness refresh IPC to the playback window. * fix(analysis): flat backfill configure IPC and restore probe track-perf Use flattened Tauri args like cover backfill so release/prod invoke works; keep emitting analysis:track-perf for library low-priority work so Performance Probe tpm/last-track stats update while waveform/enrichment UI events stay suppressed. * chore(analysis): fix clippy and drop duplicate TS backfill policy Allow too_many_arguments on library_analysis_backfill_configure for CI; remove unused frontend batch API and TS watermark helpers now owned in Rust; clarify analysis_emits_ui_events comment for low-priority track-perf. * docs: CHANGELOG and credits for PR #881 native analysis backfill
This commit is contained in:
+18
-21
@@ -28,12 +28,6 @@ export interface AnalysisPipelineQueueStatsDto {
|
||||
cpuDecodeActiveLow: number;
|
||||
}
|
||||
|
||||
export interface LibraryAnalysisBackfillBatchDto {
|
||||
trackIds: string[];
|
||||
nextCursor: string | null;
|
||||
exhausted: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryAnalysisProgressDto {
|
||||
totalTracks: number;
|
||||
pendingTracks: number;
|
||||
@@ -52,8 +46,6 @@ export interface AnalysisDeleteServerReportDto {
|
||||
loudness: number;
|
||||
}
|
||||
|
||||
export const LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE = 20;
|
||||
|
||||
function serverIndexKeyForId(serverId: string): string {
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
if (!server) return serverId;
|
||||
@@ -68,19 +60,6 @@ export function analysisGetPipelineQueueStats(): Promise<AnalysisPipelineQueueSt
|
||||
return invoke<AnalysisPipelineQueueStatsDto>('analysis_get_pipeline_queue_stats');
|
||||
}
|
||||
|
||||
export function libraryAnalysisBackfillBatch(
|
||||
serverId: string,
|
||||
cursor?: string | null,
|
||||
limit = LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE,
|
||||
): Promise<LibraryAnalysisBackfillBatchDto> {
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke<LibraryAnalysisBackfillBatchDto>('library_analysis_backfill_batch', {
|
||||
serverId: indexKey,
|
||||
cursor: cursor ?? null,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export function libraryAnalysisProgress(
|
||||
serverId: string,
|
||||
): Promise<LibraryAnalysisProgressDto> {
|
||||
@@ -157,3 +136,21 @@ export function analysisEnqueueSeedFromUrl(
|
||||
const indexKey = serverIndexKeyForId(serverId);
|
||||
return invoke('analysis_enqueue_seed_from_url', { trackId, url, serverId: indexKey, priority });
|
||||
}
|
||||
|
||||
export type LibraryAnalysisBackfillConfigureArgs = {
|
||||
enabled: boolean;
|
||||
serverIndexKey: string;
|
||||
libraryServerId: string;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
workers: number;
|
||||
};
|
||||
|
||||
/** Start/stop native library analysis backfill (advanced strategy only). */
|
||||
export function libraryAnalysisBackfillConfigure(
|
||||
args: LibraryAnalysisBackfillConfigureArgs,
|
||||
): Promise<void> {
|
||||
// Flat payload — same as `library_cover_backfill_configure` (not `{ args: … }`).
|
||||
return invoke('library_analysis_backfill_configure', args);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Albums: combined browse filters (genre/year/favorites/lossless/compilations), session restore from album detail, favorites reconcile via local index (PR #876)',
|
||||
'Artist detail: sort albums by year (newest/oldest) in the Albums section (PR #877)',
|
||||
'Cover art: Windows thumbnails, tier fallback, PNG decode, Subsonic coverArt id resolution (PR #878)',
|
||||
'Analytics: native advanced library backfill coordinator — UI stays responsive on large libraries (PR #881)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,64 +1,38 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { buildStreamUrlForServer } from '../api/subsonicStreamUrl';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
analysisEnqueueSeedFromUrl,
|
||||
analysisGetPipelineQueueStats,
|
||||
analysisSetPipelineParallelism,
|
||||
libraryAnalysisBackfillBatch,
|
||||
libraryCountLiveTracks,
|
||||
libraryAnalysisProgress,
|
||||
libraryAnalysisBackfillConfigure,
|
||||
} from '../api/analysis';
|
||||
import { librarySqlServerId } from '../api/coverCache';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useAnalysisStrategyStore } from '../store/analysisStrategyStore';
|
||||
import { DEFAULT_ADVANCED_PARALLELISM } from '../utils/library/analysisStrategy';
|
||||
import {
|
||||
libraryBackfillNeedsTopUp,
|
||||
libraryBackfillTopUpLimit,
|
||||
} from '../utils/library/libraryAnalysisBackfillPolicy';
|
||||
import { libraryIsReady } from '../utils/library/libraryReady';
|
||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||
|
||||
const TOP_UP_POLL_MS = 500;
|
||||
const STEADY_POLL_MS = 2000;
|
||||
const READY_POLL_MS = 5000;
|
||||
const EXHAUSTED_PAUSE_MS = 15_000;
|
||||
const COMPLETED_RECHECK_MS = 5 * 60_000;
|
||||
/** Consecutive exhausted scans with zero enqueue before treating the library as done. */
|
||||
const EXHAUSTED_DONE_STREAK = 2;
|
||||
|
||||
const EMPTY_PIPELINE_STATS = {
|
||||
pipelineWorkers: 1,
|
||||
httpQueued: 0,
|
||||
httpQueuedHigh: 0,
|
||||
httpQueuedMiddle: 0,
|
||||
httpQueuedLow: 0,
|
||||
httpDownloadActive: 0,
|
||||
httpDownloadActiveHigh: 0,
|
||||
httpDownloadActiveMiddle: 0,
|
||||
httpDownloadActiveLow: 0,
|
||||
cpuQueued: 0,
|
||||
cpuQueuedHigh: 0,
|
||||
cpuQueuedMiddle: 0,
|
||||
cpuQueuedLow: 0,
|
||||
cpuDecodeActive: 0,
|
||||
cpuDecodeActiveHigh: 0,
|
||||
cpuDecodeActiveMiddle: 0,
|
||||
cpuDecodeActiveLow: 0,
|
||||
};
|
||||
const DISABLED_CONFIGURE = {
|
||||
enabled: false,
|
||||
serverIndexKey: '',
|
||||
libraryServerId: '',
|
||||
serverUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
workers: DEFAULT_ADVANCED_PARALLELISM,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Advanced analytics strategy: keep the HTTP backfill backlog above worker count
|
||||
* (watermark target ≈ workers × 3) so parallel downloads stay busy. Library
|
||||
* tracks enqueue at low priority; playback tiers stay ahead in Rust.
|
||||
* Advanced analytics strategy: native coordinator in Rust (see `library_analysis_backfill`).
|
||||
* Webview only passes session + worker count — no planning loop or batch IPC.
|
||||
*/
|
||||
export function useLibraryAnalysisBackfill(enabled = true): void {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const server = useAuthStore(s =>
|
||||
s.activeServerId ? s.servers.find(srv => srv.id === s.activeServerId) : undefined,
|
||||
);
|
||||
const getBaseUrl = useAuthStore(s => s.getBaseUrl);
|
||||
const strategy = useAnalysisStrategyStore(s => s.getStrategyForServer(activeServerId));
|
||||
const advancedParallelism = useAnalysisStrategyStore(
|
||||
s => s.getAdvancedParallelismForServer(activeServerId),
|
||||
);
|
||||
const cursorRef = useRef<string | null>(null);
|
||||
const completedTotalRef = useRef<number | null>(null);
|
||||
const exhaustedIdleStreakRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -68,110 +42,38 @@ export function useLibraryAnalysisBackfill(enabled = true): void {
|
||||
}, [strategy, advancedParallelism, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || strategy !== 'advanced' || !activeServerId) return;
|
||||
|
||||
let cancelled = false;
|
||||
const serverId = activeServerId;
|
||||
|
||||
void (async () => {
|
||||
while (!cancelled) {
|
||||
if (completedTotalRef.current !== null) {
|
||||
const totalTracks = await libraryCountLiveTracks(serverId).catch(() => null);
|
||||
if (!Number.isFinite(totalTracks)) {
|
||||
await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS));
|
||||
continue;
|
||||
}
|
||||
if (totalTracks === completedTotalRef.current) {
|
||||
await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS));
|
||||
continue;
|
||||
}
|
||||
completedTotalRef.current = null;
|
||||
cursorRef.current = null;
|
||||
exhaustedIdleStreakRef.current = 0;
|
||||
}
|
||||
|
||||
if (!(await libraryIsReady(serverId))) {
|
||||
await new Promise(r => setTimeout(r, READY_POLL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
const workers = advancedParallelism;
|
||||
const stats = await analysisGetPipelineQueueStats().catch(
|
||||
() => EMPTY_PIPELINE_STATS,
|
||||
);
|
||||
|
||||
if (!libraryBackfillNeedsTopUp(stats, workers)) {
|
||||
await new Promise(r => setTimeout(r, STEADY_POLL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
const fetchLimit = libraryBackfillTopUpLimit(stats, workers);
|
||||
if (fetchLimit <= 0) {
|
||||
await new Promise(r => setTimeout(r, TOP_UP_POLL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
const batch = await libraryAnalysisBackfillBatch(
|
||||
serverId,
|
||||
cursorRef.current,
|
||||
fetchLimit,
|
||||
).catch(() => null);
|
||||
|
||||
if (!batch || cancelled) {
|
||||
await new Promise(r => setTimeout(r, TOP_UP_POLL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
cursorRef.current = batch.nextCursor;
|
||||
|
||||
await Promise.all(
|
||||
batch.trackIds.map(trackId =>
|
||||
analysisEnqueueSeedFromUrl(
|
||||
trackId,
|
||||
buildStreamUrlForServer(serverId, trackId),
|
||||
serverId,
|
||||
'low',
|
||||
).catch(() => {
|
||||
/* Rust skips completed tracks */
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (batch.trackIds.length > 0) {
|
||||
exhaustedIdleStreakRef.current = 0;
|
||||
}
|
||||
|
||||
if (batch.exhausted) {
|
||||
cursorRef.current = null;
|
||||
const progress = await libraryAnalysisProgress(serverId).catch(() => null);
|
||||
const pending = progress?.pendingTracks ?? -1;
|
||||
if (pending <= 0 && batch.trackIds.length === 0) {
|
||||
exhaustedIdleStreakRef.current += 1;
|
||||
if (exhaustedIdleStreakRef.current >= EXHAUSTED_DONE_STREAK && progress) {
|
||||
completedTotalRef.current = progress.totalTracks;
|
||||
await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS));
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
exhaustedIdleStreakRef.current = 0;
|
||||
}
|
||||
if (pending > 0) {
|
||||
continue;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, EXHAUSTED_PAUSE_MS));
|
||||
} else if (batch.trackIds.length === 0) {
|
||||
await new Promise(r => setTimeout(r, TOP_UP_POLL_MS));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cursorRef.current = null;
|
||||
completedTotalRef.current = null;
|
||||
exhaustedIdleStreakRef.current = 0;
|
||||
const disable = () => {
|
||||
void libraryAnalysisBackfillConfigure(DISABLED_CONFIGURE);
|
||||
};
|
||||
}, [strategy, activeServerId, advancedParallelism, enabled]);
|
||||
|
||||
if (!enabled || strategy !== 'advanced' || !activeServerId || !server) {
|
||||
disable();
|
||||
return disable;
|
||||
}
|
||||
|
||||
const indexKey = serverIndexKeyForProfile(server);
|
||||
const baseUrl = getBaseUrl();
|
||||
void libraryAnalysisBackfillConfigure({
|
||||
enabled: true,
|
||||
serverIndexKey: indexKey,
|
||||
libraryServerId: librarySqlServerId(activeServerId),
|
||||
serverUrl: baseUrl,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
workers: advancedParallelism,
|
||||
}).catch(() => {
|
||||
/* coordinator optional; avoid unhandled rejection noise in release */
|
||||
});
|
||||
|
||||
return disable;
|
||||
}, [
|
||||
enabled,
|
||||
strategy,
|
||||
activeServerId,
|
||||
server?.url,
|
||||
server?.username,
|
||||
server?.password,
|
||||
advancedParallelism,
|
||||
getBaseUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
hasStableLoudness,
|
||||
setCachedLoudnessGain,
|
||||
} from '../loudnessGainCache';
|
||||
import { isTrackInsideLoudnessBackfillWindow } from '../loudnessBackfillWindow';
|
||||
import { refreshLoudnessForTrack } from '../loudnessRefresh';
|
||||
import { emitNormalizationDebug } from '../normalizationDebug';
|
||||
import {
|
||||
@@ -96,8 +97,19 @@ export function setupAudioEngineListeners(): () => void {
|
||||
emitNormalizationDebug('backfill:applied', { trackId: currentId });
|
||||
return;
|
||||
}
|
||||
// Backfill finished for another id (e.g. next in queue): refresh loudness cache only
|
||||
// so the cached gain is ready before `audio_play` / gapless chain.
|
||||
// Library aggressive backfill completes thousands of tracks — only warm loudness
|
||||
// for the playback window (current + next N). Skip IPC for the rest.
|
||||
const live = usePlayerStore.getState();
|
||||
if (
|
||||
!isTrackInsideLoudnessBackfillWindow(
|
||||
payloadTrackId,
|
||||
live.queueItems,
|
||||
live.queueIndex,
|
||||
live.currentTrack,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
|
||||
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
|
||||
}),
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeLibraryBackfillTargetDepth,
|
||||
libraryBackfillNeedsTopUp,
|
||||
libraryBackfillPipelineBacklog,
|
||||
libraryBackfillTopUpLimit,
|
||||
} from './libraryAnalysisBackfillPolicy';
|
||||
|
||||
describe('libraryAnalysisBackfillPolicy', () => {
|
||||
it('targets workers × 3 with floor and cap', () => {
|
||||
expect(computeLibraryBackfillTargetDepth(1)).toBe(8);
|
||||
expect(computeLibraryBackfillTargetDepth(4)).toBe(12);
|
||||
expect(computeLibraryBackfillTargetDepth(8)).toBe(24);
|
||||
expect(computeLibraryBackfillTargetDepth(100)).toBe(240);
|
||||
});
|
||||
|
||||
const zeroCpu = { cpuQueued: 0, cpuDecodeActive: 0 };
|
||||
|
||||
it('measures backlog as HTTP plus CPU seed pipeline load', () => {
|
||||
expect(
|
||||
libraryBackfillPipelineBacklog({
|
||||
httpQueued: 5,
|
||||
httpDownloadActive: 3,
|
||||
...zeroCpu,
|
||||
}),
|
||||
).toBe(8);
|
||||
expect(
|
||||
libraryBackfillPipelineBacklog({
|
||||
httpQueued: 0,
|
||||
httpDownloadActive: 0,
|
||||
cpuQueued: 4,
|
||||
cpuDecodeActive: 2,
|
||||
}),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('requests top-up while backlog stays below target', () => {
|
||||
const stats = { httpQueued: 2, httpDownloadActive: 1, ...zeroCpu };
|
||||
expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(true);
|
||||
expect(libraryBackfillTopUpLimit(stats, 8)).toBe(20);
|
||||
});
|
||||
|
||||
it('stops top-up when backlog meets target', () => {
|
||||
const stats = { httpQueued: 20, httpDownloadActive: 4, ...zeroCpu };
|
||||
expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(false);
|
||||
expect(libraryBackfillTopUpLimit(stats, 8)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { AnalysisPipelineQueueStatsDto } from '../../api/analysis';
|
||||
import { LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE } from '../../api/analysis';
|
||||
|
||||
/** Target backlog ≈ workers × multiplier (min floor, max cap). */
|
||||
export const LIBRARY_BACKLOG_DEPTH_MULTIPLIER = 3;
|
||||
export const LIBRARY_BACKLOG_MIN = 8;
|
||||
export const LIBRARY_BACKLOG_MAX = 240;
|
||||
|
||||
export function computeLibraryBackfillTargetDepth(workers: number): number {
|
||||
const w = Math.max(1, Math.round(workers));
|
||||
return Math.min(
|
||||
LIBRARY_BACKLOG_MAX,
|
||||
Math.max(LIBRARY_BACKLOG_MIN, w * LIBRARY_BACKLOG_DEPTH_MULTIPLIER),
|
||||
);
|
||||
}
|
||||
|
||||
type PipelineBacklogStats = Pick<
|
||||
AnalysisPipelineQueueStatsDto,
|
||||
'httpQueued' | 'httpDownloadActive' | 'cpuQueued' | 'cpuDecodeActive'
|
||||
>;
|
||||
|
||||
/** HTTP + in-flight CPU seed jobs (decode backpressure must not look like an empty pipeline). */
|
||||
export function libraryBackfillPipelineBacklog(stats: PipelineBacklogStats): number {
|
||||
return (
|
||||
stats.httpQueued
|
||||
+ stats.httpDownloadActive
|
||||
+ stats.cpuQueued
|
||||
+ stats.cpuDecodeActive
|
||||
);
|
||||
}
|
||||
|
||||
export function libraryBackfillNeedsTopUp(stats: PipelineBacklogStats, workers: number): boolean {
|
||||
return libraryBackfillPipelineBacklog(stats) < computeLibraryBackfillTargetDepth(workers);
|
||||
}
|
||||
|
||||
export function libraryBackfillTopUpLimit(stats: PipelineBacklogStats, workers: number): number {
|
||||
const target = computeLibraryBackfillTargetDepth(workers);
|
||||
const deficit = target - libraryBackfillPipelineBacklog(stats);
|
||||
if (deficit <= 0) return 0;
|
||||
return Math.min(LIBRARY_ANALYSIS_BACKFILL_BATCH_SIZE, deficit);
|
||||
}
|
||||
Reference in New Issue
Block a user