import { useAuthStore } from '../store/authStore'; const DB_NAME = 'psysonic-img-cache'; const STORE_NAME = 'images'; 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 (insertion-order = LRU approximation) const objectUrlCache = new Map(); // Concurrency limiter for network fetches. // Each queue entry is a resolver that signals "slot acquired". let activeFetches = 0; const fetchQueue: Array<() => void> = []; /** * Acquires a fetch slot. Returns true if a slot was granted, false if the * provided AbortSignal fired while the call was waiting in the queue (in that * case no slot is held and the caller must NOT call releaseFetchSlot). */ function acquireFetchSlot(signal?: AbortSignal): Promise { if (signal?.aborted) return Promise.resolve(false); if (activeFetches < MAX_CONCURRENT_FETCHES) { activeFetches++; return Promise.resolve(true); } return new Promise(resolve => { const onGrant = () => { signal?.removeEventListener('abort', onAbort); resolve(true); }; const onAbort = () => { // Remove from queue without consuming a slot — no releaseFetchSlot needed. const idx = fetchQueue.indexOf(onGrant); if (idx !== -1) fetchQueue.splice(idx, 1); resolve(false); }; fetchQueue.push(onGrant); signal?.addEventListener('abort', onAbort, { once: true }); }); } function releaseFetchSlot(): void { activeFetches--; const next = fetchQueue.shift(); if (next) { activeFetches++; next(); } } function evictMemoryIfNeeded(): 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 dbPromise: Promise | null = null; function openDB(): Promise { if (db) return Promise.resolve(db); if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = e => { const database = (e.target as IDBOpenDBRequest).result; if (!database.objectStoreNames.contains(STORE_NAME)) { database.createObjectStore(STORE_NAME, { keyPath: 'key' }); } }; req.onsuccess = e => { db = (e.target as IDBOpenDBRequest).result; resolve(db!); }; req.onerror = () => reject(req.error); }); return dbPromise; } async function getBlob(key: string): Promise { try { const database = await openDB(); return new Promise(resolve => { const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(key); req.onsuccess = () => { const entry = req.result; resolve(entry && Date.now() - entry.timestamp < MAX_AGE_MS ? entry.blob : null); }; req.onerror = () => resolve(null); }); } catch { return null; } } /** Evicts oldest IDB entries until total blob size is below maxBytes. Fire-and-forget. */ async function evictDiskIfNeeded(maxBytes: number): Promise { try { const database = await openDB(); const entries: Array<{ key: string; timestamp: number; size: number }> = await new Promise(resolve => { const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll(); req.onsuccess = () => { resolve( (req.result ?? []).map((e: { key: string; timestamp: number; blob: Blob }) => ({ key: e.key, timestamp: e.timestamp, size: e.blob?.size ?? 0, })), ); }; req.onerror = () => resolve([]); }); let total = entries.reduce((acc, e) => acc + e.size, 0); if (total <= maxBytes) return; // Oldest first entries.sort((a, b) => a.timestamp - b.timestamp); const tx = database.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); for (const entry of entries) { if (total <= maxBytes) break; store.delete(entry.key); // Also purge from memory cache const objUrl = objectUrlCache.get(entry.key); if (objUrl) { URL.revokeObjectURL(objUrl); objectUrlCache.delete(entry.key); } total -= entry.size; } } catch { // Ignore } } async function putBlob(key: string, blob: Blob): Promise { try { const database = await openDB(); await new Promise(resolve => { const tx = database.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).put({ key, blob, timestamp: Date.now() }); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }); // Enforce disk limit after write (fire-and-forget) const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024; evictDiskIfNeeded(maxBytes); } catch { // Ignore write errors } } /** Returns the total size in bytes of all blobs stored in IndexedDB. */ export async function getImageCacheSize(): Promise { try { const database = await openDB(); return new Promise(resolve => { const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAll(); req.onsuccess = () => { const entries: Array<{ blob: Blob }> = req.result ?? []; resolve(entries.reduce((acc, e) => acc + (e.blob?.size ?? 0), 0)); }; req.onerror = () => resolve(0); }); } catch { return 0; } } /** Removes a single cache entry from both in-memory and IndexedDB caches. */ export async function invalidateCacheKey(cacheKey: string): Promise { const existing = objectUrlCache.get(cacheKey); if (existing) { URL.revokeObjectURL(existing); objectUrlCache.delete(cacheKey); } try { const database = await openDB(); await new Promise(resolve => { const tx = database.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).delete(cacheKey); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }); } catch { // Ignore } } /** * Invalidates all cached sizes for a given cover art entity (artist, radio, playlist, album). * Call this after uploading or deleting a cover image so the UI re-fetches from the server. */ export async function invalidateCoverArt(entityId: string): Promise { const serverId = useAuthStore.getState().getActiveServer()?.id ?? '_'; const sizes = [40, 64, 128, 200, 256, 300, 500, 2000]; await Promise.all(sizes.map(size => invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`))); } /** Clears all entries from IndexedDB and revokes all in-memory object URLs. */ export async function clearImageCache(): Promise { for (const url of objectUrlCache.values()) { URL.revokeObjectURL(url); } objectUrlCache.clear(); try { const database = await openDB(); await new Promise(resolve => { const tx = database.transaction(STORE_NAME, 'readwrite'); tx.objectStore(STORE_NAME).clear(); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); }); } catch { // Ignore } } /** * Returns a cached object URL for an image. * @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params). * @param cacheKey A stable key that identifies the image across sessions. * @param signal Optional AbortSignal — aborts queue-waiting and in-flight fetches * so navigating away does not leave zombie fetches draining I/O. */ export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise { if (!fetchUrl || signal?.aborted) return ''; // 1. In-memory hit (same session) const existing = objectUrlCache.get(cacheKey); if (existing) return existing; // 2. IndexedDB hit (persisted from previous session) const blob = await getBlob(cacheKey); if (signal?.aborted) return ''; if (blob) { const objUrl = URL.createObjectURL(blob); objectUrlCache.set(cacheKey, objUrl); evictMemoryIfNeeded(); return objUrl; } // 3. Network fetch with concurrency limit → store in IDB → return object URL. // acquireFetchSlot returns false (without holding a slot) when aborted in queue. const acquired = await acquireFetchSlot(signal); if (!acquired || signal?.aborted) { if (acquired) releaseFetchSlot(); return ''; } try { const resp = await fetch(fetchUrl); if (!resp.ok) return fetchUrl; const newBlob = await resp.blob(); if (signal?.aborted) return ''; putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction) const objUrl = URL.createObjectURL(newBlob); objectUrlCache.set(cacheKey, objUrl); evictMemoryIfNeeded(); return objUrl; } catch (e) { // AbortError → return '' (component is gone). Other errors → return raw URL. return e instanceof DOMException && e.name === 'AbortError' ? '' : fetchUrl; } finally { releaseFetchSlot(); } }