feat: add hot playback cache (queue prefetch, alpha)

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
This commit is contained in:
cucadmuh
2026-04-07 11:52:26 +03:00
committed by GitHub
parent d49af977ed
commit 0f3033d84e
16 changed files with 940 additions and 57 deletions
+20
View File
@@ -47,6 +47,13 @@ interface AuthState {
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
hotCacheMaxMb: number;
hotCacheDebounceSec: number;
/** Parent directory; actual cache is `<dir>/psysonic-hot-cache/`. Empty = app data. */
hotCacheDownloadDir: string;
// Status
isLoggedIn: boolean;
isConnecting: boolean;
@@ -88,6 +95,10 @@ interface AuthState {
setShowFullscreenLyrics: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void;
logout: () => void;
// Derived
@@ -131,6 +142,10 @@ export const useAuthStore = create<AuthState>()(
showFullscreenLyrics: true,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
hotCacheDownloadDir: '',
isLoggedIn: false,
isConnecting: false,
connectionError: null,
@@ -207,6 +222,11 @@ export const useAuthStore = create<AuthState>()(
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
logout: () => set({ isLoggedIn: false }),
getBaseUrl: () => {
+183
View File
@@ -0,0 +1,183 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import type { Track } from './playerStore';
const PREFETCH_AHEAD = 5;
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) => 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. Respects queue tail first, protects current + next N. */
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;
}
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) => {
const now = Date.now();
set(s => ({
entries: {
...s.entries,
[entryKey(serverId, trackId)]: {
localPath,
sizeBytes,
cachedAt: now,
lastPlayedAt: now,
},
},
}));
},
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 };
});
},
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 + PREFETCH_AHEAD);
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;
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;
});
for (const { key } of cands) {
if (sum <= maxBytes) break;
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(() => {});
sum -= meta.sizeBytes || 0;
delete entries[key];
}
set({ entries });
},
clearAllDisk: async (customDir: string | null) => {
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
set({ entries: {} });
},
}),
{
name: 'psysonic-hot-cache',
storage: createJSONStorage(() => localStorage),
partialize: s => ({ entries: s.entries }),
},
),
);
+25 -5
View File
@@ -3,10 +3,13 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { showToast } from '../utils/toast';
import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore';
export interface Track {
id: string;
@@ -209,6 +212,11 @@ radioAudio.addEventListener('suspend', () => {
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
function touchHotCacheOnPlayback(trackId: string, serverId: string) {
if (!trackId || !serverId) return;
useHotCacheStore.getState().touchPlayed(trackId, serverId);
}
// Track ID that has already been sent to audio_chain_preload / audio_preload.
// Prevents the 100ms progress ticker from firing 300 identical IPC calls over
// the last 30 seconds of a track, each spawning its own HTTP download.
@@ -230,6 +238,7 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
function handleAudioPlaying(_duration: number) {
setDeferHotCachePrefetch(false);
usePlayerStore.setState({ isPlaying: true });
}
@@ -281,7 +290,7 @@ function handleAudioProgress(current_time: number, duration: number) {
if (nextTrack && nextTrack.id !== track.id && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
const serverId = useAuthStore.getState().activeServerId ?? '';
const nextUrl = useOfflineStore.getState().getLocalUrl(nextTrack.id, serverId) ?? buildStreamUrl(nextTrack.id);
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
if (gaplessEnabled) {
// Gapless ON: decode + chain directly into the Sink now, 30 s in
// advance. By the time the track boundary arrives, the next source is
@@ -390,6 +399,7 @@ function handleAudioTrackSwitched(duration: number) {
});
}
syncQueueToServer(queue, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, useAuthStore.getState().activeServerId ?? '');
}
function handleAudioError(message: string) {
@@ -721,7 +731,8 @@ export const usePlayerStore = create<PlayerState>()(
});
const authState = useAuthStore.getState();
const url = useOfflineStore.getState().getLocalUrl(track.id, authState.activeServerId ?? '') ?? buildStreamUrl(track.id);
setDeferHotCachePrefetch(true);
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null
: null;
@@ -735,6 +746,7 @@ export const usePlayerStore = create<PlayerState>()(
manual,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
setTimeout(() => {
@@ -757,6 +769,7 @@ export const usePlayerStore = create<PlayerState>()(
});
}
syncQueueToServer(newQueue, track, 0);
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
},
// ── pause / resume / togglePlay ──────────────────────────────────────────
@@ -784,6 +797,7 @@ export const usePlayerStore = create<PlayerState>()(
invoke('audio_resume').catch(console.error);
isAudioPaused = false;
set({ isPlaying: true });
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
} else {
// Cold start (app relaunch) — fetch fresh track data for replay gain, then play.
const gen = ++playGeneration;
@@ -801,7 +815,9 @@ export const usePlayerStore = create<PlayerState>()(
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(trackToPlay.id, coldServerId) ?? buildStreamUrl(trackToPlay.id);
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
@@ -815,6 +831,7 @@ export const usePlayerStore = create<PlayerState>()(
}
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});
@@ -828,7 +845,9 @@ export const usePlayerStore = create<PlayerState>()(
: null;
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
const coldUrl = useOfflineStore.getState().getLocalUrl(currentTrack.id, coldServerId) ?? buildStreamUrl(currentTrack.id);
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
touchHotCacheOnPlayback(currentTrack.id, coldServerId);
invoke('audio_play', {
url: coldUrl,
volume: vol,
@@ -838,6 +857,7 @@ export const usePlayerStore = create<PlayerState>()(
manual: false,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false });
});