mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +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:
@@ -41,6 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* Filter state is ephemeral per page (not persisted) so users don't come back to a half-empty library and wonder where their content went.
|
||||
* Reads star state live from in-memory overrides — toggling a favourite from a context menu updates the visible list immediately, no refetch.
|
||||
|
||||
### Search — artist photos in live and mobile results
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#470](https://github.com/Psychotoxical/psysonic/pull/470)**
|
||||
|
||||
* **Live search** and the **mobile search overlay** now load **artist images** for rows in the **Artists** section via the same **`getCoverArt` / image-cache path** as album art (**`coverArt`** when present, otherwise the **artist id** where the server supports it), with a **fallback icon** when art is missing or fails.
|
||||
* **Mobile** artist hits use a **round** thumbnail next to **square** album art so the two result types read clearly at a glance.
|
||||
|
||||
## Changed
|
||||
|
||||
### Dependencies — npm / Cargo refresh and rodio 0.22
|
||||
@@ -66,6 +73,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **Virtualised lists:** **Albums**, **Artists** (list mode), and the **Tracks** virtual song browser **derive** **TanStack Virtual `overscan`** from the **measured scroll viewport** (~ **one screen** of extra rows above and below) instead of a tiny fixed cushion.
|
||||
* **Library & chrome:** assorted **scroll / layout** improvements on **Artist detail**, **Playlists**, **Most played**; smaller touch-ups to **Mini player**, **Live search**, **Album header**, and **dynamic colour** extraction used by player / album surfaces.
|
||||
|
||||
### Covers / image cache — parallel fetch + downscale, registry guard, search slot hints
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#470](https://github.com/Psychotoxical/psysonic/pull/470)**
|
||||
|
||||
* When a **pixel size** misses **disk** but **another size** of the same **`cover:id` is already cached**, **remote `getCoverArt`** and **client JPEG downscale** run in **parallel**; the **first good blob wins** and the other side **aborts** (network + downscale **signals**).
|
||||
* **Sibling key reads** use **one IndexedDB readonly transaction** instead of many separate transactions.
|
||||
* **`COVER_ART_REGISTERED_SIZES`** centralises known **`getCoverArt` widths** for **invalidation** and sibling lookup; **`coverArtRegisteredSizes.test.ts`** (**Vitest**) keeps **literal** `coverArtCacheKey(_, n)` call sites in **`src`** in sync with that list.
|
||||
* **`downscaleCoverBlob`** respects **AbortSignal** through **`canvas.toBlob`**.
|
||||
* **`CachedImage`:** **`fetchQueueBias`** gives **artist** search thumbs **higher network-slot priority** than **album** thumbs when the pool is saturated; **`observeRootMargin`** defaults **wider** so **priority** updates **earlier** ahead of the scroll viewport.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Hot cache, HTTP streaming replay, and queue source indicator
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
||||
import { acquireUrl, getCachedBlob, releaseUrl } from '../utils/imageCache';
|
||||
import { acquireUrl, getCachedBlob, releaseUrl, subscribeCoverUpgraded } from '../utils/imageCache';
|
||||
|
||||
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
cacheKey: string;
|
||||
/**
|
||||
* Added to the viewport-based score when waiting for a `getCachedBlob` **network** slot.
|
||||
* Use to order tiers (e.g. search: artist thumbs before album thumbs) without changing layout.
|
||||
*/
|
||||
fetchQueueBias?: number;
|
||||
/**
|
||||
* How far beyond the app scroll viewport `IntersectionObserver` expands the root.
|
||||
* Larger = priority / slot ordering updates while the row is still off-screen → less
|
||||
* empty flash when it hits the viewport. CSS margin syntax (`440px`, `10% 0`, …).
|
||||
*/
|
||||
observeRootMargin?: string;
|
||||
}
|
||||
|
||||
/** Search UI: load artist avatars before album covers when many requests compete. */
|
||||
export const FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM = 1_000_000_000;
|
||||
|
||||
/** Default IO lead — slightly before visible to reduce scroll-in jitter (tune per `CachedImage`). */
|
||||
export const DEFAULT_CACHED_IMAGE_PREPARE_MARGIN = '440px';
|
||||
|
||||
/**
|
||||
* Returns a shared, refcounted object URL for a cached image. Multiple
|
||||
* consumers of the same cacheKey see the exact same URL string, so the
|
||||
@@ -89,10 +106,36 @@ export function useCachedUrl(
|
||||
};
|
||||
}, [cacheKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchUrl || !fallbackToFetch) return;
|
||||
let cancelled = false;
|
||||
const unsub = subscribeCoverUpgraded(cacheKey, () => {
|
||||
if (cancelled) return;
|
||||
const refreshed = acquireUrl(cacheKey);
|
||||
if (refreshed) {
|
||||
ownedKeyRef.current = cacheKey;
|
||||
setResolved(refreshed);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsub();
|
||||
};
|
||||
}, [cacheKey, fetchUrl, fallbackToFetch]);
|
||||
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
|
||||
export default function CachedImage({
|
||||
src,
|
||||
cacheKey,
|
||||
fetchQueueBias = 0,
|
||||
observeRootMargin = DEFAULT_CACHED_IMAGE_PREPARE_MARGIN,
|
||||
style,
|
||||
onLoad,
|
||||
onError,
|
||||
...props
|
||||
}: CachedImageProps) {
|
||||
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
/**
|
||||
@@ -101,7 +144,10 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
||||
* (custom scroll roots, content-visibility, horizontal rails) and led to blank covers.
|
||||
*/
|
||||
const priorityRef = useRef(0);
|
||||
const getViewportImagePriority = useCallback(() => priorityRef.current, []);
|
||||
const getViewportImagePriority = useCallback(
|
||||
() => fetchQueueBias + priorityRef.current,
|
||||
[fetchQueueBias],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
@@ -128,13 +174,13 @@ export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...
|
||||
entries => { for (const e of entries) updateFromEntry(e); },
|
||||
{
|
||||
root: root ?? undefined,
|
||||
rootMargin: '300px',
|
||||
rootMargin: observeRootMargin,
|
||||
threshold: [0, 0.02, 0.1, 0.25, 0.5, 0.75, 1],
|
||||
},
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
}, [observeRootMargin]);
|
||||
|
||||
// Same as Hero/PlayerBar: show the salted fetch URL while IndexedDB/network resolves,
|
||||
// then swap to the shared blob URL — avoids an <img> with no src and opacity stuck at 0.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey, type SubsonicArtist } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
@@ -22,6 +22,26 @@ function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
|
||||
return <CachedImage className="search-result-thumb" src={src} cacheKey={cacheKey} alt="" />;
|
||||
}
|
||||
|
||||
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 40), [coverId]);
|
||||
const cacheKey = useMemo(() => coverArtCacheKey(coverId, 40), [coverId]);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
||||
return (
|
||||
<CachedImage
|
||||
className="search-result-thumb"
|
||||
src={src}
|
||||
cacheKey={cacheKey}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveSearch() {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -306,8 +326,10 @@ export default function LiveSearch() {
|
||||
openContextMenu(e.clientX, e.clientY, a, 'artist');
|
||||
}}
|
||||
role="option" aria-selected={activeIndex === i}>
|
||||
<div className="search-result-icon"><Users size={14} /></div>
|
||||
<span>{a.name}</span>
|
||||
<LiveSearchArtistThumb artist={a} />
|
||||
<div>
|
||||
<div className="search-result-name">{a.name}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
||||
import { search, SearchResults, buildCoverArtUrl, coverArtCacheKey, type SubsonicArtist } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CachedImage from './CachedImage';
|
||||
import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
const STORAGE_KEY = 'psysonic_recent_searches';
|
||||
@@ -27,6 +27,32 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
||||
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
|
||||
}
|
||||
|
||||
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const coverId = artist.coverArt || artist.id;
|
||||
const src = useMemo(() => buildCoverArtUrl(coverId, 80), [coverId]);
|
||||
const ck = useMemo(() => coverArtCacheKey(coverId, 80), [coverId]);
|
||||
useEffect(() => { setFailed(false); }, [coverId]);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CachedImage
|
||||
className="mobile-search-thumb mobile-search-thumb--artist-round"
|
||||
src={src}
|
||||
cacheKey={ck}
|
||||
alt=""
|
||||
loading="eager"
|
||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -188,9 +214,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
<div className="mobile-search-section-label">{t('search.artists')}</div>
|
||||
{results!.artists.map(a => (
|
||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
|
||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<MobileSearchArtistThumb artist={a} />
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{a.name}</span>
|
||||
<span className="mobile-search-item-sub">{t('search.artists')}</span>
|
||||
|
||||
@@ -8902,6 +8902,10 @@ html.no-compositing .fs-seekbar-played {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-search-thumb--artist-round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.mobile-search-avatar {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
|
||||
@@ -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