mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
+143
-18
@@ -1,16 +1,67 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { buildStreamUrl } from './api/subsonic';
|
import { buildStreamUrl } from './api/subsonic';
|
||||||
import { useAuthStore } from './store/authStore';
|
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 { useOfflineStore } from './store/offlineStore';
|
||||||
import { usePlayerStore } from './store/playerStore';
|
import { usePlayerStore, type Track } from './store/playerStore';
|
||||||
import { getDeferHotCachePrefetch } from './utils/hotCacheGate';
|
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;
|
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 };
|
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
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[] = [];
|
const pendingQueue: PrefetchJob[] = [];
|
||||||
let workerRunning = false;
|
let workerRunning = false;
|
||||||
|
|
||||||
@@ -20,6 +71,22 @@ function debounceMs(): number {
|
|||||||
return Math.min(600, s) * 1000;
|
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[]) {
|
function enqueueJobs(jobs: PrefetchJob[]) {
|
||||||
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
|
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
|
||||||
for (const j of jobs) {
|
for (const j of jobs) {
|
||||||
@@ -56,7 +123,8 @@ async function runWorker() {
|
|||||||
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
|
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
|
||||||
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) 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(
|
const wantIds = new Set(
|
||||||
queue
|
queue
|
||||||
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
|
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
|
||||||
@@ -64,6 +132,14 @@ async function runWorker() {
|
|||||||
);
|
);
|
||||||
if (!wantIds.has(job.trackId)) continue;
|
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);
|
const url = buildStreamUrl(job.trackId);
|
||||||
try {
|
try {
|
||||||
const customDir = auth.hotCacheDownloadDir || null;
|
const customDir = auth.hotCacheDownloadDir || null;
|
||||||
@@ -76,12 +152,14 @@ async function runWorker() {
|
|||||||
});
|
});
|
||||||
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
|
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
|
||||||
const fresh = usePlayerStore.getState();
|
const fresh = usePlayerStore.getState();
|
||||||
|
const authAfter = useAuthStore.getState();
|
||||||
|
const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024;
|
||||||
await useHotCacheStore.getState().evictToFit(
|
await useHotCacheStore.getState().evictToFit(
|
||||||
fresh.queue,
|
fresh.queue,
|
||||||
fresh.queueIndex,
|
fresh.queueIndex,
|
||||||
maxBytes,
|
maxAfter,
|
||||||
auth.activeServerId,
|
authAfter.activeServerId ?? '',
|
||||||
customDir,
|
authAfter.hotCacheDownloadDir || null,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* network / HTTP — skip */
|
/* network / HTTP — skip */
|
||||||
@@ -93,10 +171,6 @@ async function runWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryKey(serverId: string, trackId: string): string {
|
|
||||||
return `${serverId}:${trackId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReplan() {
|
function scheduleReplan() {
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
||||||
@@ -133,15 +207,21 @@ async function replanNow() {
|
|||||||
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
||||||
|
|
||||||
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
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[] = [];
|
const jobs: PrefetchJob[] = [];
|
||||||
for (const t of targets) {
|
for (const t of targets) {
|
||||||
if (offline.isDownloaded(t.id, serverId)) continue;
|
if (offline.isDownloaded(t.id, serverId)) continue;
|
||||||
if (hot.entries[entryKey(serverId, t.id)]) continue;
|
if (hot.entries[entryKey(serverId, t.id)]) continue;
|
||||||
jobs.push({
|
const isImmediateNext = t.id === immediateNextId;
|
||||||
trackId: t.id,
|
if (isImmediateNext) {
|
||||||
serverId,
|
jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' });
|
||||||
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);
|
enqueueJobs(jobs);
|
||||||
}
|
}
|
||||||
@@ -157,19 +237,61 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
const q = state.queue;
|
const q = state.queue;
|
||||||
const i = state.queueIndex;
|
const i = state.queueIndex;
|
||||||
if (q === lastQueueRef && i === lastQueueIndex) return;
|
if (q === lastQueueRef && i === lastQueueIndex) return;
|
||||||
|
const prevIdx = lastQueueIndex;
|
||||||
|
const prevQ = lastQueueRef;
|
||||||
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
|
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
|
||||||
lastQueueRef = q;
|
lastQueueRef = q;
|
||||||
lastQueueIndex = i;
|
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();
|
if (onlyIndexMoved) void replanNow();
|
||||||
else scheduleReplan();
|
else scheduleReplan();
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastAuthSig = '';
|
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}`;
|
const sig = `${state.hotCacheEnabled}:${state.hotCacheDebounceSec}:${state.hotCacheMaxMb}:${state.hotCacheDownloadDir ?? ''}:${state.activeServerId ?? ''}:${state.isLoggedIn}`;
|
||||||
if (sig === lastAuthSig) return;
|
if (sig === lastAuthSig) return;
|
||||||
lastAuthSig = sig;
|
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();
|
void replanNow();
|
||||||
@@ -179,6 +301,9 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
debounceTimer = null;
|
debounceTimer = null;
|
||||||
|
if (graceEvictTimer) clearTimeout(graceEvictTimer);
|
||||||
|
graceEvictTimer = null;
|
||||||
pendingQueue.length = 0;
|
pendingQueue.length = 0;
|
||||||
|
clearHotCachePreviousGrace();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-4
@@ -254,16 +254,37 @@ export default function Settings() {
|
|||||||
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'audio') {
|
|
||||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeTab !== 'storage') return;
|
if (activeTab !== 'storage') return;
|
||||||
getImageCacheSize().then(setImageCacheBytes);
|
getImageCacheSize().then(setImageCacheBytes);
|
||||||
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
|
||||||
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
|
||||||
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
|
}, [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<number>('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<number>('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 () => {
|
const handleClearCache = useCallback(async () => {
|
||||||
setClearing(true);
|
setClearing(true);
|
||||||
await clearImageCache();
|
await clearImageCache();
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
|
||||||
import type { Track } from './playerStore';
|
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 {
|
export interface HotCacheEntry {
|
||||||
localPath: string;
|
localPath: string;
|
||||||
@@ -22,7 +24,7 @@ interface HotCacheState {
|
|||||||
touchPlayed: (trackId: string, serverId: string) => void;
|
touchPlayed: (trackId: string, serverId: string) => void;
|
||||||
removeEntry: (trackId: string, serverId: string) => void;
|
removeEntry: (trackId: string, serverId: string) => void;
|
||||||
totalBytes: () => number;
|
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: (
|
evictToFit: (
|
||||||
queue: Track[],
|
queue: Track[],
|
||||||
queueIndex: number,
|
queueIndex: number,
|
||||||
@@ -103,7 +105,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
|||||||
if (maxBytes <= 0) return;
|
if (maxBytes <= 0) return;
|
||||||
|
|
||||||
const protectLo = Math.max(0, queueIndex);
|
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<string>();
|
const protectedIds = new Set<string>();
|
||||||
for (let i = protectLo; i <= protectHi; i++) {
|
for (let i = protectLo; i <= protectHi; i++) {
|
||||||
protectedIds.add(queue[i].id);
|
protectedIds.add(queue[i].id);
|
||||||
@@ -127,6 +129,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
|||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
const { serverId, trackId } = parsed;
|
const { serverId, trackId } = parsed;
|
||||||
if (protectedIds.has(trackId) && serverId === activeServerId) continue;
|
if (protectedIds.has(trackId) && serverId === activeServerId) continue;
|
||||||
|
if (isHotCachePreviousTrackUnderGrace(trackId, serverId)) continue;
|
||||||
|
|
||||||
const meta = entries[key];
|
const meta = entries[key];
|
||||||
const lru = lruStamp(meta);
|
const lru = lruStamp(meta);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface Track {
|
|||||||
genre?: string;
|
genre?: string;
|
||||||
samplingRate?: number;
|
samplingRate?: number;
|
||||||
bitDepth?: number;
|
bitDepth?: number;
|
||||||
|
/** Subsonic `size` in bytes when provided by the server (helps hot-cache budgeting). */
|
||||||
|
size?: number;
|
||||||
autoAdded?: boolean;
|
autoAdded?: boolean;
|
||||||
radioAdded?: boolean;
|
radioAdded?: boolean;
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,7 @@ export function songToTrack(song: SubsonicSong): Track {
|
|||||||
genre: song.genre,
|
genre: song.genre,
|
||||||
samplingRate: song.samplingRate,
|
samplingRate: song.samplingRate,
|
||||||
bitDepth: song.bitDepth,
|
bitDepth: song.bitDepth,
|
||||||
|
size: song.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/** When true, hot-cache prefetch must not start new downloads (playback has priority). */
|
/** When true, hot-cache prefetch must not start new downloads (playback has priority). */
|
||||||
let deferHotCachePrefetch = false;
|
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 {
|
export function setDeferHotCachePrefetch(v: boolean): void {
|
||||||
deferHotCachePrefetch = v;
|
deferHotCachePrefetch = v;
|
||||||
}
|
}
|
||||||
@@ -8,3 +13,26 @@ export function setDeferHotCachePrefetch(v: boolean): void {
|
|||||||
export function getDeferHotCachePrefetch(): boolean {
|
export function getDeferHotCachePrefetch(): boolean {
|
||||||
return deferHotCachePrefetch;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user