mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
@@ -0,0 +1,40 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'vitest';
|
||||
import { COVER_ART_REGISTERED_SIZES } from './coverArtRegisteredSizes';
|
||||
|
||||
const registry = new Set<number>(COVER_ART_REGISTERED_SIZES);
|
||||
|
||||
/** Matches `coverArtCacheKey(foo, 300)` literals (second argument numeric). */
|
||||
const COVER_KEY_SIZE_RX = /\bcoverArtCacheKey\([^)]*,\s*(\d+)/g;
|
||||
|
||||
function walkSrc(dir: string, files: string[]): void {
|
||||
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
if (ent.name === 'node_modules' || ent.name === 'dist') continue;
|
||||
walkSrc(p, files);
|
||||
} else if (/\.(tsx?|jsx?)$/.test(ent.name)) {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('COVER_ART_REGISTERED_SIZES', () => {
|
||||
it('includes every literal coverArtCacheKey size used under src/', () => {
|
||||
const root = join(process.cwd(), 'src');
|
||||
const files: string[] = [];
|
||||
walkSrc(root, files);
|
||||
const used = new Set<number>();
|
||||
for (const fp of files) {
|
||||
const s = readFileSync(fp, 'utf8');
|
||||
for (const m of s.matchAll(COVER_KEY_SIZE_RX)) {
|
||||
used.add(Number(m[1]));
|
||||
}
|
||||
}
|
||||
const missing = [...used].filter(n => !registry.has(n)).sort((a, b) => a - b);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`COVER_ART_REGISTERED_SIZES is missing: ${missing.join(', ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Every literal `coverArtCacheKey(_, size)` width used across the UI.
|
||||
* Used for IndexedDB sibling lookup, invalidateCoverArt, and the static test guard.
|
||||
*
|
||||
* When adding a new cover size anywhere in `src/**`, bump this tuple and rerun tests.
|
||||
*/
|
||||
export const COVER_ART_REGISTERED_SIZES = [
|
||||
40, 48, 64, 80, 96, 128, 200, 256, 300, 400, 500, 600, 800, 2000,
|
||||
] as const;
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Client-side thumbnail path when a larger cover blob is already in cache:
|
||||
* avoids a second HTTP getCoverArt for a smaller `size=` variant.
|
||||
*/
|
||||
|
||||
export async function downscaleCoverBlob(
|
||||
blob: Blob,
|
||||
maxPx: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Blob | null> {
|
||||
if (typeof document === 'undefined' || signal?.aborted || maxPx < 16) return null;
|
||||
|
||||
let bmp: ImageBitmap | undefined;
|
||||
try {
|
||||
bmp = await createImageBitmap(blob);
|
||||
if (signal?.aborted) return null;
|
||||
const w = bmp.width;
|
||||
const h = bmp.height;
|
||||
if (!w || !h || !Number.isFinite(w) || !Number.isFinite(h)) return null;
|
||||
|
||||
const maxDim = Math.max(w, h);
|
||||
/** Skip encode work when already suitable for tiny list thumbs. */
|
||||
if (maxDim <= maxPx * 1.02) return null;
|
||||
|
||||
const scale = maxPx / maxDim;
|
||||
const dw = Math.max(1, Math.round(w * scale));
|
||||
const dh = Math.max(1, Math.round(h * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = dw;
|
||||
canvas.height = dh;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(bmp, 0, 0, dw, dh);
|
||||
|
||||
if (signal?.aborted) return null;
|
||||
|
||||
return await new Promise<Blob | null>(resolve => {
|
||||
if (signal?.aborted) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const finish = (b: Blob | null) => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(signal?.aborted ? null : b);
|
||||
};
|
||||
const onAbort = () => finish(null);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
canvas.toBlob(b => finish(b ?? null), 'image/jpeg', 0.88);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
bmp?.close();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
+210
-6
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user