refactor(image-cache): split imageCache.ts into focused submodules (#684)

imageCache.ts keeps the orchestration entry points + re-exports; the
internals move into imageCache/ with an acyclic dependency graph:

- constants.ts          DB / cache size knobs
- blobCache.ts          in-memory LRU blob map + inflight reads
- urlPool.ts            refcounted shared object URLs
- netFetchScheduler.ts  priority-ordered network fetch slots
- idbStore.ts           IndexedDB open / read / write / evict
- coverSiblings.ts      cover-size sibling probing + upgrade race

Verbatim function-body moves. The only non-move changes are three
named wrappers (cancelScheduledEvict, clearAllUrlEntries, clearCoverState)
that let clearImageCache reach cross-module state — each is the original
lines wrapped in a function, behaviour identical. All seven importers
import from utils/imageCache unchanged.
This commit is contained in:
Frank Stellmacher
2026-05-14 13:12:43 +02:00
committed by GitHub
parent 7e99f030a7
commit 8c4309cc8e
7 changed files with 519 additions and 474 deletions
+21 -474
View File
@@ -1,321 +1,24 @@
// 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 './coverArtRegisteredSizes';
import { downscaleCoverBlob } from './coverBlobDownscale';
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';
const DB_NAME = 'psysonic-img-cache';
const STORE_NAME = 'images';
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/** In-memory blobs — scrolling large grids used to thrash at 200 and re-hit IndexedDB for “cold” keys that still had a live shared object URL. */
const MAX_BLOB_CACHE = 600; // hot in-memory blob entries (LRU)
/** Network-only pool — IndexedDB hits must not queue behind remote fetches. */
const MAX_CONCURRENT_NET_FETCHES = 6;
type LoadWaiter = {
getPriority: () => number;
resolve: (granted: boolean) => void;
};
const loadWaiters: LoadWaiter[] = [];
/** One in-flight read per logical image — avoids duplicate IndexedDB transactions when many cells mount together. */
const inflightBlobGets = new Map<string, Promise<Blob | null>>();
// In-memory blob cache: cacheKey → Blob (insertion-order = LRU approximation).
// Only the Map entry is dropped on overflow — the underlying Blob is freed by
// the GC once no <img>/<canvas>/object URL still references it.
const blobCache = new Map<string, Blob>();
// Refcounted object URLs shared across all consumers of the same cacheKey.
// Chromium/WebView2 keys its decoded-image cache by URL, so handing every
// <img> its own URL.createObjectURL forces a fresh decode for each instance —
// catastrophic on Windows even for tiny cover thumbnails. Sharing a single
// URL per cacheKey lets the renderer reuse the decoded bitmap.
const URL_REVOKE_DELAY_MS = 500;
type UrlEntry = { url: string; refs: number; revokeTimer: ReturnType<typeof setTimeout> | null };
const urlEntries = new Map<string, UrlEntry>();
function purgeUrlEntry(cacheKey: string): void {
const entry = urlEntries.get(cacheKey);
if (!entry) return;
if (entry.revokeTimer) clearTimeout(entry.revokeTimer);
URL.revokeObjectURL(entry.url);
urlEntries.delete(cacheKey);
}
/**
* Returns a shared object URL for the cached blob of `cacheKey`, or null if
* not currently in memory. Pair every successful call with releaseUrl().
* Subsequent acquires reuse the same URL and just bump the refcount.
*
* IMPORTANT: the Blob can be LRU-evicted from `blobCache` while `urlEntries`
* still holds a valid object URL (another `<img>` still references it). We
* must reuse that URL — otherwise callers fall through to IndexedDB / network
* again and scrolling janks even when data was already resolved once.
*/
export function acquireUrl(cacheKey: string): string | null {
const blob = blobCache.get(cacheKey);
if (blob) {
rememberBlob(cacheKey, blob); // refresh LRU position
}
const entry = urlEntries.get(cacheKey);
if (entry) {
if (entry.revokeTimer) {
clearTimeout(entry.revokeTimer);
entry.revokeTimer = null;
}
entry.refs++;
return entry.url;
}
if (!blob) return null;
const newEntry: UrlEntry = { url: URL.createObjectURL(blob), refs: 0, revokeTimer: null };
urlEntries.set(cacheKey, newEntry);
newEntry.refs++;
return newEntry.url;
}
/** Decrements the refcount; revokes (after grace delay) when it reaches zero. */
export function releaseUrl(cacheKey: string): void {
const entry = urlEntries.get(cacheKey);
if (!entry) return;
entry.refs--;
if (entry.refs > 0) return;
entry.revokeTimer = setTimeout(() => {
URL.revokeObjectURL(entry.url);
urlEntries.delete(cacheKey);
}, URL_REVOKE_DELAY_MS);
}
let activeNetFetches = 0;
function removeLoadWaiter(waiter: LoadWaiter): void {
const i = loadWaiters.indexOf(waiter);
if (i !== -1) loadWaiters.splice(i, 1);
}
/**
* Slot for remote `fetch` only. IndexedDB reads run before this — cached disk
* art can render without waiting on in-flight network downloads.
*/
function acquireNetFetchSlot(signal?: AbortSignal, getPriority?: () => number): Promise<boolean> {
if (signal?.aborted) return Promise.resolve(false);
if (activeNetFetches < MAX_CONCURRENT_NET_FETCHES) {
activeNetFetches++;
return Promise.resolve(true);
}
return new Promise<boolean>(resolve => {
let waiter: LoadWaiter;
const onAbort = () => {
signal?.removeEventListener('abort', onAbort);
removeLoadWaiter(waiter);
resolve(false);
};
waiter = {
getPriority: getPriority ?? (() => 0),
resolve: (granted: boolean) => {
signal?.removeEventListener('abort', onAbort);
resolve(granted);
},
};
loadWaiters.push(waiter);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function pickHighestPriorityWaiterIndex(): number {
if (loadWaiters.length === 0) return -1;
let best = 0;
let bestP = safePriority(loadWaiters[0].getPriority);
for (let i = 1; i < loadWaiters.length; i++) {
const p = safePriority(loadWaiters[i].getPriority);
if (p > bestP) {
bestP = p;
best = i;
}
}
return best;
}
function safePriority(fn: () => number): number {
try {
return fn();
} catch {
return 0;
}
}
function releaseNetFetchSlot(): void {
activeNetFetches = Math.max(0, activeNetFetches - 1);
if (activeNetFetches >= MAX_CONCURRENT_NET_FETCHES) return;
const idx = pickHighestPriorityWaiterIndex();
if (idx === -1) return;
const [w] = loadWaiters.splice(idx, 1);
activeNetFetches++;
w.resolve(true);
}
function rememberBlob(key: string, blob: Blob): void {
blobCache.delete(key); // re-insert at end → marks as recently used
blobCache.set(key, blob);
while (blobCache.size > MAX_BLOB_CACHE) {
const oldest = blobCache.keys().next().value;
if (!oldest) break;
blobCache.delete(oldest);
}
}
let db: IDBDatabase | null = null;
let dbPromise: Promise<IDBDatabase> | null = null;
function openDB(): Promise<IDBDatabase> {
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;
}
function entryBlobIfFresh(entry: { timestamp: number; blob: Blob } | undefined): Blob | null {
return entry && Date.now() - entry.timestamp < MAX_AGE_MS ? entry.blob : null;
}
async function getBlobFromIDB(key: string): Promise<Blob | null> {
try {
const database = await openDB();
return new Promise(resolve => {
const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(key);
req.onsuccess = () => resolve(entryBlobIfFresh(req.result));
req.onerror = () => resolve(null);
});
} catch {
return null;
}
}
/** Several `get`s in one read transaction — avoids N separate transactions when probing sibling covers. */
async function mapBlobsFromIDB(keys: readonly string[]): Promise<Map<string, Blob | null>> {
const map = new Map<string, Blob | null>();
for (const key of keys) map.set(key, null);
if (keys.length === 0) return map;
try {
const database = await openDB();
await new Promise<void>((resolve, reject) => {
const tx = database.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
let pending = keys.length;
tx.onerror = () => reject(tx.error ?? new Error('idb'));
tx.onabort = () => reject(new Error('idb abort'));
const step = (): void => {
pending--;
if (pending === 0) resolve();
};
for (const key of keys) {
const req = store.get(key);
req.onsuccess = () => {
map.set(key, entryBlobIfFresh(req.result));
step();
};
req.onerror = () => step();
}
});
} catch {
for (const key of keys) map.set(key, null);
}
return map;
}
async function evictDiskIfNeeded(maxBytes: number): Promise<void> {
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;
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);
blobCache.delete(entry.key);
total -= entry.size;
}
} catch {
// Ignore
}
}
/** Batched eviction — avoids `getAll()` on every cover write during fast scrolling. */
let evictDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let evictPendingMaxBytes = 0;
function scheduleEvictDiskIfNeeded(maxBytes: number): void {
evictPendingMaxBytes = maxBytes;
if (evictDebounceTimer) clearTimeout(evictDebounceTimer);
evictDebounceTimer = setTimeout(() => {
evictDebounceTimer = null;
void evictDiskIfNeeded(evictPendingMaxBytes);
}, 450);
}
async function putBlob(key: string, blob: Blob): Promise<void> {
try {
const database = await openDB();
await new Promise<void>(resolve => {
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put({ key, blob, timestamp: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
scheduleEvictDiskIfNeeded(maxBytes);
} catch {
// Ignore write errors
}
}
export async function getImageCacheSize(): Promise<number> {
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;
}
}
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);
@@ -334,158 +37,6 @@ export async function invalidateCacheKey(cacheKey: string): Promise<void> {
}
}
/** Prefer larger blobs as provisional placeholders — downscaled in `<img>` for sharpness. */
const COVER_ART_CACHE_SIZES_DESC = [...COVER_ART_REGISTERED_SIZES].sort((a, b) => b - a);
function parseCoverCacheKey(cacheKey: string): { stem: string; size: number } | null {
const colon = cacheKey.lastIndexOf(':');
if (colon <= 0) return null;
const tail = cacheKey.slice(colon + 1);
const size = Number(tail);
if (!Number.isFinite(size) || size <= 0) return null;
const stem = cacheKey.slice(0, colon);
if (!stem.includes(':cover:')) return null;
return { stem, size };
}
function probeSiblingCoverBlobInMemory(stem: string, excludedSize: number): Blob | null {
for (const sz of COVER_ART_CACHE_SIZES_DESC) {
if (sz === excludedSize) continue;
const b = blobCache.get(`${stem}:${sz}`);
if (b) return b;
}
return null;
}
async function probeSiblingCoverBlobFromIDB(stem: string, excludedSize: number): Promise<Blob | null> {
const keys = COVER_ART_CACHE_SIZES_DESC.filter(sz => sz !== excludedSize).map(sz => `${stem}:${sz}`);
if (keys.length === 0) return null;
const blobs = await mapBlobsFromIDB(keys);
for (const key of keys) {
const b = blobs.get(key);
if (b) return b;
}
return null;
}
const coverUpgradeListeners = new Map<string, Set<() => void>>();
const coverSiblingRaceInflights = new Map<string, Promise<void>>();
/** Abort when any of `outer` / `peer` fires (ES2022 `AbortSignal.any` not in our lib target). */
function mergedAbortSignals(outer: AbortSignal | undefined, peer: AbortSignal): AbortSignal {
if (!outer) return peer;
if (outer.aborted || peer.aborted) {
const c = new AbortController();
c.abort();
return c.signal;
}
const c = new AbortController();
const on = () => c.abort();
outer.addEventListener('abort', on, { once: true });
peer.addEventListener('abort', on, { once: true });
return c.signal;
}
function notifyCoverUpgraded(cacheKey: string): void {
purgeUrlEntry(cacheKey);
const listeners = coverUpgradeListeners.get(cacheKey);
if (!listeners) return;
for (const fn of [...listeners]) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** When the exact-resolution blob replaces a provisional sibling blob, repaint consumers. */
export function subscribeCoverUpgraded(cacheKey: string, onUpgrade: () => void): () => void {
let set = coverUpgradeListeners.get(cacheKey);
if (!set) {
set = new Set();
coverUpgradeListeners.set(cacheKey, set);
}
set.add(onUpgrade);
return () => {
const s = coverUpgradeListeners.get(cacheKey);
if (!s) return;
s.delete(onUpgrade);
if (s.size === 0) coverUpgradeListeners.delete(cacheKey);
};
}
/**
* Parallel resolve when we only have another size of the same cover in cache:
* small server request vs local downscale — first successful blob wins, other side aborts.
*/
function scheduleSiblingVersusNetworkRace(
fetchUrl: string,
cacheKey: string,
siblingBlob: Blob,
outerSignal: AbortSignal | undefined,
getPriority?: () => number,
): void {
if (coverSiblingRaceInflights.has(cacheKey)) return;
const parsed = parseCoverCacheKey(cacheKey);
if (!parsed) return;
const netCtl = new AbortController();
const dsCtl = new AbortController();
let winner = false;
const killLosers = () => {
netCtl.abort();
dsCtl.abort();
};
const tryCommitWinner = (blob: Blob | null) => {
if (!blob || winner || outerSignal?.aborted) return;
winner = true;
killLosers();
putBlob(cacheKey, blob);
rememberBlob(cacheKey, blob);
notifyCoverUpgraded(cacheKey);
};
outerSignal?.addEventListener('abort', () => killLosers(), { once: true });
const netBranch = (async () => {
if (winner || outerSignal?.aborted) return;
const waitSig = mergedAbortSignals(outerSignal, netCtl.signal);
const acquired = await acquireNetFetchSlot(waitSig, getPriority);
if (!acquired || winner || outerSignal?.aborted) {
if (acquired) releaseNetFetchSlot();
return;
}
try {
const fetchSig = mergedAbortSignals(outerSignal, netCtl.signal);
const resp = await fetch(fetchUrl, { signal: fetchSig });
if (!resp.ok || winner || outerSignal?.aborted) return;
const blob = await resp.blob();
tryCommitWinner(blob);
} catch {
/* fetch aborted / network */
} finally {
releaseNetFetchSlot();
}
})();
const clientBranch = (async () => {
await Promise.resolve();
if (winner || outerSignal?.aborted) return;
const dsSig = mergedAbortSignals(outerSignal, dsCtl.signal);
const out = await downscaleCoverBlob(siblingBlob, parsed.size, dsSig);
if (!out || winner || outerSignal?.aborted) return;
if (out.size >= siblingBlob.size * 0.92) return;
tryCommitWinner(out);
})();
const settled = Promise.allSettled([netBranch, clientBranch]).then(() => {});
coverSiblingRaceInflights.set(cacheKey, settled);
void settled.finally(() => coverSiblingRaceInflights.delete(cacheKey));
}
export async function invalidateCoverArt(entityId: string): Promise<void> {
const serverId = useAuthStore.getState().getActiveServer()?.id ?? '_';
await Promise.all(
@@ -496,15 +47,11 @@ export async function invalidateCoverArt(entityId: string): Promise<void> {
}
export async function clearImageCache(): Promise<void> {
if (evictDebounceTimer) {
clearTimeout(evictDebounceTimer);
evictDebounceTimer = null;
}
cancelScheduledEvict();
blobCache.clear();
inflightBlobGets.clear();
coverUpgradeListeners.clear();
coverSiblingRaceInflights.clear();
for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key);
clearCoverState();
clearAllUrlEntries();
try {
const database = await openDB();
await new Promise<void>(resolve => {