mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
7e99f030a7
commit
8c4309cc8e
+21
-474
@@ -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 => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MAX_BLOB_CACHE } from './constants';
|
||||
|
||||
/** One in-flight read per logical image — avoids duplicate IndexedDB transactions when many cells mount together. */
|
||||
export 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.
|
||||
export const blobCache = new Map<string, Blob>();
|
||||
|
||||
export 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const DB_NAME = 'psysonic-img-cache';
|
||||
export const STORE_NAME = 'images';
|
||||
export 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. */
|
||||
export const MAX_BLOB_CACHE = 600; // hot in-memory blob entries (LRU)
|
||||
/** Network-only pool — IndexedDB hits must not queue behind remote fetches. */
|
||||
export const MAX_CONCURRENT_NET_FETCHES = 6;
|
||||
export const URL_REVOKE_DELAY_MS = 500;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { COVER_ART_REGISTERED_SIZES } from '../coverArtRegisteredSizes';
|
||||
import { downscaleCoverBlob } from '../coverBlobDownscale';
|
||||
import { blobCache, rememberBlob } from './blobCache';
|
||||
import { purgeUrlEntry } from './urlPool';
|
||||
import { mapBlobsFromIDB, putBlob } from './idbStore';
|
||||
import { acquireNetFetchSlot, releaseNetFetchSlot } from './netFetchScheduler';
|
||||
|
||||
/** 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);
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export 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));
|
||||
}
|
||||
|
||||
/** Drop all cover-upgrade listeners and in-flight sibling races — used by clearImageCache. */
|
||||
export function clearCoverState(): void {
|
||||
coverUpgradeListeners.clear();
|
||||
coverSiblingRaceInflights.clear();
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { DB_NAME, STORE_NAME, MAX_AGE_MS } from './constants';
|
||||
import { blobCache } from './blobCache';
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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. */
|
||||
export 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);
|
||||
}
|
||||
|
||||
/** Cancels a pending debounced eviction — used by clearImageCache. */
|
||||
export function cancelScheduledEvict(): void {
|
||||
if (evictDebounceTimer) {
|
||||
clearTimeout(evictDebounceTimer);
|
||||
evictDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MAX_CONCURRENT_NET_FETCHES } from './constants';
|
||||
|
||||
type LoadWaiter = {
|
||||
getPriority: () => number;
|
||||
resolve: (granted: boolean) => void;
|
||||
};
|
||||
const loadWaiters: LoadWaiter[] = [];
|
||||
|
||||
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.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { blobCache, rememberBlob } from './blobCache';
|
||||
import { URL_REVOKE_DELAY_MS } from './constants';
|
||||
|
||||
// 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.
|
||||
type UrlEntry = { url: string; refs: number; revokeTimer: ReturnType<typeof setTimeout> | null };
|
||||
const urlEntries = new Map<string, UrlEntry>();
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
/** Purge every shared URL entry — used by clearImageCache. */
|
||||
export function clearAllUrlEntries(): void {
|
||||
for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key);
|
||||
}
|
||||
Reference in New Issue
Block a user