mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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 { invoke } from '@tauri-apps/api/core';
|
||||
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 { usePlayerStore } from './store/playerStore';
|
||||
import {
|
||||
@@ -10,126 +10,25 @@ import {
|
||||
clearHotCachePreviousGrace,
|
||||
getDeferHotCachePrefetch,
|
||||
} from './utils/hotCacheGate';
|
||||
|
||||
/** Settings → Logging → Debug (`frontend_debug_log` → Rust stderr), same as normalization / 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(() => {});
|
||||
}
|
||||
|
||||
/** How many upcoming queue tracks may be prefetched (only current + next are eviction-protected). */
|
||||
const PREFETCH_AHEAD = 5;
|
||||
|
||||
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 };
|
||||
import {
|
||||
PREFETCH_AHEAD,
|
||||
type PrefetchJob,
|
||||
entryKey,
|
||||
sumCachedBytesInProtectedWindow,
|
||||
estimateTrackHotCacheBytes,
|
||||
hotCacheFrontendDebug,
|
||||
debounceMs,
|
||||
} from './hotCachePrefetch/helpers';
|
||||
import {
|
||||
scheduleAnalysisQueuePruneFromPlaybackQueue,
|
||||
resetAnalysisPruneState,
|
||||
} from './hotCachePrefetch/analysisPrune';
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Fires `replanNow` once grace for the ex-current track ends so eviction can drop it. */
|
||||
let graceEvictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pendingQueue: PrefetchJob[] = [];
|
||||
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 {
|
||||
if (graceEvictTimer) {
|
||||
@@ -454,9 +353,7 @@ export function initHotCachePrefetch(): () => void {
|
||||
debounceTimer = null;
|
||||
if (graceEvictTimer) clearTimeout(graceEvictTimer);
|
||||
graceEvictTimer = null;
|
||||
if (analysisPruneTimer) clearTimeout(analysisPruneTimer);
|
||||
analysisPruneTimer = null;
|
||||
lastAnalysisPruneSig = '';
|
||||
resetAnalysisPruneState();
|
||||
pendingQueue.length = 0;
|
||||
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