Files
Psychotoxical-psysonic/src/utils/imageCache.ts
T
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
2026-05-14 14:27:44 +02:00

141 lines
4.7 KiB
TypeScript

// 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<void> {
blobCache.delete(cacheKey);
purgeUrlEntry(cacheKey);
inflightBlobGets.delete(cacheKey);
try {
const database = await openDB();
await new Promise<void>(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<void> {
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<void> {
cancelScheduledEvict();
blobCache.clear();
inflightBlobGets.clear();
clearCoverState();
clearAllUrlEntries();
try {
const database = await openDB();
await new Promise<void>(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<Blob | null> {
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;
}