mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
refactor(hot-cache): extract pure helpers + analysis-prune cluster from hotCachePrefetch.ts (#687)
The prefetch worker / replan orchestration and its shared module state
(pendingQueue, workerRunning, debounceTimer, graceEvictTimer) stay
entirely untouched in hotCachePrefetch.ts. Extracted only what does not
touch that state:
- hotCachePrefetch/helpers.ts pure helpers — entryKey, byte
estimators, debounceMs, frontend
debug log, PREFETCH_AHEAD, PrefetchJob
- hotCachePrefetch/analysisPrune.ts self-contained analysis-queue prune
cluster (its own analysisPruneTimer +
lastAnalysisPruneSig state, fully
encapsulated behind a
resetAnalysisPruneState wrapper)
Verbatim moves. The orchestration core diff is import-block + the three
analysis-cleanup lines collapsing to resetAnalysisPruneState() — nothing
else. The MainApp call site imports initHotCachePrefetch unchanged.
This commit is contained in:
committed by
GitHub
parent
f419081a2a
commit
ec112922a2
+15
-118
@@ -2,7 +2,7 @@ import { buildStreamUrl } from './api/subsonicStreamUrl';
|
|||||||
import type { Track } from './store/playerStoreTypes';
|
import type { Track } from './store/playerStoreTypes';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useAuthStore } from './store/authStore';
|
import { useAuthStore } from './store/authStore';
|
||||||
import { HOT_CACHE_PROTECT_AFTER_CURRENT, useHotCacheStore, type HotCacheEntry } from './store/hotCacheStore';
|
import { useHotCacheStore } from './store/hotCacheStore';
|
||||||
import { useOfflineStore } from './store/offlineStore';
|
import { useOfflineStore } from './store/offlineStore';
|
||||||
import { usePlayerStore } from './store/playerStore';
|
import { usePlayerStore } from './store/playerStore';
|
||||||
import {
|
import {
|
||||||
@@ -10,126 +10,25 @@ import {
|
|||||||
clearHotCachePreviousGrace,
|
clearHotCachePreviousGrace,
|
||||||
getDeferHotCachePrefetch,
|
getDeferHotCachePrefetch,
|
||||||
} from './utils/hotCacheGate';
|
} from './utils/hotCacheGate';
|
||||||
|
import {
|
||||||
/** Settings → Logging → Debug (`frontend_debug_log` → Rust stderr), same as normalization / lucky-mix. */
|
PREFETCH_AHEAD,
|
||||||
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
|
type PrefetchJob,
|
||||||
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
entryKey,
|
||||||
void invoke('frontend_debug_log', {
|
sumCachedBytesInProtectedWindow,
|
||||||
scope: 'hot-cache',
|
estimateTrackHotCacheBytes,
|
||||||
message: JSON.stringify(payload),
|
hotCacheFrontendDebug,
|
||||||
}).catch(() => {});
|
debounceMs,
|
||||||
}
|
} from './hotCachePrefetch/helpers';
|
||||||
|
import {
|
||||||
/** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */
|
scheduleAnalysisQueuePruneFromPlaybackQueue,
|
||||||
const PREFETCH_AHEAD = 5;
|
resetAnalysisPruneState,
|
||||||
|
} from './hotCachePrefetch/analysisPrune';
|
||||||
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 };
|
|
||||||
|
|
||||||
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. */
|
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
|
||||||
let graceEvictTimer: ReturnType<typeof setTimeout> | null = null;
|
let graceEvictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const pendingQueue: PrefetchJob[] = [];
|
const pendingQueue: PrefetchJob[] = [];
|
||||||
let workerRunning = false;
|
let workerRunning = false;
|
||||||
let analysisPruneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let lastAnalysisPruneSig = '';
|
|
||||||
const ANALYSIS_PRUNE_DEBOUNCE_MS = 1200;
|
|
||||||
|
|
||||||
type AnalysisPrunePendingResult = {
|
|
||||||
keepCount: number;
|
|
||||||
httpRemoved: number;
|
|
||||||
cpuRemovedJobs: number;
|
|
||||||
cpuRemovedWaiters: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
|
|
||||||
const { queue, currentTrack } = usePlayerStore.getState();
|
|
||||||
const keepTrackIds: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const pushId = (id: string | undefined | null) => {
|
|
||||||
if (!id) return;
|
|
||||||
const tid = id.trim();
|
|
||||||
if (!tid || seen.has(tid)) return;
|
|
||||||
seen.add(tid);
|
|
||||||
keepTrackIds.push(tid);
|
|
||||||
};
|
|
||||||
pushId(currentTrack?.id);
|
|
||||||
for (const track of queue) {
|
|
||||||
pushId(track.id);
|
|
||||||
if (keepTrackIds.length >= 1000) break;
|
|
||||||
}
|
|
||||||
const sig = JSON.stringify(keepTrackIds);
|
|
||||||
if (sig === lastAnalysisPruneSig) return;
|
|
||||||
lastAnalysisPruneSig = sig;
|
|
||||||
if (analysisPruneTimer) {
|
|
||||||
clearTimeout(analysisPruneTimer);
|
|
||||||
analysisPruneTimer = null;
|
|
||||||
}
|
|
||||||
analysisPruneTimer = setTimeout(() => {
|
|
||||||
analysisPruneTimer = null;
|
|
||||||
void invoke<AnalysisPrunePendingResult>('analysis_prune_pending_to_track_ids', { trackIds: keepTrackIds })
|
|
||||||
.then(result => {
|
|
||||||
if (!result) return;
|
|
||||||
hotCacheFrontendDebug({
|
|
||||||
event: 'analysis-prune',
|
|
||||||
keepCount: result.keepCount,
|
|
||||||
removedHttp: result.httpRemoved,
|
|
||||||
removedCpuJobs: result.cpuRemovedJobs,
|
|
||||||
removedCpuWaiters: result.cpuRemovedWaiters,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, ANALYSIS_PRUNE_DEBOUNCE_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function debounceMs(): number {
|
|
||||||
const s = useAuthStore.getState().hotCacheDebounceSec;
|
|
||||||
if (!Number.isFinite(s) || s < 0) return 0;
|
|
||||||
return Math.min(600, s) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleEvictAfterPreviousGrace(): void {
|
function scheduleEvictAfterPreviousGrace(): void {
|
||||||
if (graceEvictTimer) {
|
if (graceEvictTimer) {
|
||||||
@@ -454,9 +353,7 @@ export function initHotCachePrefetch(): () => void {
|
|||||||
debounceTimer = null;
|
debounceTimer = null;
|
||||||
if (graceEvictTimer) clearTimeout(graceEvictTimer);
|
if (graceEvictTimer) clearTimeout(graceEvictTimer);
|
||||||
graceEvictTimer = null;
|
graceEvictTimer = null;
|
||||||
if (analysisPruneTimer) clearTimeout(analysisPruneTimer);
|
resetAnalysisPruneState();
|
||||||
analysisPruneTimer = null;
|
|
||||||
lastAnalysisPruneSig = '';
|
|
||||||
pendingQueue.length = 0;
|
pendingQueue.length = 0;
|
||||||
clearHotCachePreviousGrace();
|
clearHotCachePreviousGrace();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
import { hotCacheFrontendDebug } from './helpers';
|
||||||
|
|
||||||
|
let analysisPruneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastAnalysisPruneSig = '';
|
||||||
|
const ANALYSIS_PRUNE_DEBOUNCE_MS = 1200;
|
||||||
|
|
||||||
|
type AnalysisPrunePendingResult = {
|
||||||
|
keepCount: number;
|
||||||
|
httpRemoved: number;
|
||||||
|
cpuRemovedJobs: number;
|
||||||
|
cpuRemovedWaiters: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scheduleAnalysisQueuePruneFromPlaybackQueue(): void {
|
||||||
|
const { queue, currentTrack } = usePlayerStore.getState();
|
||||||
|
const keepTrackIds: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const pushId = (id: string | undefined | null) => {
|
||||||
|
if (!id) return;
|
||||||
|
const tid = id.trim();
|
||||||
|
if (!tid || seen.has(tid)) return;
|
||||||
|
seen.add(tid);
|
||||||
|
keepTrackIds.push(tid);
|
||||||
|
};
|
||||||
|
pushId(currentTrack?.id);
|
||||||
|
for (const track of queue) {
|
||||||
|
pushId(track.id);
|
||||||
|
if (keepTrackIds.length >= 1000) break;
|
||||||
|
}
|
||||||
|
const sig = JSON.stringify(keepTrackIds);
|
||||||
|
if (sig === lastAnalysisPruneSig) return;
|
||||||
|
lastAnalysisPruneSig = sig;
|
||||||
|
if (analysisPruneTimer) {
|
||||||
|
clearTimeout(analysisPruneTimer);
|
||||||
|
analysisPruneTimer = null;
|
||||||
|
}
|
||||||
|
analysisPruneTimer = setTimeout(() => {
|
||||||
|
analysisPruneTimer = null;
|
||||||
|
void invoke<AnalysisPrunePendingResult>('analysis_prune_pending_to_track_ids', { trackIds: keepTrackIds })
|
||||||
|
.then(result => {
|
||||||
|
if (!result) return;
|
||||||
|
hotCacheFrontendDebug({
|
||||||
|
event: 'analysis-prune',
|
||||||
|
keepCount: result.keepCount,
|
||||||
|
removedHttp: result.httpRemoved,
|
||||||
|
removedCpuJobs: result.cpuRemovedJobs,
|
||||||
|
removedCpuWaiters: result.cpuRemovedWaiters,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, ANALYSIS_PRUNE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear-down for the analysis-prune state — used by initHotCachePrefetch's cleanup. */
|
||||||
|
export function resetAnalysisPruneState(): void {
|
||||||
|
if (analysisPruneTimer) clearTimeout(analysisPruneTimer);
|
||||||
|
analysisPruneTimer = null;
|
||||||
|
lastAnalysisPruneSig = '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { HOT_CACHE_PROTECT_AFTER_CURRENT, type HotCacheEntry } from '../store/hotCacheStore';
|
||||||
|
|
||||||
|
/** Settings → Logging → Debug (`frontend_debug_log` → Rust stderr), same as normalization / lucky-mix. */
|
||||||
|
export 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(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */
|
||||||
|
export const PREFETCH_AHEAD = 5;
|
||||||
|
|
||||||
|
export type PrefetchJob = { trackId: string; serverId: string; suffix: string };
|
||||||
|
|
||||||
|
export 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`). */
|
||||||
|
export 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. */
|
||||||
|
export 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounceMs(): number {
|
||||||
|
const s = useAuthStore.getState().hotCacheDebounceSec;
|
||||||
|
if (!Number.isFinite(s) || s < 0) return 0;
|
||||||
|
return Math.min(600, s) * 1000;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user