mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -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
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user