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.
This commit is contained in:
Maxim Isaev
2026-04-11 03:29:59 +03:00
parent 20dabbfd03
commit b4c3124168
5 changed files with 205 additions and 25 deletions
+143 -18
View File
@@ -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<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 };
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;
@@ -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();
};
}