Files
Psychotoxical-psysonic/src/store/hotCacheStore.ts
T
cucadmuh 218aa00718 fix(analysis): CPU seed queue, single waveform emit, and log URL redaction
Serialize heavy PCM seeding through a dedicated queue with optional priority
for the current track. Emit waveform-updated once per completed seed, fix
Lucky Mix waveform refresh tokens, redact Subsonic URLs in logs, and align
hot-cache prefetch with the queued path.
2026-04-26 20:59:33 +03:00

277 lines
8.8 KiB
TypeScript

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';
import { emitAnalysisStorageChanged } from './analysisSync';
/** 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;
sizeBytes: number;
cachedAt: number;
/** Last time this file was started as the current track (eviction tie-break: newer = keep longer). */
lastPlayedAt?: number;
}
interface HotCacheState {
/** Persisted map `${serverId}:${trackId}` → file meta */
entries: Record<string, HotCacheEntry>;
getLocalUrl: (trackId: string, serverId: string) => string | null;
setEntry: (
trackId: string,
serverId: string,
localPath: string,
sizeBytes: number,
debugSource?: string,
) => void;
/** Bump LRU when the user actually plays this track (if it is in the hot cache). */
touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => void;
totalBytes: () => number;
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track). */
evictToFit: (
queue: Track[],
queueIndex: number,
maxBytes: number,
activeServerId: string,
hotCacheCustomDir: string | null,
) => Promise<void>;
clearAllDisk: (customDir: string | null) => Promise<void>;
}
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
}
function parseKey(key: string): { serverId: string; trackId: string } | null {
const i = key.indexOf(':');
if (i <= 0) return null;
return { serverId: key.slice(0, i), trackId: key.slice(i + 1) };
}
function lruStamp(meta: HotCacheEntry | undefined): number {
if (!meta) return 0;
return meta.lastPlayedAt ?? meta.cachedAt ?? 0;
}
function evictionReasonForTier(tier: number): string {
const labels: Record<number, string> = {
0: 'inactive-server',
1: 'not-in-queue',
2: 'ahead-of-protected-window',
3: 'behind-current-in-queue',
};
return labels[tier] ?? `tier-${tier}`;
}
/** Settings → Logging → Debug, same as `emitNormalizationDebug` / lucky-mix. Dynamic `authStore` import avoids a static cycle (auth → player → hot-cache). */
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
void import('./authStore')
.then(({ useAuthStore }) => {
if (useAuthStore.getState().loggingMode !== 'debug') return;
return invoke('frontend_debug_log', {
scope: 'hot-cache',
message: JSON.stringify(payload),
});
})
.catch(() => {});
}
export const useHotCacheStore = create<HotCacheState>()(
persist(
(set, get) => ({
entries: {},
getLocalUrl: (trackId, serverId) => {
const e = get().entries[entryKey(serverId, trackId)];
if (!e?.localPath) return null;
return `psysonic-local://${e.localPath}`;
},
setEntry: (trackId, serverId, localPath, sizeBytes, debugSource) => {
const now = Date.now();
set(s => ({
entries: {
...s.entries,
[entryKey(serverId, trackId)]: {
localPath,
sizeBytes,
cachedAt: now,
lastPlayedAt: now,
},
},
}));
hotCacheFrontendDebug({
event: 'index-add',
trackId,
serverId,
sizeBytes,
source: debugSource ?? 'unknown',
});
},
touchPlayed: (trackId, serverId) => {
const k = entryKey(serverId, trackId);
set(s => {
const e = s.entries[k];
if (!e) return s;
return {
entries: {
...s.entries,
[k]: { ...e, lastPlayedAt: Date.now() },
},
};
});
},
removeEntry: (trackId, serverId) => {
set(s => {
const next = { ...s.entries };
delete next[entryKey(serverId, trackId)];
return { entries: next };
});
hotCacheFrontendDebug({
event: 'index-remove',
trackId,
serverId,
reason: 'explicit-removeEntry',
});
emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' });
},
totalBytes: () =>
Object.values(get().entries).reduce((acc, e) => acc + (e.sizeBytes || 0), 0),
evictToFit: async (queue, queueIndex, maxBytes, activeServerId, hotCacheCustomDir) => {
if (maxBytes <= 0) return;
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].id);
}
const indexOfInQueue = (trackId: string): number | null => {
const idx = queue.findIndex(t => t.id === trackId);
return idx >= 0 ? idx : null;
};
let entries = { ...get().entries };
let sum = Object.values(entries).reduce((a, e) => a + (e.sizeBytes || 0), 0);
if (sum <= maxBytes) return;
const keys = Object.keys(entries);
type Cand = { key: string; tier: number; primary: number; lru: number };
const cands: Cand[] = [];
for (const key of keys) {
const parsed = parseKey(key);
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);
if (serverId !== activeServerId) {
cands.push({ key, tier: 0, primary: 0, lru });
continue;
}
const qIdx = indexOfInQueue(trackId);
if (qIdx === null) {
cands.push({ key, tier: 1, primary: 0, lru });
} else if (qIdx > protectHi) {
cands.push({ key, tier: 2, primary: -qIdx, lru });
} else if (qIdx < protectLo) {
cands.push({ key, tier: 3, primary: qIdx, lru });
}
}
cands.sort((a, b) => {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.primary !== b.primary) return a.primary - b.primary;
return a.lru - b.lru;
});
if (cands.length === 0) {
hotCacheFrontendDebug({
event: 'evict-no-candidates',
sumBytes: sum,
maxBytes,
queueIndex,
entryKeys: keys.length,
reason: 'all-protected-or-grace-or-parse-fail',
});
return;
}
hotCacheFrontendDebug({
event: 'evict-start',
sumBytes: sum,
maxBytes,
queueIndex,
protectLo,
protectHi,
candidateCount: cands.length,
});
for (const cand of cands) {
if (sum <= maxBytes) break;
const { key, tier } = cand;
const meta = entries[key];
if (!meta) continue;
const parsed = parseKey(key);
if (!parsed) continue;
await invoke('delete_hot_cache_track', {
localPath: meta.localPath,
customDir: hotCacheCustomDir || null,
}).catch((e: unknown) => {
hotCacheFrontendDebug({
event: 'evict-disk-delete-failed',
trackId: parsed.trackId,
serverId: parsed.serverId,
error: String(e),
});
});
hotCacheFrontendDebug({
event: 'evict-remove',
trackId: parsed.trackId,
serverId: parsed.serverId,
reason: `budget:${evictionReasonForTier(tier)}`,
tier,
bytes: meta.sizeBytes,
sumBytesAfter: sum - (meta.sizeBytes || 0),
maxBytes,
});
sum -= meta.sizeBytes || 0;
delete entries[key];
emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' });
}
set({ entries });
},
clearAllDisk: async (customDir: string | null) => {
hotCacheFrontendDebug({
event: 'purge-all',
customDir: customDir && customDir.length > 0 ? '(custom)' : 'default',
});
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
set({ entries: {} });
emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' });
},
}),
{
name: 'psysonic-hot-cache',
storage: createJSONStorage(() => localStorage),
partialize: s => ({ entries: s.entries }),
},
),
);