// IndexedDB image cache (30-day TTL, LRU). Split into focused submodules under // `imageCache/`; this file keeps the orchestration entry points and re-exports // the public surface so existing call sites import from `imageCache` unchanged. import { useAuthStore } from '../store/authStore'; import { COVER_ART_REGISTERED_SIZES } from './cover/coverArtRegisteredSizes'; import { STORE_NAME } from './imageCache/constants'; import { blobCache, inflightBlobGets, rememberBlob } from './imageCache/blobCache'; import { purgeUrlEntry, clearAllUrlEntries } from './imageCache/urlPool'; import { acquireNetFetchSlot, releaseNetFetchSlot } from './imageCache/netFetchScheduler'; import { openDB, getBlobFromIDB, putBlob, cancelScheduledEvict } from './imageCache/idbStore'; import { parseCoverCacheKey, probeSiblingCoverBlobInMemory, probeSiblingCoverBlobFromIDB, scheduleSiblingVersusNetworkRace, clearCoverState, } from './imageCache/coverSiblings'; export { acquireUrl, releaseUrl } from './imageCache/urlPool'; export { getImageCacheSize } from './imageCache/idbStore'; export { subscribeCoverUpgraded } from './imageCache/coverSiblings'; export async function invalidateCacheKey(cacheKey: string): Promise { blobCache.delete(cacheKey); purgeUrlEntry(cacheKey); inflightBlobGets.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 } } export async function invalidateCoverArt(entityId: string): Promise { const serverId = useAuthStore.getState().getActiveServer()?.id ?? '_'; await Promise.all( COVER_ART_REGISTERED_SIZES.map(size => invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`), ), ); } export async function clearImageCache(): Promise { cancelScheduledEvict(); blobCache.clear(); inflightBlobGets.clear(); clearCoverState(); clearAllUrlEntries(); 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 the cached Blob for an image, fetching it if necessary. Callers own * any object URL they create from the returned blob and must revoke it when * done — there is no shared URL pool. * * @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. * @param getPriority Called when waiting for a **network** slot (IndexedDB hits skip this queue). */ export async function getCachedBlob( fetchUrl: string, cacheKey: string, signal?: AbortSignal, getPriority?: () => number, ): Promise { if (!fetchUrl || signal?.aborted) return null; const memHit = blobCache.get(cacheKey); if (memHit) { rememberBlob(cacheKey, memHit); // refresh LRU position return memHit; } const existing = inflightBlobGets.get(cacheKey); if (existing) return existing; const run = (async () => { if (signal?.aborted) return null; const idbHit = await getBlobFromIDB(cacheKey); if (signal?.aborted) return null; if (idbHit) { rememberBlob(cacheKey, idbHit); return idbHit; } const parsedCover = parseCoverCacheKey(cacheKey); if (parsedCover && !signal?.aborted) { const provisional = probeSiblingCoverBlobInMemory(parsedCover.stem, parsedCover.size) ?? (await probeSiblingCoverBlobFromIDB(parsedCover.stem, parsedCover.size)); if (provisional && !signal?.aborted) { rememberBlob(cacheKey, provisional); scheduleSiblingVersusNetworkRace(fetchUrl, cacheKey, provisional, signal, getPriority); return provisional; } } const acquired = await acquireNetFetchSlot(signal, getPriority); if (!acquired || signal?.aborted) { if (acquired) releaseNetFetchSlot(); return null; } try { const resp = await fetch(fetchUrl, { signal }); if (!resp.ok) return null; const newBlob = await resp.blob(); if (signal?.aborted) return null; putBlob(cacheKey, newBlob); // fire-and-forget rememberBlob(cacheKey, newBlob); return newBlob; } catch { return null; } finally { releaseNetFetchSlot(); } })(); inflightBlobGets.set(cacheKey, run); run.finally(() => inflightBlobGets.delete(cacheKey)); return run; }