import { buildStreamUrlForServer } from './api/subsonicStreamUrl'; import { getPlaybackCacheServerKey } from './utils/playback/playbackServer'; import type { QueueItemRef } from './store/playerStoreTypes'; import { resolveQueueTrack } from './utils/library/queueTrackView'; import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from './store/authStore'; import { useHotCacheStore } from './store/hotCacheStore'; import { useOfflineStore } from './store/offlineStore'; import { usePlayerStore } from './store/playerStore'; import { bumpHotCachePreviousTrackGrace, clearHotCachePreviousGrace, getDeferHotCachePrefetch, } from './utils/cache/hotCacheGate'; 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; function scheduleEvictAfterPreviousGrace(): void { if (graceEvictTimer) { clearTimeout(graceEvictTimer); graceEvictTimer = null; } const ms = debounceMs(); if (ms <= 0) { void replanNow(); return; } graceEvictTimer = setTimeout(() => { graceEvictTimer = null; void replanNow(); }, ms); } function enqueueJobs(jobs: PrefetchJob[]) { const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`)); let merged = 0; for (const j of jobs) { const k = `${j.serverId}:${j.trackId}`; if (seen.has(k)) continue; seen.add(k); pendingQueue.push(j); merged++; } if (merged > 0) { hotCacheFrontendDebug({ event: 'prefetch-queue-jobs', added: merged, pendingTotal: pendingQueue.length, trackIds: jobs.map(j => j.trackId), }); } void runWorker(); } async function runWorker() { if (workerRunning) return; workerRunning = true; try { while (pendingQueue.length > 0) { const auth = useAuthStore.getState(); const playbackSid = getPlaybackCacheServerKey(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) { hotCacheFrontendDebug({ event: 'prefetch-worker-stop', reason: 'auth-disabled-or-logged-out', clearedPending: pendingQueue.length, }); pendingQueue.length = 0; break; } while (getDeferHotCachePrefetch()) { await new Promise(r => setTimeout(r, 150)); } const job = pendingQueue.shift(); if (!job) break; const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024; if (maxBytes <= 0) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'max-mb-zero' }); continue; } const offline = useOfflineStore.getState(); if (offline.isDownloaded(job.trackId, job.serverId)) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'offline-library' }); continue; } if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'already-in-hot-index', }); continue; } const player = usePlayerStore.getState(); const { queueItems, queueIndex } = player; const upcomingRefs = queueItems.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD); const wantIds = new Set(upcomingRefs.map(r => r.trackId)); if (!wantIds.has(job.trackId)) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'not-in-upcoming-window', queueIndex, window: PREFETCH_AHEAD, }); continue; } // Thin-state: the upcoming window sits inside the resolver-warm range, so // resolveQueueTrack returns the full Track (placeholder only on a cold // miss, where the size estimate falls back to the bitrate heuristic). const jobRef = upcomingRefs.find(r => r.trackId === job.trackId); if (!jobRef) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'track-not-in-queue', }); continue; } const track = resolveQueueTrack(jobRef); const hotEntries = useHotCacheStore.getState().entries; const occupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, job.serverId, hotEntries); const est = estimateTrackHotCacheBytes(track); const isImmediateNext = queueItems[queueIndex + 1]?.trackId === job.trackId; if (!isImmediateNext && occupied + est > maxBytes) { hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'budget-protected-window-plus-estimate', occupied, estimateBytes: est, maxBytes, }); continue; } const url = buildStreamUrlForServer(job.serverId, job.trackId); try { const customDir = auth.hotCacheDownloadDir || null; hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId }); const res = await invoke<{ path: string; size: number }>('download_track_hot_cache', { trackId: job.trackId, serverId: job.serverId, url, suffix: job.suffix, customDir, }); useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size, 'prefetch'); hotCacheFrontendDebug({ event: 'prefetch-stored', trackId: job.trackId, sizeBytes: res.size }); const fresh = usePlayerStore.getState(); const authAfter = useAuthStore.getState(); const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024; await useHotCacheStore.getState().evictToFit( fresh.queueItems, fresh.queueIndex, maxAfter, getPlaybackCacheServerKey(), authAfter.hotCacheDownloadDir || null, ); } catch (e: unknown) { hotCacheFrontendDebug({ event: 'prefetch-download-failed', trackId: job.trackId, error: String(e) }); } } } finally { workerRunning = false; if (pendingQueue.length > 0) void runWorker(); } } function scheduleReplan() { const auth = useAuthStore.getState(); const playbackSid = getPlaybackCacheServerKey(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) { if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } return; } if (debounceTimer) clearTimeout(debounceTimer); const ms = debounceMs(); debounceTimer = setTimeout(() => { debounceTimer = null; void replanNow(); }, ms); } async function replanNow() { const auth = useAuthStore.getState(); const playbackSid = getPlaybackCacheServerKey(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !playbackSid) return; const serverId = playbackSid; const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024; const customDir = auth.hotCacheDownloadDir || null; if (maxBytes <= 0) return; const { queueItems, queueIndex, currentRadio } = usePlayerStore.getState(); if (currentRadio) { hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' }); return; } const offline = useOfflineStore.getState(); await useHotCacheStore.getState().evictToFit(queueItems, queueIndex, maxBytes, serverId, customDir); // Must read entries after eviction: the pre-evict snapshot still lists removed keys and would // skip prefetch for upcoming tracks that no longer have on-disk rows. const hotEntries = useHotCacheStore.getState().entries; // Thin-state: resolve only the small upcoming window (within the resolver-warm // range) to full Tracks for the size estimates / suffix. const targetRefs = queueItems.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD); const targets = targetRefs.map(r => resolveQueueTrack(r)); const immediateNextId = queueItems[queueIndex + 1]?.trackId; let projectedOccupied = sumCachedBytesInProtectedWindow(queueItems, queueIndex, serverId, hotEntries); const jobs: PrefetchJob[] = []; const skipped: { trackId: string; reason: string }[] = []; for (const t of targets) { if (offline.isDownloaded(t.id, serverId)) { skipped.push({ trackId: t.id, reason: 'offline-library' }); continue; } if (hotEntries[entryKey(serverId, t.id)]) { skipped.push({ trackId: t.id, reason: 'already-in-hot-index' }); continue; } const isImmediateNext = t.id === immediateNextId; if (isImmediateNext) { jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' }); continue; } const est = estimateTrackHotCacheBytes(t); if (projectedOccupied + est > maxBytes) { skipped.push({ trackId: t.id, reason: 'budget-cap-rest-deferred' }); break; } projectedOccupied += est; jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' }); } hotCacheFrontendDebug({ event: 'replan', queueIndex, aheadCount: targets.length, scheduledIds: jobs.map(j => j.trackId), skipped, projectedOccupiedBytes: projectedOccupied, maxBytes, }); enqueueJobs(jobs); } /** * Subscribe to queue/auth changes and run debounced prefetch. * Call once from the app shell. */ export function initHotCachePrefetch(): () => void { let lastQueueRef: unknown = null; let lastQueueIndex = -1; const unsubPlayer = usePlayerStore.subscribe(state => { const q = state.queueItems; const i = state.queueIndex; if (q === lastQueueRef && i === lastQueueIndex) return; const prevIdx = lastQueueIndex; const prevQ = lastQueueRef; const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex; lastQueueRef = q; lastQueueIndex = i; scheduleAnalysisQueuePruneFromPlaybackQueue(); if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) { const left = (prevQ as QueueItemRef[])[prevIdx]; const a = useAuthStore.getState(); const graceSid = getPlaybackCacheServerKey(); if (left && graceSid) { bumpHotCachePreviousTrackGrace(left.trackId, graceSid, a.hotCacheDebounceSec); scheduleEvictAfterPreviousGrace(); } } if (onlyIndexMoved) void replanNow(); else scheduleReplan(); }); let lastAuthSig = ''; const unsubAuth = useAuthStore.subscribe((state, prev) => { const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`; if (sig === lastAuthSig) return; lastAuthSig = sig; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } if (!state.hotCacheEnabled || !state.isLoggedIn) { hotCacheFrontendDebug({ event: 'prefetch-auth-off', clearedPending: pendingQueue.length }); pendingQueue.length = 0; clearHotCachePreviousGrace(); return; } const budgetSettingsChanged = !prev || state.hotCacheMaxMb !== prev.hotCacheMaxMb || state.hotCacheDownloadDir !== prev.hotCacheDownloadDir || state.hotCacheEnabled !== prev.hotCacheEnabled || state.activeServerId !== prev.activeServerId || state.isLoggedIn !== prev.isLoggedIn; const onlyDebounceChanged = !!prev && state.hotCacheDebounceSec !== prev.hotCacheDebounceSec && !budgetSettingsChanged; if (budgetSettingsChanged) { if (prev && state.hotCacheMaxMb < prev.hotCacheMaxMb) { hotCacheFrontendDebug({ event: 'prefetch-pending-cleared', reason: 'hot-cache-max-mb-decreased', prevMb: prev.hotCacheMaxMb, nextMb: state.hotCacheMaxMb, droppedJobs: pendingQueue.length, }); pendingQueue.length = 0; } void replanNow(); } else if (onlyDebounceChanged) { scheduleReplan(); } }); void replanNow(); scheduleAnalysisQueuePruneFromPlaybackQueue(); return () => { unsubPlayer(); unsubAuth(); if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = null; if (graceEvictTimer) clearTimeout(graceEvictTimer); graceEvictTimer = null; resetAnalysisPruneState(); pendingQueue.length = 0; clearHotCachePreviousGrace(); }; }