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:
cucadmuh
2026-05-28 10:49:31 +03:00
committed by GitHub
parent df3533bb5a
commit b24a7fc5cb
21 changed files with 888 additions and 387 deletions
+52 -150
View File
@@ -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,
]);
}