refactor(hot-cache): extract pure helpers + analysis-prune cluster from hotCachePrefetch.ts (#687)

The prefetch worker / replan orchestration and its shared module state
(pendingQueue, workerRunning, debounceTimer, graceEvictTimer) stay
entirely untouched in hotCachePrefetch.ts. Extracted only what does not
touch that state:

- hotCachePrefetch/helpers.ts       pure helpers — entryKey, byte
                                    estimators, debounceMs, frontend
                                    debug log, PREFETCH_AHEAD, PrefetchJob
- hotCachePrefetch/analysisPrune.ts  self-contained analysis-queue prune
                                    cluster (its own analysisPruneTimer +
                                    lastAnalysisPruneSig state, fully
                                    encapsulated behind a
                                    resetAnalysisPruneState wrapper)

Verbatim moves. The orchestration core diff is import-block + the three
analysis-cleanup lines collapsing to resetAnalysisPruneState() — nothing
else. The MainApp call site imports initHotCachePrefetch unchanged.
This commit is contained in:
Frank Stellmacher
2026-05-14 13:40:21 +02:00
committed by GitHub
parent f419081a2a
commit ec112922a2
3 changed files with 144 additions and 118 deletions
+15 -118
View File
@@ -2,7 +2,7 @@ import { buildStreamUrl } from './api/subsonicStreamUrl';
import type { Track } from './store/playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './store/authStore';
import { HOT_CACHE_PROTECT_AFTER_CURRENT, useHotCacheStore, type HotCacheEntry } from './store/hotCacheStore';
import { useHotCacheStore } from './store/hotCacheStore';
import { useOfflineStore } from './store/offlineStore';
import { usePlayerStore } from './store/playerStore';
import {
@@ -10,126 +10,25 @@ import {
clearHotCachePreviousGrace,
getDeferHotCachePrefetch,
} from './utils/hotCacheGate';
/** Settings → Logging → Debug (`frontend_debug_log` → Rust stderr), same as normalization / lucky-mix. */
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'hot-cache',
message: JSON.stringify(payload),
}).catch(() => {});
}
/** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */
const PREFETCH_AHEAD = 5;
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
/** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */
function sumCachedBytesInProtectedWindow(
queue: Track[],
queueIndex: number,
serverId: string,
entries: Record<string, HotCacheEntry>,
): number {
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
let sum = 0;
for (let i = protectLo; i <= protectHi; i++) {
const e = entries[entryKey(serverId, queue[i].id)];
if (e) sum += e.sizeBytes || 0;
}
return sum;
}
/** Conservative size guess so we do not prefetch when the protected window could exceed the cap. */
function estimateTrackHotCacheBytes(track: Track): number {
const sz = track.size;
if (typeof sz === 'number' && Number.isFinite(sz) && sz > 0) {
return Math.ceil(sz * 1.06);
}
const dur =
typeof track.duration === 'number' && Number.isFinite(track.duration) && track.duration > 0
? track.duration
: 240;
const sfx = (track.suffix || '').toLowerCase();
const lossless = /^(flac|wav|dsf|dff|alac|ape|wv)$/.test(sfx);
let kbps =
typeof track.bitRate === 'number' && Number.isFinite(track.bitRate) && track.bitRate > 0
? track.bitRate
: 320;
if (lossless && kbps < 800) {
kbps = Math.max(kbps, 900);
}
const raw = Math.ceil((dur * kbps * 1000) / 8);
return Math.max(256 * 1024, Math.ceil(raw * (lossless ? 1.2 : 1.15)));
}
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
import {
PREFETCH_AHEAD,
type PrefetchJob,
entryKey,
sumCachedBytesInProtectedWindow,
estimateTrackHotCacheBytes,
hotCacheFrontendDebug,
debounceMs,
} from './hotCachePrefetch/helpers';
import {
scheduleAnalysisQueuePruneFromPlaybackQueue,
resetAnalysisPruneState,
} from './hotCachePrefetch/analysisPrune';
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
let graceEvictTimer: ReturnType<typeof setTimeout> | null = null;
const pendingQueue: PrefetchJob[] = [];
let workerRunning = false;
let analysisPruneTimer: ReturnType<typeof setTimeout> | null = null;
let lastAnalysisPruneSig = '';
const ANALYSIS_PRUNE_DEBOUNCE_MS = 1200;
type AnalysisPrunePendingResult = {
keepCount: number;
httpRemoved: number;
cpuRemovedJobs: number;
cpuRemovedWaiters: number;
};
function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
const { queue, currentTrack } = usePlayerStore.getState();
const keepTrackIds: string[] = [];
const seen = new Set<string>();
const pushId = (id: string | undefined | null) => {
if (!id) return;
const tid = id.trim();
if (!tid || seen.has(tid)) return;
seen.add(tid);
keepTrackIds.push(tid);
};
pushId(currentTrack?.id);
for (const track of queue) {
pushId(track.id);
if (keepTrackIds.length >= 1000) break;
}
const sig = JSON.stringify(keepTrackIds);
if (sig === lastAnalysisPruneSig) return;
lastAnalysisPruneSig = sig;
if (analysisPruneTimer) {
clearTimeout(analysisPruneTimer);
analysisPruneTimer = null;
}
analysisPruneTimer = setTimeout(() => {
analysisPruneTimer = null;
void invoke<AnalysisPrunePendingResult>('analysis_prune_pending_to_track_ids', { trackIds: keepTrackIds })
.then(result => {
if (!result) return;
hotCacheFrontendDebug({
event: 'analysis-prune',
keepCount: result.keepCount,
removedHttp: result.httpRemoved,
removedCpuJobs: result.cpuRemovedJobs,
removedCpuWaiters: result.cpuRemovedWaiters,
});
})
.catch(() => {});
}, ANALYSIS_PRUNE_DEBOUNCE_MS);
}
function debounceMs(): number {
const s = useAuthStore.getState().hotCacheDebounceSec;
if (!Number.isFinite(s) || s < 0) return 0;
return Math.min(600, s) * 1000;
}
function scheduleEvictAfterPreviousGrace(): void {
if (graceEvictTimer) {
@@ -454,9 +353,7 @@ export function initHotCachePrefetch(): () => void {
debounceTimer = null;
if (graceEvictTimer) clearTimeout(graceEvictTimer);
graceEvictTimer = null;
if (analysisPruneTimer) clearTimeout(analysisPruneTimer);
analysisPruneTimer = null;
lastAnalysisPruneSig = '';
resetAnalysisPruneState();
pendingQueue.length = 0;
clearHotCachePreviousGrace();
};
+61
View File
@@ -0,0 +1,61 @@
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { hotCacheFrontendDebug } from './helpers';
let analysisPruneTimer: ReturnType<typeof setTimeout> | null = null;
let lastAnalysisPruneSig = '';
const ANALYSIS_PRUNE_DEBOUNCE_MS = 1200;
type AnalysisPrunePendingResult = {
keepCount: number;
httpRemoved: number;
cpuRemovedJobs: number;
cpuRemovedWaiters: number;
};
export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
const { queue, currentTrack } = usePlayerStore.getState();
const keepTrackIds: string[] = [];
const seen = new Set<string>();
const pushId = (id: string | undefined | null) => {
if (!id) return;
const tid = id.trim();
if (!tid || seen.has(tid)) return;
seen.add(tid);
keepTrackIds.push(tid);
};
pushId(currentTrack?.id);
for (const track of queue) {
pushId(track.id);
if (keepTrackIds.length >= 1000) break;
}
const sig = JSON.stringify(keepTrackIds);
if (sig === lastAnalysisPruneSig) return;
lastAnalysisPruneSig = sig;
if (analysisPruneTimer) {
clearTimeout(analysisPruneTimer);
analysisPruneTimer = null;
}
analysisPruneTimer = setTimeout(() => {
analysisPruneTimer = null;
void invoke<AnalysisPrunePendingResult>('analysis_prune_pending_to_track_ids', { trackIds: keepTrackIds })
.then(result => {
if (!result) return;
hotCacheFrontendDebug({
event: 'analysis-prune',
keepCount: result.keepCount,
removedHttp: result.httpRemoved,
removedCpuJobs: result.cpuRemovedJobs,
removedCpuWaiters: result.cpuRemovedWaiters,
});
})
.catch(() => {});
}, ANALYSIS_PRUNE_DEBOUNCE_MS);
}
/** Tear-down for the analysis-prune state — used by initHotCachePrefetch's cleanup. */
export function resetAnalysisPruneState(): void {
if (analysisPruneTimer) clearTimeout(analysisPruneTimer);
analysisPruneTimer = null;
lastAnalysisPruneSig = '';
}
+68
View File
@@ -0,0 +1,68 @@
import { invoke } from '@tauri-apps/api/core';
import type { Track } from '../store/playerStoreTypes';
import { useAuthStore } from '../store/authStore';
import { HOT_CACHE_PROTECT_AFTER_CURRENT, type HotCacheEntry } from '../store/hotCacheStore';
/** Settings → Logging → Debug (`frontend_debug_log` → Rust stderr), same as normalization / lucky-mix. */
export function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'hot-cache',
message: JSON.stringify(payload),
}).catch(() => {});
}
/** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */
export const PREFETCH_AHEAD = 5;
export type PrefetchJob = { trackId: string; serverId: string; suffix: string };
export function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
/** Sum of on-disk bytes for eviction-protected slots (current + next — same span as `evictToFit`). */
export function sumCachedBytesInProtectedWindow(
queue: Track[],
queueIndex: number,
serverId: string,
entries: Record<string, HotCacheEntry>,
): number {
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
let sum = 0;
for (let i = protectLo; i <= protectHi; i++) {
const e = entries[entryKey(serverId, queue[i].id)];
if (e) sum += e.sizeBytes || 0;
}
return sum;
}
/** Conservative size guess so we do not prefetch when the protected window could exceed the cap. */
export function estimateTrackHotCacheBytes(track: Track): number {
const sz = track.size;
if (typeof sz === 'number' && Number.isFinite(sz) && sz > 0) {
return Math.ceil(sz * 1.06);
}
const dur =
typeof track.duration === 'number' && Number.isFinite(track.duration) && track.duration > 0
? track.duration
: 240;
const sfx = (track.suffix || '').toLowerCase();
const lossless = /^(flac|wav|dsf|dff|alac|ape|wv)$/.test(sfx);
let kbps =
typeof track.bitRate === 'number' && Number.isFinite(track.bitRate) && track.bitRate > 0
? track.bitRate
: 320;
if (lossless && kbps < 800) {
kbps = Math.max(kbps, 900);
}
const raw = Math.ceil((dur * kbps * 1000) / 8);
return Math.max(256 * 1024, Math.ceil(raw * (lossless ? 1.2 : 1.15)));
}
export function debounceMs(): number {
const s = useAuthStore.getState().hotCacheDebounceSec;
if (!Number.isFinite(s) || s < 0) return 0;
return Math.min(600, s) * 1000;
}