perf: drastically reduce background API requests to Navidrome (v1.34.5)

- NowPlaying polling: only active while dropdown is open + respects
  Page Visibility API — was firing every 10s unconditionally (~8.6k req/day)
- Connection check interval: 30s → 120s (4× reduction)
- Queue sync debounce: 1.5s → 5s (prevents burst on rapid skip)
- Rating prefetch: add 7-minute in-memory TTL cache for artist/album
  ratings — repeated random album loads no longer re-fetch known ratings

Fixes server log flooding reported by users behind reverse proxies (Traefik).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-08 17:57:49 +02:00
parent 1e4b851e9e
commit 3643a78cd6
9 changed files with 47 additions and 20 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.34.4", "version": "1.34.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -3339,7 +3339,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.34.4" version = "1.34.5"
dependencies = [ dependencies = [
"biquad", "biquad",
"discord-rich-presence", "discord-rich-presence",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.34.4" version = "1.34.5"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.34.4", "version": "1.34.5",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+35 -6
View File
@@ -332,6 +332,19 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
} }
const MIX_RATING_PREFETCH_CONCURRENCY = 8; const MIX_RATING_PREFETCH_CONCURRENCY = 8;
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
function getCachedRating(key: string): number | undefined | null {
const entry = ratingCache.get(key);
if (!entry) return null; // cache miss
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
return entry.value;
}
function setCachedRating(key: string, value: number | undefined): void {
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
}
function parseEntityUserRating(v: unknown): number | undefined { function parseEntityUserRating(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined; if (v === null || v === undefined) return undefined;
@@ -348,22 +361,30 @@ export async function prefetchArtistUserRatings(
const unique = [...new Set(ids.filter(Boolean))]; const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>(); const out = new Map<string, number>();
if (!unique.length) return out; if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`artist:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
if (!uncached.length) return out;
let next = 0; let next = 0;
async function worker() { async function worker() {
for (;;) { for (;;) {
const i = next++; const i = next++;
if (i >= unique.length) return; if (i >= uncached.length) return;
const id = unique[i]; const id = uncached[i];
try { try {
const { artist } = await getArtist(id); const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating); const r = parseEntityUserRating(artist.userRating);
setCachedRating(`artist:${id}`, r);
if (r !== undefined) out.set(id, r); if (r !== undefined) out.set(id, r);
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
} }
const nWorkers = Math.min(concurrency, unique.length); const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker())); await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out; return out;
} }
@@ -376,22 +397,30 @@ export async function prefetchAlbumUserRatings(
const unique = [...new Set(ids.filter(Boolean))]; const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>(); const out = new Map<string, number>();
if (!unique.length) return out; if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`album:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
if (!uncached.length) return out;
let next = 0; let next = 0;
async function worker() { async function worker() {
for (;;) { for (;;) {
const i = next++; const i = next++;
if (i >= unique.length) return; if (i >= uncached.length) return;
const id = unique[i]; const id = uncached[i];
try { try {
const { album } = await getAlbum(id); const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating); const r = parseEntityUserRating(album.userRating);
setCachedRating(`album:${id}`, r);
if (r !== undefined) out.set(id, r); if (r !== undefined) out.set(id, r);
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
} }
const nWorkers = Math.min(concurrency, unique.length); const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker())); await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out; return out;
} }
+5 -7
View File
@@ -36,16 +36,14 @@ export default function NowPlayingDropdown() {
}); });
}; };
// Poll in background so the badge stays current without opening the dropdown // Poll only while the dropdown is open AND the page is visible.
useEffect(() => { useEffect(() => {
if (!isOpen) return;
fetchNowPlaying(); fetchNowPlaying();
const id = setInterval(fetchNowPlaying, 10000); const id = setInterval(() => {
if (document.visibilityState === 'visible') fetchNowPlaying();
}, 10000);
return () => clearInterval(id); return () => clearInterval(id);
}, []);
// Refresh immediately when dropdown is opened
useEffect(() => {
if (isOpen) fetchNowPlaying();
}, [isOpen]); }, [isOpen]);
// Click outside to close // Click outside to close
+1 -1
View File
@@ -49,7 +49,7 @@ export function useConnectionStatus() {
useEffect(() => { useEffect(() => {
check(); check();
intervalRef.current = setInterval(check, 30_000); intervalRef.current = setInterval(check, 120_000);
const handleOnline = () => check(); const handleOnline = () => check();
const handleOffline = () => setStatus('disconnected'); const handleOffline = () => setStatus('disconnected');
+1 -1
View File
@@ -227,7 +227,7 @@ export const useAuthStore = create<AuthState>()(
minimizeToTray: false, minimizeToTray: false,
discordRichPresence: false, discordRichPresence: false,
enableAppleMusicCoversDiscord: false, enableAppleMusicCoversDiscord: false,
useCustomTitlebar: true, useCustomTitlebar: false,
nowPlayingEnabled: false, nowPlayingEnabled: false,
lyricsServerFirst: true, lyricsServerFirst: true,
showFullscreenLyrics: true, showFullscreenLyrics: true,
+1 -1
View File
@@ -264,7 +264,7 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
savePlayQueue(ids, currentTrack?.id, pos).catch(err => { savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
console.error('Failed to sync play queue to server', err); console.error('Failed to sync play queue to server', err);
}); });
}, 1500); }, 5000);
} }
// ─── Audio event handlers (called from initAudioListeners) ─────────────────── // ─── Audio event handlers (called from initAudioListeners) ───────────────────