mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
8d8c1aa8a3
* chore: upgrade dependencies and migrate playback to rodio 0.22 Bump npm and Rust crates; adapt symphonia decoding, ringbuf 0.5, lofty tags, and discord-rich-presence usage. Use native rodio Player/MixerDeviceSink and cpal device descriptions; drop the unused cpal patch. Align Vite 8 build targets and chunking; remove redundant dynamic imports and fix hot-cache debug logging imports. * perf(build): lazy-load routes and restore default chunk warnings Lazy-load all routed pages with React.lazy to shrink the main bundle; wrap root Routes in Suspense for lazy Login. Drop chunkSizeWarningLimit override so Vite uses the default 500 kB threshold. * fix(windows): tray double-click without spurious menu; clean unused import Disable tray menu on left mouse-up on Windows so a double-click to hide the main window does not immediately reopen the context menu (tray-icon default menu_on_left_click). Gate std::fs in app_api/core behind cfg(linux) for /proc-only code so Windows builds stay warning-free. * fix(sidebar): preserve new-releases read state under storage cap When merging seen album ids, keep the current newest sample first so the 500-id localStorage limit does not truncate freshly marked reads and bring back the unread badge. * fix(audio): hot-cache replay, analysis no-op skips, playback source UI Retain stream_completed_cache across audio_stop so end-of-queue replay can use RAM promote or disk hot file instead of re-ranging HTTP. Add cpu_seed_redundant_for_track gate before file/bytes seeds and local-file spawn; emit analysis:waveform-updated only on Upserted. Ranged/legacy promote checks generation after await before filling the slot. Frontend: promote on same-track and cold resume; set currentPlaybackSource on resume, queue undo restore, and gapless track switch so cache/stream icons stay accurate. Import tauri::Manager for try_state in audio_play. * fix(ts): narrow activeServerId for hot-cache promote calls promoteCompletedStreamToHotCache expects a string; bind non-null server ids in repeat-one, playTrack prev/same-track, and cold resume paths so tauri production build (tsc) succeeds. * fix(player): handle same-track hot-cache promote promise chain Add .catch for promoteCompletedStreamToHotCache → runPlayTrackBody so sync throws and unexpected rejections do not surface as unhandled in DevTools; reset defer-hot-cache prefetch and isPlaying on failure. * chore(nix): sync npmDepsHash with package-lock.json * chore(release): finalize 1.46.0 CHANGELOG with PR #463 links Document the release with full GitHub PR #463 on every subsection so entries stay attributable if sections are reordered. Fix ContextMenu lines where dynamic imports were accidentally merged onto one line. * docs(contributors): credit cucadmuh for #463
274 lines
8.7 KiB
TypeScript
274 lines
8.7 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';
|
|
import { useAuthStore } from './authStore';
|
|
|
|
/** 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. */
|
|
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
|
|
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
|
void 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 }),
|
|
},
|
|
),
|
|
);
|