mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(browse,cover,perf): lazy catalogs, cover pipeline, and Performance Probe (#890)
* fix(cover): per-server cache stats and cover pipeline perf probe Stop count_cached_cover_ids from borrowing sibling bucket counts so Settings progress no longer attributes one server's disk cache to another. Add cover pipeline queue stats (ui ensure queue, ui vs lib HTTP/WebP semaphores) to Performance Probe overlay, with clearer ui/lib labels. * fix(browse): stabilize in-page infinite scroll and cap cover memory caches Extract useInpageScrollSentinel for album grids and song lists so sentinel reconnects do not spam loadMore during scroll. Harden useAlbumBrowseData with sync loading refs, tighter root margin, and hasMore termination when dedupe adds nothing. Pause middle-priority cover work during SQL pagination and bound diskSrc/resolve/ensure tail maps on long cold-cache sessions. * refactor(browse): unify in-page infinite scroll hooks and sentinel UI Extract shared transport (viewport ref, async pagination guards, client slice) and InpageScrollSentinel so Albums, New Releases, Artists, and song lists use one pagination pattern instead of duplicated IntersectionObserver wiring. * fix(browse): prioritize album SQL pagination over cover ensures Pause the entire webview ensure pump during grid page fetches, resume after SQL settles, add cover-queue backpressure before load-more, and re-probe the sentinel when pagination finishes so cold-cache scroll does not stall. * fix(browse): unblock covers, SQL spawn_blocking, and pagination retry Pair grid-pagination hold begin/end on stale fetches, resume the ensure pump after SQL, retry load-more when the cover backlog drains while the sentinel stays visible, and run album browse SQL on spawn_blocking so Tokio stays responsive during library_advanced_search. * feat(browse): All Albums client-slice scroll on local index (Artists-style) Load the filtered catalog once from SQLite when the library index is ready, then grow the visible grid with useClientSliceInfiniteScroll instead of offset SQL pagination per scroll. Network-only servers keep page mode. * fix(browse): lazy local catalog chunks instead of full 50k SQL fetch All Albums slice mode now loads 200 albums first, shows the grid immediately, then appends catalog chunks in the background as the user scrolls. Avoids the blocking library_advanced_search that hung the app on large libraries. * fix(browse): keep album covers loading during active grid scroll Pass high ensure priority and the in-page scroll root to AlbumCard on All Albums, stop pausing cover traffic for background catalog chunks, and never trim high-priority ensure jobs from the queue during scroll bursts. * fix(cover): viewport priority tiers and unstick ensure invoke pump All Albums uses IO-driven high/middle instead of blanket high; release only on unmount so scroll-ahead jobs are not dropped on reprioritize. Ensure queue shares one Rust flight per cover id, attaches duplicate waiters without consuming invoke slots, and times out wedged calls. Warm the first viewport slice on large grids; acquire CPU permits before spawn_blocking in cover_cache to avoid blocking-thread deadlocks. * fix(cover): wire in-page scroll root on New Releases and Lossless grids AlbumCard IO uses the same viewport id as VirtualCardGrid so cover ensure priority tracks visible in-page rows like All Albums. * fix(browse): lazy local artist catalog in 200-row chunks Replace runLocalBrowseAllArtists bulk fetch with paginated local-index chunks so large libraries do not hang on open; preserve text search, starred, letter filter, and client-slice scroll behavior. * feat(perf): add RSS and thread CPU groups to Performance Probe Extend performance_cpu_snapshot with process RSS (psysonic + WebKit children) and in-process thread CPU breakdown. Classify tokio-rt-worker and tokio-* workers separately from glib, audio/pipewire, reqwest, and other misc threads (Linux /proc only). * feat(perf): redesign Performance Probe with tabs, pins, and overlay layout Split the probe into Monitor (live metric cards, per-metric overlay pins, corner and opacity controls) and Toggles (diagnostic tree). Share live polling via perfLiveStore; label analysis/cover pipeline blocks in the HUD. * feat(perf): overlay sparklines, macOS CPU/memory, and sync fixes Add 1-minute pinned-metric sparklines with right-aligned growth and a shared poll clock. Enable macOS performance snapshots via sysinfo. Fix overlay infinite loop from unstable history snapshots, bar/sparkline tick jitter, and probe bar rescale flicker. * docs: CHANGELOG and credits for PR #890 * perf(probe): scoped CPU poll, adjustable interval, lazy thread groups Read only psysonic + WebKit children instead of the full process table; macOS uses sysctl host CPU and refreshes cached child PIDs. Add 0.5–10s poll slider (default 2s). Collect /proc thread groups only when the Monitor section is open or a thread metric is pinned. * feat(perf): three-way overlay mode switch (off / FPS / pinned) Add Monitor control for overlay visibility: hidden, FPS-only, or pinned metrics from Monitor. Live CPU poll runs only in pinned mode with live pins.
This commit is contained in:
+71
-12
@@ -1,10 +1,11 @@
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { DEFAULT_CACHED_IMAGE_PREPARE_MARGIN } from '../components/CachedImage';
|
||||
import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot';
|
||||
import { coverEnsureBump } from './ensureQueue';
|
||||
import { coverEnsureQueued, coverEnsureReprioritize } from './ensureQueue';
|
||||
import { coverPrefetchBumpPriority } from './prefetchRegistry';
|
||||
import { coverServerReachable } from './reachability';
|
||||
import { coverStorageKeyFromRef } from './storageKeys';
|
||||
import { resolveCoverDisplayTier } from './tiers';
|
||||
import { coverImgSrc } from './imgSrc';
|
||||
@@ -38,9 +39,12 @@ export function CoverArtImage({
|
||||
onError: restOnError,
|
||||
...rest
|
||||
}: CoverArtImageProps) {
|
||||
const pinnedHigh = ensurePriorityProp === 'high';
|
||||
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
|
||||
ensurePriorityProp ?? 'middle',
|
||||
);
|
||||
const [seenViewport, setSeenViewport] = useState(false);
|
||||
const seenViewportRef = useRef(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [imgLoadFailed, setImgLoadFailed] = useState(false);
|
||||
|
||||
@@ -48,11 +52,15 @@ export function CoverArtImage({
|
||||
if (ensurePriorityProp) setEnsurePriority(ensurePriorityProp);
|
||||
}, [ensurePriorityProp]);
|
||||
|
||||
useEffect(() => {
|
||||
seenViewportRef.current = seenViewport;
|
||||
}, [seenViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
setImgLoadFailed(false);
|
||||
}, [coverRef.cacheEntityId, coverRef.cacheKind, coverRef.fetchCoverArtId, displayCssPx, surface, fullRes]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return;
|
||||
|
||||
@@ -63,15 +71,37 @@ export function CoverArtImage({
|
||||
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface, fullRes });
|
||||
const storageKey = coverStorageKeyFromRef(coverRef, tier);
|
||||
const reachable = coverServerReachable(coverRef.serverScope);
|
||||
|
||||
const queueEnsure = (priority: CoverPrefetchPriority) => {
|
||||
if (!reachable) return;
|
||||
void coverEnsureQueued(storageKey, coverRef, tier, priority);
|
||||
};
|
||||
|
||||
const applyIntersecting = () => {
|
||||
seenViewportRef.current = true;
|
||||
setSeenViewport(true);
|
||||
setEnsurePriority('high');
|
||||
coverPrefetchBumpPriority(coverRef, 'high');
|
||||
coverEnsureReprioritize(storageKey, 'high');
|
||||
queueEnsure('high');
|
||||
};
|
||||
|
||||
const applyLeftViewport = () => {
|
||||
if (!seenViewportRef.current || pinnedHigh) return;
|
||||
setEnsurePriority('middle');
|
||||
coverEnsureReprioritize(storageKey, 'middle');
|
||||
queueEnsure('middle');
|
||||
};
|
||||
|
||||
const applyEntry = (entry: IntersectionObserverEntry) => {
|
||||
if (entry.isIntersecting) applyIntersecting();
|
||||
else applyLeftViewport();
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setEnsurePriority('high');
|
||||
coverPrefetchBumpPriority(coverRef, 'high');
|
||||
coverEnsureBump(storageKey, 'high');
|
||||
}
|
||||
}
|
||||
for (const entry of entries) applyEntry(entry);
|
||||
},
|
||||
{
|
||||
root: root ?? undefined,
|
||||
@@ -80,13 +110,42 @@ export function CoverArtImage({
|
||||
},
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [coverRef, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]);
|
||||
|
||||
const drainRecords = () => {
|
||||
for (const entry of observer.takeRecords()) applyEntry(entry);
|
||||
};
|
||||
drainRecords();
|
||||
|
||||
let cancelled = false;
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
drainRecords();
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
drainRecords();
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(raf1);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [
|
||||
coverRef,
|
||||
displayCssPx,
|
||||
surface,
|
||||
fullRes,
|
||||
observeRootMargin,
|
||||
observeScrollRootId,
|
||||
pinnedHigh,
|
||||
]);
|
||||
|
||||
const { src, provisional, onImgError } = useCoverArt(coverRef, displayCssPx, {
|
||||
surface,
|
||||
fullRes,
|
||||
ensurePriority,
|
||||
seenViewport,
|
||||
alt,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ import { libraryCoverBackfillSetUiPriority } from '../api/coverCache';
|
||||
import {
|
||||
__test_resetCoverTraffic,
|
||||
coverTrafficBackgroundPaused,
|
||||
coverTrafficBeginGridPagination,
|
||||
coverTrafficBeginNavigation,
|
||||
coverTrafficEndGridPagination,
|
||||
coverTrafficEndNavigation,
|
||||
coverTrafficGridPaginationDepth,
|
||||
} from './coverTraffic';
|
||||
|
||||
describe('coverTraffic navigation hold', () => {
|
||||
@@ -40,3 +43,27 @@ describe('coverTraffic navigation hold', () => {
|
||||
expect(coverTrafficBackgroundPaused()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverTraffic grid pagination hold', () => {
|
||||
beforeEach(() => {
|
||||
__test_resetCoverTraffic();
|
||||
});
|
||||
|
||||
it('pauses middle/low cover work while album pages fetch', () => {
|
||||
coverTrafficBeginGridPagination();
|
||||
expect(coverTrafficBackgroundPaused()).toBe(true);
|
||||
expect(coverTrafficGridPaginationDepth()).toBe(1);
|
||||
coverTrafficEndGridPagination();
|
||||
expect(coverTrafficBackgroundPaused()).toBe(false);
|
||||
expect(coverTrafficGridPaginationDepth()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not leak hold depth when overlapping browse fetches end out of order', () => {
|
||||
coverTrafficBeginGridPagination();
|
||||
coverTrafficBeginGridPagination();
|
||||
coverTrafficEndGridPagination();
|
||||
expect(coverTrafficGridPaginationDepth()).toBe(1);
|
||||
coverTrafficEndGridPagination();
|
||||
expect(coverTrafficGridPaginationDepth()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ function cancelVisibleCoverWork(): void {
|
||||
}
|
||||
|
||||
let navigationHoldDepth = 0;
|
||||
/** Album grid SQL page fetch — pause middle/low cover work until rows settle. */
|
||||
let gridPaginationHoldDepth = 0;
|
||||
let serverSwitchHold = false;
|
||||
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let serverSwitchEndTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -48,6 +50,15 @@ export function coverTrafficEndNavigation(): void {
|
||||
scheduleNavigationResume();
|
||||
}
|
||||
|
||||
/** Local album browse page fetch — avoid middle-priority cover storms during SQL pagination. */
|
||||
export function coverTrafficBeginGridPagination(): void {
|
||||
gridPaginationHoldDepth += 1;
|
||||
}
|
||||
|
||||
export function coverTrafficEndGridPagination(): void {
|
||||
gridPaginationHoldDepth = Math.max(0, gridPaginationHoldDepth - 1);
|
||||
}
|
||||
|
||||
/** Active server change — stop all cover IPC so ping + menu stay responsive. */
|
||||
export function coverTrafficBeginServerSwitch(): void {
|
||||
serverSwitchHold = true;
|
||||
@@ -70,7 +81,12 @@ export function coverTrafficEndServerSwitch(): void {
|
||||
}
|
||||
|
||||
export function coverTrafficBackgroundPaused(): boolean {
|
||||
return navigationHoldDepth > 0 || serverSwitchHold;
|
||||
return navigationHoldDepth > 0 || gridPaginationHoldDepth > 0 || serverSwitchHold;
|
||||
}
|
||||
|
||||
/** @internal Diagnostics / tests — album grid SQL hold depth. */
|
||||
export function coverTrafficGridPaginationDepth(): number {
|
||||
return gridPaginationHoldDepth;
|
||||
}
|
||||
|
||||
/** Hard stop for ensure/peek pumps (includes visible `high` grid jobs). */
|
||||
@@ -89,6 +105,7 @@ function scheduleNavigationResume(): void {
|
||||
/** Test-only — reset module hold state between cases. */
|
||||
export function __test_resetCoverTraffic(): void {
|
||||
navigationHoldDepth = 0;
|
||||
gridPaginationHoldDepth = 0;
|
||||
serverSwitchHold = false;
|
||||
if (resumeTimer) clearTimeout(resumeTimer);
|
||||
if (serverSwitchEndTimer) clearTimeout(serverSwitchEndTimer);
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { CoverServerScope } from './types';
|
||||
|
||||
/** Stable asset URLs for disk `.webp` tiers — survives route unmount. */
|
||||
const diskSrcByStorageKey = new Map<string, string>();
|
||||
/** Bound webview memory when scrolling large grids with a cold cover cache. */
|
||||
const MAX_DISK_SRC_CACHE_ENTRIES = 4096;
|
||||
|
||||
let cacheGeneration = 0;
|
||||
const cacheListeners = new Set<() => void>();
|
||||
@@ -93,13 +95,22 @@ export function rememberDiskSrc(storageKey: string, fsPath: string): string {
|
||||
if (!src) return '';
|
||||
const prev = diskSrcByStorageKey.get(storageKey);
|
||||
if (prev === src) return src;
|
||||
if (diskSrcByStorageKey.size >= MAX_DISK_SRC_CACHE_ENTRIES) {
|
||||
const oldest = diskSrcByStorageKey.keys().next().value;
|
||||
if (oldest !== undefined) diskSrcByStorageKey.delete(oldest);
|
||||
}
|
||||
diskSrcByStorageKey.set(storageKey, src);
|
||||
bumpDiskSrcCache();
|
||||
return src;
|
||||
}
|
||||
|
||||
export function getDiskSrc(storageKey: string): string {
|
||||
return diskSrcByStorageKey.get(storageKey) ?? '';
|
||||
const src = diskSrcByStorageKey.get(storageKey) ?? '';
|
||||
if (src && diskSrcByStorageKey.has(storageKey)) {
|
||||
diskSrcByStorageKey.delete(storageKey);
|
||||
diskSrcByStorageKey.set(storageKey, src);
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
export function forgetDiskSrc(storageKey: string): void {
|
||||
|
||||
@@ -16,19 +16,32 @@ vi.mock('../api/coverCache', () => ({
|
||||
}));
|
||||
|
||||
import { coverArtRef } from './ref';
|
||||
import { coverTrafficBeginServerSwitch, coverTrafficEndServerSwitch } from './coverTraffic';
|
||||
import {
|
||||
__test_resetCoverTraffic,
|
||||
coverTrafficBeginServerSwitch,
|
||||
coverTrafficEndServerSwitch,
|
||||
} from './coverTraffic';
|
||||
import {
|
||||
__test_queuedCoverIds,
|
||||
__test_resetCoverEnsureQueue,
|
||||
coverEnsureBump,
|
||||
coverEnsureQueued,
|
||||
coverEnsureRelease,
|
||||
coverEnsureReprioritize,
|
||||
coverEnsureQueueStats,
|
||||
} from './ensureQueue';
|
||||
|
||||
describe('coverEnsureQueued', () => {
|
||||
beforeEach(() => {
|
||||
__test_resetCoverEnsureQueue();
|
||||
__test_resetCoverTraffic();
|
||||
ensureImpl.mockClear();
|
||||
ensureImpl.mockImplementation(
|
||||
async (ref: { fetchCoverArtId: string }, _tier: number, _priority: string) => {
|
||||
await new Promise(r => setTimeout(r, 2));
|
||||
return { hit: true, path: `/tmp/${ref.fetchCoverArtId}.webp`, tier: 128 };
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('dedupes concurrent ensures for the same storage key', async () => {
|
||||
@@ -57,6 +70,45 @@ describe('coverEnsureQueued', () => {
|
||||
coverTrafficEndServerSwitch();
|
||||
});
|
||||
|
||||
it('reprioritize downgrades viewport-leavers to middle with LIFO order', () => {
|
||||
coverTrafficBeginServerSwitch();
|
||||
const refNear = coverArtRef('al-near');
|
||||
const refFar = coverArtRef('al-far');
|
||||
|
||||
void coverEnsureQueued('s:cover:al-near:128', refNear, 128, 'high');
|
||||
void coverEnsureQueued('s:cover:al-far:128', refFar, 128, 'high');
|
||||
coverEnsureReprioritize('s:cover:al-near:128', 'middle');
|
||||
coverEnsureReprioritize('s:cover:al-far:128', 'middle');
|
||||
|
||||
const stats = coverEnsureQueueStats();
|
||||
expect(stats.queuedHigh).toBe(0);
|
||||
expect(stats.queuedMiddle).toBe(2);
|
||||
expect(__test_queuedCoverIds()[0]).toBe('al-far');
|
||||
coverTrafficEndServerSwitch();
|
||||
});
|
||||
|
||||
it('shares one invoke slot per cover art id while duplicate jobs wait', async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
ensureImpl.mockImplementationOnce(async () => {
|
||||
await gate;
|
||||
return { hit: true, path: '/tmp/al-shared.webp', tier: 128 };
|
||||
});
|
||||
|
||||
const ref = coverArtRef('al-shared');
|
||||
void coverEnsureQueued('s:cover:al-shared:128', ref, 128, 'high');
|
||||
void coverEnsureQueued('s:cover:al-shared:256', ref, 256, 'high');
|
||||
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
expect(ensureImpl).toHaveBeenCalledTimes(1);
|
||||
expect(coverEnsureQueueStats().inflight).toBe(1);
|
||||
|
||||
release();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
});
|
||||
|
||||
it('release drops a pending job so a remount can re-queue', async () => {
|
||||
coverTrafficBeginServerSwitch();
|
||||
const ref = coverArtRef('al-drop');
|
||||
|
||||
+162
-33
@@ -24,11 +24,87 @@ export const COVER_ENSURE_MAX_INFLIGHT = 10;
|
||||
const MAX_INFLIGHT = COVER_ENSURE_MAX_INFLIGHT;
|
||||
/** Drop stale scroll-ahead work so the queue cannot grow without bound. */
|
||||
const MAX_QUEUE = 96;
|
||||
/** Abort wedged Rust ensures so invoke slots cannot stall the grid forever. */
|
||||
const ENSURE_INVOKE_TIMEOUT_MS = 45_000;
|
||||
|
||||
let inflight = 0;
|
||||
let queue: EnsureJob[] = [];
|
||||
let nextOrderKey = 0;
|
||||
const inflightStorageKeys = new Set<string>();
|
||||
const backlogDrainListeners = new Set<() => void>();
|
||||
|
||||
type EnsureInvokeResult = { hit: boolean; path: string };
|
||||
|
||||
function coverInflightKey(ref: CoverArtRef): string {
|
||||
return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`;
|
||||
}
|
||||
|
||||
/** One active Rust ensure per cover art id — waiters attach without consuming invoke slots. */
|
||||
const coverArtInFlight = new Map<string, Promise<EnsureInvokeResult>>();
|
||||
|
||||
function withEnsureTimeout(
|
||||
promise: Promise<EnsureInvokeResult>,
|
||||
): Promise<EnsureInvokeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
reject(new Error('cover ensure invoke timeout'));
|
||||
}, ENSURE_INVOKE_TIMEOUT_MS);
|
||||
void promise.then(
|
||||
value => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
error => {
|
||||
window.clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function invokeEnsureForCover(
|
||||
ref: CoverArtRef,
|
||||
tier: CoverArtTier,
|
||||
priority: CoverPrefetchPriority,
|
||||
): Promise<EnsureInvokeResult> {
|
||||
const key = coverInflightKey(ref);
|
||||
const existing = coverArtInFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const flight = withEnsureTimeout(
|
||||
coverCacheEnsure(ref, tier, priority).then(r => ({ hit: r.hit, path: r.path })),
|
||||
).finally(() => {
|
||||
if (coverArtInFlight.get(key) === flight) coverArtInFlight.delete(key);
|
||||
});
|
||||
coverArtInFlight.set(key, flight);
|
||||
return flight;
|
||||
}
|
||||
|
||||
function settleJob(job: EnsureJob, result: EnsureInvokeResult): void {
|
||||
job.resolve(result);
|
||||
}
|
||||
|
||||
function attachQueuedJobsToActiveFlights(): void {
|
||||
let i = 0;
|
||||
while (i < queue.length) {
|
||||
const job = queue[i]!;
|
||||
const flight = coverArtInFlight.get(coverInflightKey(job.ref));
|
||||
if (!flight) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
queue.splice(i, 1);
|
||||
void flight
|
||||
.then(r => settleJob(job, r))
|
||||
.catch(() => settleJob(job, { hit: false, path: '' }));
|
||||
}
|
||||
}
|
||||
|
||||
function notifyBacklogDrain(): void {
|
||||
for (const listener of backlogDrainListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function priorityRank(p: CoverPrefetchPriority): number {
|
||||
return p === 'high' ? 0 : p === 'middle' ? 1 : 2;
|
||||
@@ -44,41 +120,28 @@ function sortQueue(): void {
|
||||
|
||||
function trimQueue(): void {
|
||||
while (queue.length > MAX_QUEUE) {
|
||||
let worstIdx = 0;
|
||||
for (let i = 1; i < queue.length; i += 1) {
|
||||
const a = queue[worstIdx]!;
|
||||
const b = queue[i]!;
|
||||
const rankA = priorityRank(a.priority);
|
||||
const rankB = priorityRank(b.priority);
|
||||
if (rankB > rankA || (rankB === rankA && b.orderKey < a.orderKey)) {
|
||||
let worstIdx = -1;
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
const job = queue[i]!;
|
||||
if (job.priority === 'high') continue;
|
||||
if (worstIdx < 0) {
|
||||
worstIdx = i;
|
||||
continue;
|
||||
}
|
||||
const worst = queue[worstIdx]!;
|
||||
const rank = priorityRank(job.priority);
|
||||
const worstRank = priorityRank(worst.priority);
|
||||
if (rank > worstRank || (rank === worstRank && job.orderKey < worst.orderKey)) {
|
||||
worstIdx = i;
|
||||
}
|
||||
}
|
||||
if (worstIdx < 0) break;
|
||||
const [job] = queue.splice(worstIdx, 1);
|
||||
job.resolve({ hit: false, path: '' });
|
||||
ensureInflight.delete(job.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
function coverInflightKey(ref: CoverArtRef): string {
|
||||
return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`;
|
||||
}
|
||||
|
||||
/** Serialize ensures per cover ID so we do not re-download for every tier. */
|
||||
const coverDownloadTail = new Map<string, Promise<unknown>>();
|
||||
|
||||
function ensureForCover(
|
||||
ref: CoverArtRef,
|
||||
tier: CoverArtTier,
|
||||
priority: CoverPrefetchPriority,
|
||||
) {
|
||||
const key = coverInflightKey(ref);
|
||||
const tail = coverDownloadTail.get(key) ?? Promise.resolve();
|
||||
const run = tail.then(() => coverCacheEnsure(ref, tier, priority));
|
||||
coverDownloadTail.set(key, run.catch(() => {}));
|
||||
return run;
|
||||
}
|
||||
|
||||
function findQueuedJob(storageKey: string): EnsureJob | undefined {
|
||||
return queue.find(j => j.storageKey === storageKey);
|
||||
}
|
||||
@@ -93,21 +156,32 @@ function bumpJob(job: EnsureJob, priority?: CoverPrefetchPriority): void {
|
||||
|
||||
function pump(): void {
|
||||
if (coverTrafficServerSwitchPaused()) return;
|
||||
attachQueuedJobsToActiveFlights();
|
||||
|
||||
while (inflight < MAX_INFLIGHT && queue.length > 0) {
|
||||
const next = queue[0]!;
|
||||
if (coverTrafficBackgroundPaused() && next.priority !== 'high') {
|
||||
let pickIdx = -1;
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
const candidate = queue[i]!;
|
||||
if (coverTrafficBackgroundPaused() && candidate.priority !== 'high') {
|
||||
break;
|
||||
}
|
||||
if (coverArtInFlight.has(coverInflightKey(candidate.ref))) continue;
|
||||
pickIdx = i;
|
||||
break;
|
||||
}
|
||||
const job = queue.shift()!;
|
||||
if (pickIdx < 0) break;
|
||||
|
||||
const job = queue.splice(pickIdx, 1)[0]!;
|
||||
inflight += 1;
|
||||
inflightStorageKeys.add(job.storageKey);
|
||||
void ensureForCover(job.ref, job.tier, job.priority)
|
||||
.then(r => job.resolve({ hit: r.hit, path: r.path }))
|
||||
.catch(() => job.resolve({ hit: false, path: '' }))
|
||||
void invokeEnsureForCover(job.ref, job.tier, job.priority)
|
||||
.then(r => settleJob(job, r))
|
||||
.catch(() => settleJob(job, { hit: false, path: '' }))
|
||||
.finally(() => {
|
||||
inflight -= 1;
|
||||
inflightStorageKeys.delete(job.storageKey);
|
||||
pump();
|
||||
notifyBacklogDrain();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -125,6 +199,19 @@ export function coverEnsureBump(
|
||||
pump();
|
||||
}
|
||||
|
||||
/** Set queued priority (upgrade or downgrade) and bump order for LIFO within the tier. */
|
||||
export function coverEnsureReprioritize(
|
||||
storageKey: string,
|
||||
priority: CoverPrefetchPriority,
|
||||
): void {
|
||||
const job = findQueuedJob(storageKey);
|
||||
if (!job) return;
|
||||
job.priority = priority;
|
||||
job.orderKey = ++nextOrderKey;
|
||||
sortQueue();
|
||||
pump();
|
||||
}
|
||||
|
||||
/** Drop queued ensures (route/server change) — in-flight jobs finish on their own. */
|
||||
export function coverEnsureCancelPending(): void {
|
||||
const dropped = queue;
|
||||
@@ -147,11 +234,52 @@ export function coverEnsureRelease(storageKey: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume ensure pump after a grid-pagination hold ends. */
|
||||
export function coverEnsureResumePump(): void {
|
||||
pump();
|
||||
notifyBacklogDrain();
|
||||
}
|
||||
|
||||
/** Retry pagination / prefetch when the ensure backlog drops (sentinel may still be visible). */
|
||||
export function coverEnsureSubscribeBacklogDrain(listener: () => void): () => void {
|
||||
backlogDrainListeners.add(listener);
|
||||
return () => {
|
||||
backlogDrainListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Queued + active ensure jobs (for library backfill watermark). */
|
||||
export function coverEnsureQueueBacklog(): number {
|
||||
return queue.length + inflight;
|
||||
}
|
||||
|
||||
export type CoverEnsureQueueStats = {
|
||||
queuedHigh: number;
|
||||
queuedMiddle: number;
|
||||
queuedLow: number;
|
||||
inflight: number;
|
||||
maxInflight: number;
|
||||
};
|
||||
|
||||
/** Webview ensure queue — tier breakdown for perf probe overlay. */
|
||||
export function coverEnsureQueueStats(): CoverEnsureQueueStats {
|
||||
let queuedHigh = 0;
|
||||
let queuedMiddle = 0;
|
||||
let queuedLow = 0;
|
||||
for (const job of queue) {
|
||||
if (job.priority === 'high') queuedHigh += 1;
|
||||
else if (job.priority === 'middle') queuedMiddle += 1;
|
||||
else queuedLow += 1;
|
||||
}
|
||||
return {
|
||||
queuedHigh,
|
||||
queuedMiddle,
|
||||
queuedLow,
|
||||
inflight,
|
||||
maxInflight: MAX_INFLIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal Vitest-only — module singleton queue. */
|
||||
export function __test_resetCoverEnsureQueue(): void {
|
||||
queue = [];
|
||||
@@ -159,7 +287,8 @@ export function __test_resetCoverEnsureQueue(): void {
|
||||
nextOrderKey = 0;
|
||||
inflightStorageKeys.clear();
|
||||
ensureInflight.clear();
|
||||
coverDownloadTail.clear();
|
||||
coverArtInFlight.clear();
|
||||
backlogDrainListeners.clear();
|
||||
}
|
||||
|
||||
/** @internal Vitest-only — queued cover art IDs front-to-back. */
|
||||
|
||||
@@ -114,3 +114,13 @@ export function coverPeekCancelPending(): void {
|
||||
inflight.delete(job.storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
export type CoverPeekQueueStats = {
|
||||
pending: number;
|
||||
inflight: number;
|
||||
};
|
||||
|
||||
/** Batched disk peek backlog — perf probe overlay. */
|
||||
export function coverPeekQueueStats(): CoverPeekQueueStats {
|
||||
return { pending: pending.size, inflight: inflight.size };
|
||||
}
|
||||
|
||||
@@ -54,6 +54,15 @@ function libraryResolveCacheKey(
|
||||
|
||||
const resolvedEntryCache = new Map<string, CoverEntry | null>();
|
||||
const inflightResolves = new Map<string, Promise<CoverEntry | null>>();
|
||||
const MAX_RESOLVED_ENTRY_CACHE = 4096;
|
||||
|
||||
function trimResolvedEntryCache(): void {
|
||||
while (resolvedEntryCache.size > MAX_RESOLVED_ENTRY_CACHE) {
|
||||
const oldest = resolvedEntryCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
resolvedEntryCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
const LIBRARY_RESOLVE_MAX_INFLIGHT = 4;
|
||||
let libraryResolveActive = 0;
|
||||
@@ -99,9 +108,11 @@ export async function libraryResolveCoverEntry(
|
||||
});
|
||||
const entry = dto ? dtoToEntry(dto) : null;
|
||||
resolvedEntryCache.set(key, entry);
|
||||
trimResolvedEntryCache();
|
||||
return entry;
|
||||
} catch {
|
||||
resolvedEntryCache.set(key, null);
|
||||
trimResolvedEntryCache();
|
||||
return null;
|
||||
} finally {
|
||||
inflightResolves.delete(key);
|
||||
|
||||
@@ -31,6 +31,8 @@ export function useCoverArt(
|
||||
observeRootMargin?: string;
|
||||
alt?: string;
|
||||
ensurePriority?: CoverPrefetchPriority;
|
||||
/** Dense grid: true after first viewport intersection — allows middle-tier scroll-ahead. */
|
||||
seenViewport?: boolean;
|
||||
},
|
||||
): CoverArtHandle {
|
||||
const ref = coverRef ?? null;
|
||||
@@ -55,7 +57,9 @@ export function useCoverArt(
|
||||
|
||||
const ensurePriority: CoverPrefetchPriority = opts?.ensurePriority ?? 'middle';
|
||||
|
||||
const deferEnsureUntilVisible = surface === 'dense' && ensurePriority !== 'high';
|
||||
const seenViewport = opts?.seenViewport ?? false;
|
||||
const deferEnsureUntilVisible =
|
||||
surface === 'dense' && !seenViewport && ensurePriority !== 'high';
|
||||
|
||||
const readCachedSrc = useCallback(() => {
|
||||
if (!ref) return '';
|
||||
@@ -83,9 +87,9 @@ export function useCoverArt(
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
const peekHit = await coverPeekQueued(storageKey, ref, tier);
|
||||
await coverPeekQueued(storageKey, ref, tier);
|
||||
if (cancelled) return;
|
||||
if (peekHit || readCachedSrc()) return;
|
||||
if (readCachedSrc()) return;
|
||||
|
||||
if (reachable && !deferEnsureUntilVisible) {
|
||||
const result = await coverEnsureQueued(storageKey, ref, tier, ensurePriority);
|
||||
@@ -103,7 +107,6 @@ export function useCoverArt(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubDisk();
|
||||
coverEnsureRelease(storageKey);
|
||||
};
|
||||
}, [
|
||||
ref,
|
||||
@@ -116,6 +119,11 @@ export function useCoverArt(
|
||||
readCachedSrc,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey) return;
|
||||
return () => coverEnsureRelease(storageKey);
|
||||
}, [storageKey]);
|
||||
|
||||
const src = cachedSrc;
|
||||
const provisional = Boolean(ref && storageKey && !src);
|
||||
|
||||
|
||||
@@ -110,13 +110,14 @@ export async function ensureAlbumCoverMisses(
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
|
||||
const slice = albums.slice(0, limit);
|
||||
|
||||
const needEnsure: Array<{ entityId: string; coverArt: string; ref: CoverArtRef }> = [];
|
||||
const needEnsure: Array<{ ref: CoverArtRef }> = [];
|
||||
for (const album of slice) {
|
||||
const entityId = album.id ?? album.coverArt;
|
||||
if (!entityId || !album.coverArt) continue;
|
||||
const ref = albumCoverRef(entityId, album.coverArt);
|
||||
if (!entityId) continue;
|
||||
const coverArt = album.coverArt ?? entityId;
|
||||
const ref = albumCoverRef(entityId, coverArt);
|
||||
if (!getDiskSrcForGrid(ref, tier)) {
|
||||
needEnsure.push({ entityId, coverArt: album.coverArt, ref });
|
||||
needEnsure.push({ ref });
|
||||
}
|
||||
}
|
||||
if (needEnsure.length === 0) return;
|
||||
@@ -127,7 +128,7 @@ export async function ensureAlbumCoverMisses(
|
||||
await Promise.all(
|
||||
chunk.map(async ({ ref }) => {
|
||||
const key = coverStorageKeyFromRef(ref, tier);
|
||||
const result = await coverEnsureQueued(key, ref, tier, 'high');
|
||||
const result = await coverEnsureQueued(key, ref, tier, 'middle');
|
||||
if (result.hit && result.path) {
|
||||
rememberGridDiskSrc(ref, tier, result.path);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user