fix: random albums performance, image cache memory leak, i18n fix (v1.4.1)

- Remove auto-refresh timer from Random Albums (caused 100ms progress
  interval + 30 concurrent fetches every 30s, eventually freezing the app)
- Limit concurrent image fetches to 5 (was unbounded)
- Cap in-memory object URL cache at 150 entries with revokeObjectURL eviction
- Add cancellation flag to useCachedUrl to prevent setState on unmounted components
- Fix hardcoded "Neueste" page title in New Releases (now uses i18n)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-16 19:09:31 +01:00
parent d3ffa30bf5
commit af18aef42a
9 changed files with 62 additions and 57 deletions
+13
View File
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.1] - 2026-03-16
### Fixed
#### Random Albums — Performance & Memory
- **Auto-refresh removed**: The 30-second auto-cycle timer caused 10 React state updates/second (progress bar interval) and a burst of 30 concurrent image fetches on every tick, eventually making the whole app unresponsive. The page now loads once on mount; use the manual refresh button to get a new selection.
- **Concurrent fetch limit**: Image fetches are now capped at 5 simultaneous network requests (was unlimited — 30 at once on every refresh).
- **Object URL memory leak**: The in-memory image cache now caps at 150 entries and revokes old object URLs via `URL.revokeObjectURL()` when evicting. Previously, object URLs accumulated without bound across the entire session.
- **Dangling state updates**: `useCachedUrl` now uses a cancellation flag — if a component unmounts while a fetch is in flight (e.g. during a grid refresh), the resolved URL is discarded instead of calling `setState` on an unmounted component.
#### i18n
- Page title "Neueste" on the New Releases page was hardcoded German. Now uses `t('sidebar.newReleases')`.
## [1.4.0] - 2026-03-16 ## [1.4.0] - 2026-03-16
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.4.0", "version": "1.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.4.0" version = "1.4.1"
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.4.0", "version": "1.4.1",
"identifier": "dev.psysonic.app", "identifier": "dev.psysonic.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+3 -1
View File
@@ -10,7 +10,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
const [resolved, setResolved] = useState(''); const [resolved, setResolved] = useState('');
useEffect(() => { useEffect(() => {
if (!fetchUrl) { setResolved(''); return; } if (!fetchUrl) { setResolved(''); return; }
getCachedUrl(fetchUrl, cacheKey).then(setResolved); let cancelled = false;
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
return () => { cancelled = true; };
}, [fetchUrl, cacheKey]); }, [fetchUrl, cacheKey]);
return resolved || fetchUrl; return resolved || fetchUrl;
} }
+3 -1
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic'; import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function NewReleases() { export default function NewReleases() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -49,7 +51,7 @@ export default function NewReleases() {
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>Neueste</h1> <h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
{loading && albums.length === 0 ? ( {loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
+2 -36
View File
@@ -4,23 +4,14 @@ import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const INTERVAL_MS = 30000;
const ALBUM_COUNT = 30; const ALBUM_COUNT = 30;
export default function RandomAlbums() { export default function RandomAlbums() {
const { t } = useTranslation(); const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const clearTimers = () => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
if (progressRef.current) { clearInterval(progressRef.current); progressRef.current = null; }
};
const load = useCallback(async () => { const load = useCallback(async () => {
if (loadingRef.current) return; if (loadingRef.current) return;
loadingRef.current = true; loadingRef.current = true;
@@ -36,27 +27,7 @@ export default function RandomAlbums() {
} }
}, []); }, []);
const startCycle = useCallback(() => { useEffect(() => { load(); }, [load]);
clearTimers();
setProgress(0);
const startTime = Date.now();
progressRef.current = setInterval(() => {
setProgress(Math.min((Date.now() - startTime) / INTERVAL_MS * 100, 100));
}, 100);
timerRef.current = setInterval(() => {
load().then(() => startCycle());
}, INTERVAL_MS);
}, [load]);
useEffect(() => {
load().then(() => startCycle());
return clearTimers;
}, [load, startCycle]);
const handleManualRefresh = () => {
clearTimers();
load().then(() => startCycle());
};
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
@@ -64,7 +35,7 @@ export default function RandomAlbums() {
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1> <h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
<button <button
className="btn btn-ghost" className="btn btn-ghost"
onClick={handleManualRefresh} onClick={load}
disabled={loading} disabled={loading}
data-tooltip={t('randomAlbums.refresh')} data-tooltip={t('randomAlbums.refresh')}
> >
@@ -73,11 +44,6 @@ export default function RandomAlbums() {
</button> </button>
</div> </div>
{/* Countdown progress bar */}
<div className="random-albums-progress">
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
</div>
{loading && albums.length === 0 ? ( {loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}> <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" /> <div className="spinner" />
+1 -13
View File
@@ -313,19 +313,7 @@
gap: var(--space-4); gap: var(--space-4);
} }
.random-albums-progress {
height: 2px;
background: var(--border-subtle);
border-radius: var(--radius-full);
margin-bottom: 1.5rem;
overflow: hidden;
}
.random-albums-progress-fill {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.1s linear;
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } .album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
} }
+37 -3
View File
@@ -1,10 +1,39 @@
const DB_NAME = 'psysonic-img-cache'; const DB_NAME = 'psysonic-img-cache';
const STORE_NAME = 'images'; const STORE_NAME = 'images';
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const MAX_MEMORY_CACHE = 150; // max object URLs kept in RAM
const MAX_CONCURRENT_FETCHES = 5;
// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session) // In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
const objectUrlCache = new Map<string, string>(); const objectUrlCache = new Map<string, string>();
// Concurrency limiter for network fetches
let activeFetches = 0;
const fetchQueue: Array<() => void> = [];
function acquireFetchSlot(): Promise<void> {
if (activeFetches < MAX_CONCURRENT_FETCHES) {
activeFetches++;
return Promise.resolve();
}
return new Promise(resolve => fetchQueue.push(resolve));
}
function releaseFetchSlot(): void {
activeFetches--;
const next = fetchQueue.shift();
if (next) { activeFetches++; next(); }
}
function evictIfNeeded(): void {
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
const oldestKey = objectUrlCache.keys().next().value;
if (!oldestKey) break;
URL.revokeObjectURL(objectUrlCache.get(oldestKey)!);
objectUrlCache.delete(oldestKey);
}
}
let db: IDBDatabase | null = null; let db: IDBDatabase | null = null;
let dbPromise: Promise<IDBDatabase> | null = null; let dbPromise: Promise<IDBDatabase> | null = null;
@@ -75,10 +104,12 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
if (blob) { if (blob) {
const objUrl = URL.createObjectURL(blob); const objUrl = URL.createObjectURL(blob);
objectUrlCache.set(cacheKey, objUrl); objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
return objUrl; return objUrl;
} }
// 3. Network fetch → store in IDB → return object URL // 3. Network fetch with concurrency limit → store in IDB → return object URL
await acquireFetchSlot();
try { try {
const resp = await fetch(fetchUrl); const resp = await fetch(fetchUrl);
if (!resp.ok) return fetchUrl; if (!resp.ok) return fetchUrl;
@@ -86,8 +117,11 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
putBlob(cacheKey, newBlob); // fire-and-forget putBlob(cacheKey, newBlob); // fire-and-forget
const objUrl = URL.createObjectURL(newBlob); const objUrl = URL.createObjectURL(newBlob);
objectUrlCache.set(cacheKey, objUrl); objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
return objUrl; return objUrl;
} catch { } catch {
return fetchUrl; // fallback: direct URL return fetchUrl;
} finally {
releaseFetchSlot();
} }
} }