mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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.
This commit is contained in:
+114
-16
@@ -10,6 +10,15 @@ import {
|
||||
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;
|
||||
|
||||
@@ -89,11 +98,21 @@ function scheduleEvictAfterPreviousGrace(): void {
|
||||
|
||||
function enqueueJobs(jobs: PrefetchJob[]) {
|
||||
const seen = new Set(pendingQueue.map(j => `${j.serverId}:${j.trackId}`));
|
||||
let merged = 0;
|
||||
for (const j of jobs) {
|
||||
const k = `${j.serverId}:${j.trackId}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
pendingQueue.push(j);
|
||||
merged++;
|
||||
}
|
||||
if (merged > 0) {
|
||||
hotCacheFrontendDebug({
|
||||
event: 'prefetch-queue-jobs',
|
||||
added: merged,
|
||||
pendingTotal: pendingQueue.length,
|
||||
trackIds: jobs.map(j => j.trackId),
|
||||
});
|
||||
}
|
||||
void runWorker();
|
||||
}
|
||||
@@ -105,6 +124,11 @@ async function runWorker() {
|
||||
while (pendingQueue.length > 0) {
|
||||
const auth = useAuthStore.getState();
|
||||
if (!auth.isLoggedIn || !auth.hotCacheEnabled || !auth.activeServerId) {
|
||||
hotCacheFrontendDebug({
|
||||
event: 'prefetch-worker-stop',
|
||||
reason: 'auth-disabled-or-logged-out',
|
||||
clearedPending: pendingQueue.length,
|
||||
});
|
||||
pendingQueue.length = 0;
|
||||
break;
|
||||
}
|
||||
@@ -117,11 +141,24 @@ async function runWorker() {
|
||||
if (!job) break;
|
||||
|
||||
const maxBytes = Math.max(0, auth.hotCacheMaxMb) * 1024 * 1024;
|
||||
if (maxBytes <= 0) continue;
|
||||
if (maxBytes <= 0) {
|
||||
hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'max-mb-zero' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const offline = useOfflineStore.getState();
|
||||
if (offline.isDownloaded(job.trackId, job.serverId)) continue;
|
||||
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) continue;
|
||||
if (offline.isDownloaded(job.trackId, job.serverId)) {
|
||||
hotCacheFrontendDebug({ event: 'prefetch-skip-job', trackId: job.trackId, reason: 'offline-library' });
|
||||
continue;
|
||||
}
|
||||
if (useHotCacheStore.getState().entries[entryKey(job.serverId, job.trackId)]) {
|
||||
hotCacheFrontendDebug({
|
||||
event: 'prefetch-skip-job',
|
||||
trackId: job.trackId,
|
||||
reason: 'already-in-hot-index',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const player = usePlayerStore.getState();
|
||||
const { queue, queueIndex } = player;
|
||||
@@ -130,19 +167,46 @@ async function runWorker() {
|
||||
.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD)
|
||||
.map(t => t.id),
|
||||
);
|
||||
if (!wantIds.has(job.trackId)) continue;
|
||||
if (!wantIds.has(job.trackId)) {
|
||||
hotCacheFrontendDebug({
|
||||
event: 'prefetch-skip-job',
|
||||
trackId: job.trackId,
|
||||
reason: 'not-in-upcoming-window',
|
||||
queueIndex,
|
||||
window: PREFETCH_AHEAD,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const track = queue.find(t => t.id === job.trackId);
|
||||
if (!track) continue;
|
||||
if (!track) {
|
||||
hotCacheFrontendDebug({
|
||||
event: 'prefetch-skip-job',
|
||||
trackId: job.trackId,
|
||||
reason: 'track-not-in-queue',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const hotEntries = useHotCacheStore.getState().entries;
|
||||
const occupied = sumCachedBytesInProtectedWindow(queue, queueIndex, job.serverId, hotEntries);
|
||||
const est = estimateTrackHotCacheBytes(track);
|
||||
const isImmediateNext = queue[queueIndex + 1]?.id === job.trackId;
|
||||
if (!isImmediateNext && occupied + est > maxBytes) continue;
|
||||
if (!isImmediateNext && occupied + est > maxBytes) {
|
||||
hotCacheFrontendDebug({
|
||||
event: 'prefetch-skip-job',
|
||||
trackId: job.trackId,
|
||||
reason: 'budget-protected-window-plus-estimate',
|
||||
occupied,
|
||||
estimateBytes: est,
|
||||
maxBytes,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = buildStreamUrl(job.trackId);
|
||||
try {
|
||||
const customDir = auth.hotCacheDownloadDir || null;
|
||||
hotCacheFrontendDebug({ event: 'prefetch-invoke', trackId: job.trackId });
|
||||
const res = await invoke<{ path: string; size: number }>('download_track_hot_cache', {
|
||||
trackId: job.trackId,
|
||||
serverId: job.serverId,
|
||||
@@ -150,7 +214,8 @@ async function runWorker() {
|
||||
suffix: job.suffix,
|
||||
customDir,
|
||||
});
|
||||
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size);
|
||||
useHotCacheStore.getState().setEntry(job.trackId, job.serverId, res.path, res.size, 'prefetch');
|
||||
hotCacheFrontendDebug({ event: 'prefetch-stored', trackId: job.trackId, sizeBytes: res.size });
|
||||
const fresh = usePlayerStore.getState();
|
||||
const authAfter = useAuthStore.getState();
|
||||
const maxAfter = Math.max(0, authAfter.hotCacheMaxMb) * 1024 * 1024;
|
||||
@@ -161,8 +226,8 @@ async function runWorker() {
|
||||
authAfter.activeServerId ?? '',
|
||||
authAfter.hotCacheDownloadDir || null,
|
||||
);
|
||||
} catch {
|
||||
/* network / HTTP — skip */
|
||||
} catch (e: unknown) {
|
||||
hotCacheFrontendDebug({ event: 'prefetch-download-failed', trackId: job.trackId, error: String(e) });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -199,30 +264,55 @@ async function replanNow() {
|
||||
if (maxBytes <= 0) return;
|
||||
|
||||
const { queue, queueIndex, currentRadio } = usePlayerStore.getState();
|
||||
if (currentRadio) return;
|
||||
if (currentRadio) {
|
||||
hotCacheFrontendDebug({ event: 'replan-skip', reason: 'radio-mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const offline = useOfflineStore.getState();
|
||||
const hot = useHotCacheStore.getState();
|
||||
|
||||
await hot.evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
||||
await useHotCacheStore.getState().evictToFit(queue, queueIndex, maxBytes, serverId, customDir);
|
||||
|
||||
// Must read entries after eviction: the pre-evict snapshot still lists removed keys and would
|
||||
// skip prefetch for upcoming tracks that no longer have on-disk rows.
|
||||
const hotEntries = useHotCacheStore.getState().entries;
|
||||
|
||||
const targets = queue.slice(queueIndex + 1, queueIndex + 1 + PREFETCH_AHEAD);
|
||||
const immediateNextId = queue[queueIndex + 1]?.id;
|
||||
let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hot.entries);
|
||||
let projectedOccupied = sumCachedBytesInProtectedWindow(queue, queueIndex, serverId, hotEntries);
|
||||
const jobs: PrefetchJob[] = [];
|
||||
const skipped: { trackId: string; reason: string }[] = [];
|
||||
for (const t of targets) {
|
||||
if (offline.isDownloaded(t.id, serverId)) continue;
|
||||
if (hot.entries[entryKey(serverId, t.id)]) continue;
|
||||
if (offline.isDownloaded(t.id, serverId)) {
|
||||
skipped.push({ trackId: t.id, reason: 'offline-library' });
|
||||
continue;
|
||||
}
|
||||
if (hotEntries[entryKey(serverId, t.id)]) {
|
||||
skipped.push({ trackId: t.id, reason: 'already-in-hot-index' });
|
||||
continue;
|
||||
}
|
||||
const isImmediateNext = t.id === immediateNextId;
|
||||
if (isImmediateNext) {
|
||||
jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' });
|
||||
continue;
|
||||
}
|
||||
const est = estimateTrackHotCacheBytes(t);
|
||||
if (projectedOccupied + est > maxBytes) break;
|
||||
if (projectedOccupied + est > maxBytes) {
|
||||
skipped.push({ trackId: t.id, reason: 'budget-cap-rest-deferred' });
|
||||
break;
|
||||
}
|
||||
projectedOccupied += est;
|
||||
jobs.push({ trackId: t.id, serverId, suffix: t.suffix || 'mp3' });
|
||||
}
|
||||
hotCacheFrontendDebug({
|
||||
event: 'replan',
|
||||
queueIndex,
|
||||
aheadCount: targets.length,
|
||||
scheduledIds: jobs.map(j => j.trackId),
|
||||
skipped,
|
||||
projectedOccupiedBytes: projectedOccupied,
|
||||
maxBytes,
|
||||
});
|
||||
enqueueJobs(jobs);
|
||||
}
|
||||
|
||||
@@ -266,6 +356,7 @@ export function initHotCachePrefetch(): () => void {
|
||||
}
|
||||
|
||||
if (!state.hotCacheEnabled || !state.isLoggedIn) {
|
||||
hotCacheFrontendDebug({ event: 'prefetch-auth-off', clearedPending: pendingQueue.length });
|
||||
pendingQueue.length = 0;
|
||||
clearHotCachePreviousGrace();
|
||||
return;
|
||||
@@ -286,6 +377,13 @@ export function initHotCachePrefetch(): () => void {
|
||||
|
||||
if (budgetSettingsChanged) {
|
||||
if (prev && state.hotCacheMaxMb < prev.hotCacheMaxMb) {
|
||||
hotCacheFrontendDebug({
|
||||
event: 'prefetch-pending-cleared',
|
||||
reason: 'hot-cache-max-mb-decreased',
|
||||
prevMb: prev.hotCacheMaxMb,
|
||||
nextMb: state.hotCacheMaxMb,
|
||||
droppedJobs: pendingQueue.length,
|
||||
});
|
||||
pendingQueue.length = 0;
|
||||
}
|
||||
void replanNow();
|
||||
|
||||
@@ -20,7 +20,13 @@ 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;
|
||||
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;
|
||||
@@ -51,6 +57,29 @@ function lruStamp(meta: HotCacheEntry | undefined): number {
|
||||
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) => ({
|
||||
@@ -62,7 +91,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
return `psysonic-local://${e.localPath}`;
|
||||
},
|
||||
|
||||
setEntry: (trackId, serverId, localPath, sizeBytes) => {
|
||||
setEntry: (trackId, serverId, localPath, sizeBytes, debugSource) => {
|
||||
const now = Date.now();
|
||||
set(s => ({
|
||||
entries: {
|
||||
@@ -75,6 +104,13 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
},
|
||||
},
|
||||
}));
|
||||
hotCacheFrontendDebug({
|
||||
event: 'index-add',
|
||||
trackId,
|
||||
serverId,
|
||||
sizeBytes,
|
||||
source: debugSource ?? 'unknown',
|
||||
});
|
||||
},
|
||||
|
||||
touchPlayed: (trackId, serverId) => {
|
||||
@@ -97,6 +133,12 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
delete next[entryKey(serverId, trackId)];
|
||||
return { entries: next };
|
||||
});
|
||||
hotCacheFrontendDebug({
|
||||
event: 'index-remove',
|
||||
trackId,
|
||||
serverId,
|
||||
reason: 'explicit-removeEntry',
|
||||
});
|
||||
emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' });
|
||||
},
|
||||
|
||||
@@ -157,8 +199,31 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
return a.lru - b.lru;
|
||||
});
|
||||
|
||||
for (const { key } of cands) {
|
||||
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);
|
||||
@@ -166,7 +231,24 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
await invoke('delete_hot_cache_track', {
|
||||
localPath: meta.localPath,
|
||||
customDir: hotCacheCustomDir || null,
|
||||
}).catch(() => {});
|
||||
}).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' });
|
||||
@@ -176,6 +258,10 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
},
|
||||
|
||||
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' });
|
||||
|
||||
+36
-29
@@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||
import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl';
|
||||
import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl';
|
||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||
import { useAuthStore } from './authStore';
|
||||
@@ -608,8 +609,16 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||
}
|
||||
|
||||
/** Coalesce concurrent `analysis_get_waveform_for_track` for one id (StrictMode double mount, init + queue restore). */
|
||||
const waveformRefreshInflight = new Map<string, Promise<void>>();
|
||||
/** Last-write-wins generation per track: avoids applying a stale empty waveform read when
|
||||
* `analysis:waveform-updated` bumps gen after SQLite commit while an older `analysis_get_waveform_for_track`
|
||||
* is still in flight. Gen is bumped only on explicit invalidation (waveform-updated, analysis storage),
|
||||
* not on every `refreshWaveformForTrack` call — otherwise bursts (Lucky Mix, queue) cancel each other. */
|
||||
const waveformRefreshGenByTrackId: Record<string, number> = {};
|
||||
|
||||
function bumpWaveformRefreshGen(trackId: string) {
|
||||
if (!trackId) return;
|
||||
waveformRefreshGenByTrackId[trackId] = (waveformRefreshGenByTrackId[trackId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The
|
||||
* analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in
|
||||
@@ -740,33 +749,25 @@ async function reseedLoudnessForTrackId(trackId: string) {
|
||||
|
||||
async function refreshWaveformForTrack(trackId: string) {
|
||||
if (!trackId) return;
|
||||
let job = waveformRefreshInflight.get(trackId);
|
||||
if (!job) {
|
||||
const p = (async () => {
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
|
||||
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
|
||||
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
|
||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||
if (!bins || bins.length === 0) {
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
usePlayerStore.setState({
|
||||
waveformBins: bins,
|
||||
});
|
||||
} catch {
|
||||
// best-effort; seekbar falls back to placeholder waveform
|
||||
}
|
||||
})();
|
||||
job = p.finally(() => {
|
||||
waveformRefreshInflight.delete(trackId);
|
||||
const gen = waveformRefreshGenByTrackId[trackId] ?? 0;
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
|
||||
if ((waveformRefreshGenByTrackId[trackId] ?? 0) !== gen) return;
|
||||
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
|
||||
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
|
||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||
if (!bins || bins.length === 0) {
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
usePlayerStore.setState({
|
||||
waveformBins: bins,
|
||||
});
|
||||
waveformRefreshInflight.set(trackId, job);
|
||||
} catch {
|
||||
// best-effort; seekbar falls back to placeholder waveform
|
||||
}
|
||||
await job;
|
||||
}
|
||||
|
||||
/** When `syncPlayingEngine` is false, only update `cachedLoudnessGainByTrackId` (e.g. queue neighbour) — do not call `audio_update_replay_gain` for the already-playing track. */
|
||||
@@ -805,7 +806,11 @@ async function runRefreshLoudnessForTrack(trackId: string, syncEngine: boolean):
|
||||
analysisBackfillInFlightByTrackId[trackId] = true;
|
||||
analysisBackfillAttemptsByTrackId[trackId] = attempts + 1;
|
||||
const url = buildStreamUrl(trackId);
|
||||
emitNormalizationDebug('backfill:enqueue', { trackId, url, attempt: attempts + 1 });
|
||||
emitNormalizationDebug('backfill:enqueue', {
|
||||
trackId,
|
||||
url: redactSubsonicUrlForLog(url),
|
||||
attempt: attempts + 1,
|
||||
});
|
||||
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
|
||||
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
|
||||
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
|
||||
@@ -884,7 +889,7 @@ async function promoteCompletedStreamToHotCache(track: Track, serverId: string,
|
||||
},
|
||||
);
|
||||
if (!res || !res.path) return;
|
||||
useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0);
|
||||
useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0, 'stream-promote');
|
||||
} catch {
|
||||
// best-effort promotion; normal hot-cache prefetch remains fallback
|
||||
}
|
||||
@@ -1300,6 +1305,7 @@ export function initAudioListeners(): () => void {
|
||||
const currentRaw = usePlayerStore.getState().currentTrack?.id;
|
||||
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
|
||||
if (currentId && payloadTrackId === currentId) {
|
||||
bumpWaveformRefreshGen(currentRaw!);
|
||||
void refreshWaveformForTrack(currentRaw!);
|
||||
void refreshLoudnessForTrack(currentId);
|
||||
emitNormalizationDebug('backfill:applied', { trackId: currentId });
|
||||
@@ -1468,6 +1474,7 @@ export function initAudioListeners(): () => void {
|
||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (!currentId) return;
|
||||
if (detail.trackId && detail.trackId !== currentId) return;
|
||||
bumpWaveformRefreshGen(currentId);
|
||||
void refreshWaveformForTrack(currentId);
|
||||
void refreshLoudnessForTrack(currentId);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Masks Subsonic wire-auth query params so debug logs are safe to copy.
|
||||
* (`t` salt, `s` token hash, `p` password when present.)
|
||||
*/
|
||||
export function redactSubsonicUrlForLog(url: string): string {
|
||||
if (!url || !url.includes('stream.view')) return url;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
// Placeholder must stay URL-safe (no `<>` — URLSearchParams percent-encodes them).
|
||||
for (const k of ['t', 's', 'p'] as const) {
|
||||
if (u.searchParams.has(k)) u.searchParams.set(k, 'REDACTED');
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url.replace(/([?&])(t|s|p)=([^&]*)/gi, (_m, sep: string, key: string) => `${sep}${key}=REDACTED`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user