Feat/search improvements (#470)

* feat(covers): race sibling downscale vs fetch, search thumb priorities

Run getCoverArt and client downscale in parallel when another size of the
same cover is cached; first successful result wins and aborts the other path.
Await both branches so inflight bookkeeping does not detach early.

Extend the cover cache size roster so provisional siblings resolve for sizes
used in the UI (e.g. 400/600/800, 48/96).

CachedImage: fetchQueueBias for live/mobile search (artist thumbnails ahead of
albums in fetch-slot ordering); configurable observeRootMargin with a wider
default to prepare priority slightly before elements enter view.

Mobile search adds round artist-thumb styling; add shared cover blob downscale
helper.

* perf(image-cache): batch sibling IDB reads and guard cover size registry

Use one read transaction when probing IndexedDB for sibling cover keys.
Extract COVER_ART_REGISTERED_SIZES and add Vitest coverage so every literal
coverArtCacheKey(_, size) in src stays aligned with sibling invalidation.
Honor AbortSignal during JPEG encode in downscaleCoverBlob.
This commit is contained in:
cucadmuh
2026-05-06 01:26:30 +03:00
committed by GitHub
parent 072bef473f
commit c3d37546cf
9 changed files with 449 additions and 21 deletions
+210 -6
View File
@@ -1,4 +1,6 @@
import { useAuthStore } from '../store/authStore';
import { COVER_ART_REGISTERED_SIZES } from './coverArtRegisteredSizes';
import { downscaleCoverBlob } from './coverBlobDownscale';
const DB_NAME = 'psysonic-img-cache';
const STORE_NAME = 'images';
@@ -186,15 +188,16 @@ function openDB(): Promise<IDBDatabase> {
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 = () => {
const entry = req.result;
resolve(entry && Date.now() - entry.timestamp < MAX_AGE_MS ? entry.blob : null);
};
req.onsuccess = () => resolve(entryBlobIfFresh(req.result));
req.onerror = () => resolve(null);
});
} catch {
@@ -202,6 +205,38 @@ async function getBlobFromIDB(key: string): Promise<Blob | 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();
@@ -299,10 +334,165 @@ 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 ?? '_';
const sizes = [40, 64, 128, 200, 256, 300, 500, 2000];
await Promise.all(sizes.map(size => invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`)));
await Promise.all(
COVER_ART_REGISTERED_SIZES.map(size =>
invalidateCacheKey(`${serverId}:cover:${entityId}:${size}`),
),
);
}
export async function clearImageCache(): Promise<void> {
@@ -312,6 +502,8 @@ export async function clearImageCache(): Promise<void> {
}
blobCache.clear();
inflightBlobGets.clear();
coverUpgradeListeners.clear();
coverSiblingRaceInflights.clear();
for (const key of Array.from(urlEntries.keys())) purgeUrlEntry(key);
try {
const database = await openDB();
@@ -363,6 +555,18 @@ export async function getCachedBlob(
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();