mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
0f3033d84e
Ephemeral on-disk cache for upcoming queue tracks. Adds Rust commands (download/delete/purge), hotCacheStore with LRU eviction, serial prefetch worker, playback gate, and Settings UI. Disabled by default. - fix: boundary check before file delete in delete_hot_cache_track - fix: remove stale languageRu2 key from ru.ts - fix: restore accidentally removed lyricsServerFirst/fsLyricsToggle/lyricsSource* strings in ru.ts
185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
import { invoke } from '@tauri-apps/api/core';
|
|
import { buildStreamUrl } from './api/subsonic';
|
|
import { useAuthStore } from './store/authStore';
|
|
import { useHotCacheStore } from './store/hotCacheStore';
|
|
import { useOfflineStore } from './store/offlineStore';
|
|
import { usePlayerStore } from './store/playerStore';
|
|
import { getDeferHotCachePrefetch } from './utils/hotCacheGate';
|
|
|
|
const PREFETCH_AHEAD = 5;
|
|
|
|
type PrefetchJob = { trackId: string; serverId: string; suffix: string };
|
|
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const pendingQueue: PrefetchJob[] = [];
|
|
let workerRunning = false;
|
|
|
|
function debounceMs(): number {
|
|
const s = useAuthStore.getState().hotCacheDebounceSec;
|
|
if (!Number.isFinite(s) || s < 0) return 0;
|
|
return Math.min(600, s) * 1000;
|
|
}
|
|
|
|
function enqueueJobs(jobs: PrefetchJob[]) {
|
|
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
|
|
for (const j of jobs) {
|
|
const k = `${j.serverId}:${j.trackId}`;
|
|
if (seen.has(k)) continue;
|
|
seen.add(k);
|
|
pendingQueue.push(j);
|
|
}
|
|
void runWorker();
|
|
}
|
|
|
|
async function runWorker() {
|
|
if (workerRunning) return;
|
|
workerRunning = true;
|
|
try {
|
|
while (pendingQueue.length > 0) {
|
|
const auth = useAuthStore.getState();
|
|
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
|
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) continue;
|
|
|
|
const offline = useOfflineStore.getState();
|
|
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
|
|
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue;
|
|
|
|
const { queue, queueIndex } = usePlayerStore.getState();
|
|
const wantIds = new Set(
|
|
queue
|
|
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
|
|
.map(t => t.id),
|
|
);
|
|
if (!wantIds.has(job.trackId)) continue;
|
|
|
|
const url = buildStreamUrl(job.trackId);
|
|
try {
|
|
const customDir = auth.hotCacheDownloadDir || null;
|
|
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);
|
|
const fresh = usePlayerStore.getState();
|
|
await useHotCacheStore.getState().evictToFit(
|
|
fresh.queue,
|
|
fresh.queueIndex,
|
|
maxBytes,
|
|
auth.activeServerId,
|
|
customDir,
|
|
);
|
|
} catch {
|
|
/* network / HTTP — skip */
|
|
}
|
|
}
|
|
} finally {
|
|
workerRunning = false;
|
|
if (pendingQueue.length > 0) void runWorker();
|
|
}
|
|
}
|
|
|
|
function entryKey(serverId: string, trackId: string): string {
|
|
return `${serverId}:${trackId}`;
|
|
}
|
|
|
|
function scheduleReplan() {
|
|
const auth = useAuthStore.getState();
|
|
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
|
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();
|
|
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) return;
|
|
|
|
const serverId = auth.activeServerId;
|
|
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
|
|
const customDir = auth.hotCacheDownloadDir || null;
|
|
if (maxBytes <= 0) return;
|
|
|
|
const { queue, queueIndex, currentRadio } = usePlayerStore.getState();
|
|
if (currentRadio) return;
|
|
|
|
const offline = useOfflineStore.getState();
|
|
const hot = useHotCacheStore.getState();
|
|
|
|
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
|
|
|
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
|
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',
|
|
});
|
|
}
|
|
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.queue;
|
|
const i = state.queueIndex;
|
|
if (q === lastQueueRef && i === lastQueueIndex) return;
|
|
const onlyIndexMoved = q === lastQueueRef && i !== lastQueueIndex;
|
|
lastQueueRef = q;
|
|
lastQueueIndex = i;
|
|
if (onlyIndexMoved) void replanNow();
|
|
else scheduleReplan();
|
|
});
|
|
|
|
let lastAuthSig = '';
|
|
const unsubAuth = useAuthStore.subscribe(state => {
|
|
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();
|
|
});
|
|
|
|
void replanNow();
|
|
|
|
return () => {
|
|
unsubPlayer();
|
|
unsubAuth();
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
debounceTimer = null;
|
|
pendingQueue.length = 0;
|
|
};
|
|
}
|