diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index 24952e61..cb80a416 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -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): 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, -): 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 | null = null; /** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */ let graceEvictTimer: ReturnType | null = null; const pendingQueue: PrefetchJob[] = []; let workerRunning = false; -let analysisPruneTimer: ReturnType | 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(); - 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('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(); }; diff --git a/src/hotCachePrefetch/analysisPrune.ts b/src/hotCachePrefetch/analysisPrune.ts new file mode 100644 index 00000000..476c88de --- /dev/null +++ b/src/hotCachePrefetch/analysisPrune.ts @@ -0,0 +1,61 @@ +import { invoke } from '@tauri-apps/api/core'; +import { usePlayerStore } from '../store/playerStore'; +import { hotCacheFrontendDebug } from './helpers'; + +let analysisPruneTimer: ReturnType | 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(); + 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('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 = ''; +} diff --git a/src/hotCachePrefetch/helpers.ts b/src/hotCachePrefetch/helpers.ts new file mode 100644 index 00000000..36ace8c7 --- /dev/null +++ b/src/hotCachePrefetch/helpers.ts @@ -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): 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, +): 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; +}