From b4c31241686d351c8b0b09616724835e9c159941 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sat, 11 Apr 2026 03:29:59 +0300 Subject: [PATCH] fix(player): minor hot-cache eviction, prefetch budget, and settings live size Small fix for hot playback cache: eviction keeps current and next only; prefetch up to five tracks when under cap, always fetch the immediate next; grace for the previous current until debounce; run evict immediately on MB or folder changes; re-read cap after download; optional Track.size; live disk usage on Audio settings. --- src/hotCachePrefetch.ts | 161 ++++++++++++++++++++++++++++++++----- src/pages/Settings.tsx | 29 ++++++- src/store/hotCacheStore.ts | 9 ++- src/store/playerStore.ts | 3 + src/utils/hotCacheGate.ts | 28 +++++++ 5 files changed, 205 insertions(+), 25 deletions(-) diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index 06371f62..6e89fba8 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -1,16 +1,67 @@ import { invoke } from '@tauri-apps/api/core'; import { buildStreamUrl } from './api/subsonic'; import { useAuthStore } from './store/authStore'; -import { useHotCacheStore } from './store/hotCacheStore'; +import { HOT_CACHE_PROTECT_AFTER_CURRENT, useHotCacheStore, type HotCacheEntry } from './store/hotCacheStore'; import { useOfflineStore } from './store/offlineStore'; -import { usePlayerStore } from './store/playerStore'; -import { getDeferHotCachePrefetch } from './utils/hotCacheGate'; +import { usePlayerStore, type Track } from './store/playerStore'; +import { + bumpHotCachePreviousTrackGrace, + clearHotCachePreviousGrace, + getDeferHotCachePrefetch, +} from './utils/hotCacheGate'; +/** 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 }; 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; @@ -20,6 +71,22 @@ function debounceMs(): number { return Math.min(600, s) * 1000; } +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}`)); for (const j of jobs) { @@ -56,7 +123,8 @@ async function runWorker() { if (offline.isDownloaded(job.trackId, job.serverId)) continue; if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue; - const { queue, queueIndex } = usePlayerStore.getState(); + const player = usePlayerStore.getState(); + const { queue, queueIndex } = player; const wantIds = new Set( queue .slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD) @@ -64,6 +132,14 @@ async function runWorker() { ); if (!wantIds.has(job.trackId)) continue; + const track = queue.find(t => t.id === job.trackId); + if (!track) continue; + const hotEntries = useHotCacheStore.getState().entries; + const occupied = sumCachedBytesInProtectedWindow(queue, queueIndex, job.serverId, hotEntries); + const est = estimateTrackHotCacheBytes(track); + const isImmediateNext = queue[queueIndex + 1]?.id === job.trackId; + if (!isImmediateNext && occupied + est > maxBytes) continue; + const url = buildStreamUrl(job.trackId); try { const customDir = auth.hotCacheDownloadDir || null; @@ -76,12 +152,14 @@ async function runWorker() { }); useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size); const fresh = usePlayerStore.getState(); + const authAfter = useAuthStore.getState(); + const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024; await useHotCacheStore.getState().evictToFit( fresh.queue, fresh.queueIndex, - maxBytes, - auth.activeServerId, - customDir, + maxAfter, + authAfter.activeServerId ?? '', + authAfter.hotCacheDownloadDir || null, ); } catch { /* network / HTTP — skip */ @@ -93,10 +171,6 @@ async function runWorker() { } } -function entryKey(serverId: string, trackId: string): string { - return `${serverId}:${trackId}`; -} - function scheduleReplan() { const auth = useAuthStore.getState(); if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) { @@ -133,15 +207,21 @@ async function replanNow() { await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir); const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD); + const immediateNextId = queue[queueIndex + 1]?.id; + let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hot.entries); const jobs: PrefetchJob[] = []; for (const t of targets) { if (offline.isDownloaded(t.id, serverId)) continue; if (hot.entries[entryKey(serverId, t.id)]) continue; - jobs.push({ - trackId: t.id, - serverId, - suffix: t.suffix || 'mp3', - }); + 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) break; + projectedOccupied += est; + jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' }); } enqueueJobs(jobs); } @@ -157,19 +237,61 @@ export function initHotCachePrefetch(): () => void { const q = state.queue; 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; + if (onlyIndexMoved && i > prevIdx && prevIdx >= 0 && Array.isArray(prevQ)) { + const left = (prevQ as Track[])[prevIdx]; + const a = useAuthStore.getState(); + if (left && a.activeServerId) { + bumpHotCachePreviousTrackGrace(left.id, a.activeServerId, a.hotCacheDebounceSec); + scheduleEvictAfterPreviousGrace(); + } + } if (onlyIndexMoved) void replanNow(); else scheduleReplan(); }); let lastAuthSig = ''; - const unsubAuth = useAuthStore.subscribe(state => { + 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 (state.hotCacheEnabled && state.isLoggedIn) scheduleReplan(); + + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + + if (!state.hotCacheEnabled || !state.isLoggedIn) { + 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) { + pendingQueue.length = 0; + } + void replanNow(); + } else if (onlyDebounceChanged) { + scheduleReplan(); + } }); void replanNow(); @@ -179,6 +301,9 @@ export function initHotCachePrefetch(): () => void { unsubAuth(); if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = null; + if (graceEvictTimer) clearTimeout(graceEvictTimer); + graceEvictTimer = null; pendingQueue.length = 0; + clearHotCachePreviousGrace(); }; } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 22db22cb..91e68f1a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -254,16 +254,37 @@ export default function Settings() { }, [auth.lastfmSessionKey, auth.lastfmUsername]); useEffect(() => { - if (activeTab === 'audio') { - invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); - return; - } if (activeTab !== 'storage') return; getImageCacheSize().then(setImageCacheBytes); invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0)); invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); + /** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */ + useEffect(() => { + if (activeTab !== 'audio') return; + const customDir = auth.hotCacheDownloadDir || null; + const refresh = () => { + invoke('get_hot_cache_size', { customDir }) + .then(setHotCacheBytes) + .catch(() => setHotCacheBytes(0)); + }; + refresh(); + if (!auth.hotCacheEnabled) return; + const interval = window.setInterval(refresh, 2000); + return () => window.clearInterval(interval); + }, [activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); + + useEffect(() => { + if (activeTab !== 'audio' || !auth.hotCacheEnabled) return; + const t = window.setTimeout(() => { + invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }) + .then(setHotCacheBytes) + .catch(() => setHotCacheBytes(0)); + }, 400); + return () => window.clearTimeout(t); + }, [hotCacheEntries, activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); + const handleClearCache = useCallback(async () => { setClearing(true); await clearImageCache(); diff --git a/src/store/hotCacheStore.ts b/src/store/hotCacheStore.ts index f3998d31..1ac26e8b 100644 --- a/src/store/hotCacheStore.ts +++ b/src/store/hotCacheStore.ts @@ -1,9 +1,11 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; +import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate'; import type { Track } from './playerStore'; -const PREFETCH_AHEAD = 5; +/** How many queue slots after the current index are eviction-protected (1 = current + next only). */ +export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1; export interface HotCacheEntry { localPath: string; @@ -22,7 +24,7 @@ interface HotCacheState { touchPlayed: (trackId: string, serverId: string) => void; removeEntry: (trackId: string, serverId: string) => void; totalBytes: () => number; - /** Evict until total size ≤ maxBytes. Respects queue tail first, protects current + next N. */ + /** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). */ evictToFit: ( queue: Track[], queueIndex: number, @@ -103,7 +105,7 @@ export const useHotCacheStore = create()( if (maxBytes <= 0) return; const protectLo = Math.max(0, queueIndex); - const protectHi = Math.min(queue.length - 1, queueIndex + PREFETCH_AHEAD); + const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT); const protectedIds = new Set(); for (let i = protectLo; i <= protectHi; i++) { protectedIds.add(queue[i].id); @@ -127,6 +129,7 @@ export const useHotCacheStore = create()( if (!parsed) continue; const { serverId, trackId } = parsed; if (protectedIds.has(trackId) && serverId === activeServerId) continue; + if (isHotCachePreviousTrackUnderGrace(trackId, serverId)) continue; const meta = entries[key]; const lru = lruStamp(meta); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index c6669441..46e723f9 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -32,6 +32,8 @@ export interface Track { genre?: string; samplingRate?: number; bitDepth?: number; + /** Subsonic `size` in bytes when provided by the server (helps hot-cache budgeting). */ + size?: number; autoAdded?: boolean; radioAdded?: boolean; } @@ -58,6 +60,7 @@ export function songToTrack(song: SubsonicSong): Track { genre: song.genre, samplingRate: song.samplingRate, bitDepth: song.bitDepth, + size: song.size, }; } diff --git a/src/utils/hotCacheGate.ts b/src/utils/hotCacheGate.ts index 303bad29..8807007a 100644 --- a/src/utils/hotCacheGate.ts +++ b/src/utils/hotCacheGate.ts @@ -1,6 +1,11 @@ /** When true, hot-cache prefetch must not start new downloads (playback has priority). */ let deferHotCachePrefetch = false; +/** Last track that was «current» before a forward queue step — not evicted until debounce elapses. */ +let previousTrackGraceUntilMs = 0; +let previousTrackGraceTrackId: string | null = null; +let previousTrackGraceServerId: string | null = null; + export function setDeferHotCachePrefetch(v: boolean): void { deferHotCachePrefetch = v; } @@ -8,3 +13,26 @@ export function setDeferHotCachePrefetch(v: boolean): void { export function getDeferHotCachePrefetch(): boolean { return deferHotCachePrefetch; } + +/** Call when `queueIndex` advances; the old current track stays eviction-safe for `debounceSec` (capped at 600 s). */ +export function bumpHotCachePreviousTrackGrace( + trackId: string, + serverId: string, + debounceSec: number, +): void { + const sec = Number.isFinite(debounceSec) ? Math.min(600, Math.max(0, debounceSec)) : 0; + previousTrackGraceUntilMs = Date.now() + sec * 1000; + previousTrackGraceTrackId = trackId; + previousTrackGraceServerId = serverId; +} + +export function isHotCachePreviousTrackUnderGrace(trackId: string, serverId: string): boolean { + if (!previousTrackGraceTrackId || Date.now() >= previousTrackGraceUntilMs) return false; + return previousTrackGraceTrackId === trackId && previousTrackGraceServerId === serverId; +} + +export function clearHotCachePreviousGrace(): void { + previousTrackGraceUntilMs = 0; + previousTrackGraceTrackId = null; + previousTrackGraceServerId = null; +}