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
+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 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 {
if (v === null || v === undefined) return undefined;
@@ -348,22 +361,30 @@ export async function prefetchArtistUserRatings(
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
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;
async function worker() {
for (;;) {
const i = next++;
if (i >= unique.length) return;
const id = unique[i];
if (i >= uncached.length) return;
const id = uncached[i];
try {
const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating);
setCachedRating(`artist:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, unique.length);
const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
@@ -376,22 +397,30 @@ export async function prefetchAlbumUserRatings(
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
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;
async function worker() {
for (;;) {
const i = next++;
if (i >= unique.length) return;
const id = unique[i];
if (i >= uncached.length) return;
const id = uncached[i];
try {
const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating);
setCachedRating(`album:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, unique.length);
const nWorkers = Math.min(concurrency, uncached.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
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(() => {
if (!isOpen) return;
fetchNowPlaying();
const id = setInterval(fetchNowPlaying, 10000);
const id = setInterval(() => {
if (document.visibilityState === 'visible') fetchNowPlaying();
}, 10000);
return () => clearInterval(id);
}, []);
// Refresh immediately when dropdown is opened
useEffect(() => {
if (isOpen) fetchNowPlaying();
}, [isOpen]);
// Click outside to close
+1 -1
View File
@@ -49,7 +49,7 @@ export function useConnectionStatus() {
useEffect(() => {
check();
intervalRef.current = setInterval(check, 30_000);
intervalRef.current = setInterval(check, 120_000);
const handleOnline = () => check();
const handleOffline = () => setStatus('disconnected');
+1 -1
View File
@@ -227,7 +227,7 @@ export const useAuthStore = create<AuthState>()(
minimizeToTray: false,
discordRichPresence: false,
enableAppleMusicCoversDiscord: false,
useCustomTitlebar: true,
useCustomTitlebar: false,
nowPlayingEnabled: false,
lyricsServerFirst: 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 => {
console.error('Failed to sync play queue to server', err);
});
}, 1500);
}, 5000);
}
// ─── Audio event handlers (called from initAudioListeners) ───────────────────