Files
Psychotoxical-psysonic/src/api/coverCache.ts
T
cucadmuh 2224ddbe78 feat(perf): add on-demand (ui) throughput to cover pipeline cpm (#947)
* feat(perf): add on-demand (ui) throughput to cover pipeline cpm

Cover cpm previously measured only the native backfill (lib) via the
cover:library-progress done delta. Add a parallel UI series: every completed
on-demand Rust cover ensure (grid / now-playing) records a timestamp, surfaced
as a covers-per-minute rate. Both are exposed in the live cover diag, shown as
separate Backfill (lib) / On-demand (ui) cards in the Monitor tab (each pinnable
to the overlay) and as lib/ui rows in the Cover pipeline overlay block.

* docs(changelog): note cover on-demand (ui) throughput (PR #947)

Add CHANGELOG entry and credits line for the UI cover cpm metric.

* fix(perf): source on-demand (ui) cpm from backend produced-cover count

The JS ensure-queue counter never tracked: produced covers return hit:true
(only misses/errors are hit:false), and ensure-queue dedup/HMR made client
counting unreliable. Count on-demand covers natively in ensure_inner on the
produce path (non-bulk, past the cache-hit gate), expose a cumulative
uiEnsuredTotal in the pipeline stats, and derive the per-minute rate on the
frontend from polled deltas — mirroring the lib backfill series.

* perf(cover): measure cpm over trailing 5s instead of full minute

A 60s rolling average added too much inertia, flattening real bursts and
stalls in both the lib backfill and on-demand (ui) cover throughput. Compute
the rate from the trailing 5s of samples (still extrapolated to per-minute),
so the figure reacts promptly and decays to 0 within the window when idle.
2026-06-02 12:11:22 +03:00

265 lines
8.8 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys';
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { restBaseFromUrl } from './subsonicClient';
import type { CoverArtRef, CoverArtTier } from '../cover/types';
/** Library SQLite `track.server_id` uses host index keys, not auth profile UUIDs. */
export function librarySqlServerId(profileOrIndexServerId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === profileOrIndexServerId);
if (server) return serverIndexKeyForProfile(server);
return profileOrIndexServerId;
}
/** Host root for Rust `build_cover_art_url` (`{host}/rest/getCoverArt.view`). */
export function coverCacheRestHost(serverUrl: string): string {
return restBaseFromUrl(serverUrl).replace(/\/rest$/i, '');
}
export type CoverCacheEnsureResult = {
hit: boolean;
path: string;
tier: CoverArtTier;
};
export type CoverCacheStats = {
bytes: number;
count: number;
pressure: 'ok' | 'pressure' | 'full';
autoDownloadEnabled: boolean;
entryCount: number;
};
export type CoverPipelineQueueStatsDto = {
httpMax: number;
httpActive: number;
cpuUiMax: number;
cpuUiActive: number;
cpuBackfillMax: number;
cpuBackfillActive: number;
libraryBackfillHttpMax: number;
libraryBackfillHttpActive: number;
libraryBackfillPassRunning: boolean;
uiEnsuredTotal: number;
};
let coverAutoDownloadEnabled = true;
export function setCoverCacheAutoDownloadEnabled(enabled: boolean): void {
coverAutoDownloadEnabled = enabled;
}
function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const scope = ref.serverScope;
if (scope.kind === 'server') {
// scope.url is the index-stable primary; the Rust cover fetcher needs
// the runtime connect URL (LAN or public, whichever currently answers).
return {
serverIndexKey: coverIndexKeyFromRef(ref),
cacheKind: ref.cacheKind,
cacheEntityId: ref.cacheEntityId,
coverArtId: ref.fetchCoverArtId,
tier,
restBaseUrl: coverCacheRestHost(
connectBaseUrlForServer({ id: scope.serverId, url: scope.url }),
),
username: scope.username,
password: scope.password,
};
}
const server =
scope.kind === 'playback'
? (() => {
const playbackServerId = getPlaybackServerId();
if (playbackServerId) {
const playbackServer = useAuthStore
.getState()
.servers.find(s => s.id === playbackServerId);
if (playbackServer) return playbackServer;
}
return getActiveServer();
})()
: getActiveServer();
const baseUrl = server ? connectBaseUrlForServer(server) : getBaseUrl();
return {
serverIndexKey: coverIndexKeyFromRef(ref),
cacheKind: ref.cacheKind,
cacheEntityId: ref.cacheEntityId,
coverArtId: ref.fetchCoverArtId,
tier,
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
username: server?.username ?? '',
password: server?.password ?? '',
};
}
export type CoverCachePeekItem = {
serverIndexKey: string;
cacheKind: 'album' | 'artist';
cacheEntityId: string;
tier: CoverArtTier;
storageKey: string;
};
/** Disk-only — no HTTP. Returns map storageKey → absolute .webp path. */
export async function coverCachePeekBatch(
refs: CoverArtRef[],
tier: CoverArtTier,
): Promise<Record<string, string>> {
if (refs.length === 0) return {};
const items: CoverCachePeekItem[] = refs.map(ref => ({
serverIndexKey: coverIndexKeyFromRef(ref),
cacheKind: ref.cacheKind,
cacheEntityId: ref.cacheEntityId,
tier,
storageKey: coverStorageKeyFromRef(ref, tier),
}));
return invoke<Record<string, string>>('cover_cache_peek_batch', { items });
}
export async function coverCacheEnsure(
ref: CoverArtRef,
tier: CoverArtTier,
_priority?: string,
): Promise<CoverCacheEnsureResult> {
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', {
args: ensureArgsFromRef(ref, tier),
});
}
export async function coverCacheEnsureBatch(
refs: CoverArtRef[],
tier: CoverArtTier,
_priority?: string,
): Promise<void> {
if (refs.length === 0) return;
const items = refs.map(ref => ensureArgsFromRef(ref, tier));
await invoke('cover_cache_ensure_batch', { items });
}
export async function coverCacheStats(): Promise<CoverCacheStats> {
const stats = await invoke<CoverCacheStats>('cover_cache_stats', {});
setCoverCacheAutoDownloadEnabled(stats.autoDownloadEnabled);
return stats;
}
/** Clears all servers (legacy). Prefer `coverCacheClearServer`. */
export async function coverCacheClear(): Promise<void> {
return invoke('cover_cache_clear', {});
}
export async function coverCacheClearServer(serverIndexKey: string): Promise<void> {
return invoke('cover_cache_clear_server', { serverIndexKey });
}
export async function coverCacheStatsServer(
serverIndexKey: string,
): Promise<Pick<CoverCacheStats, 'bytes' | 'entryCount'>> {
const stats = await invoke<CoverCacheStats>('cover_cache_stats_server', { serverIndexKey });
return { bytes: stats.bytes, entryCount: stats.entryCount };
}
export function coverGetPipelineQueueStats(): Promise<CoverPipelineQueueStatsDto> {
return invoke<CoverPipelineQueueStatsDto>('cover_cache_get_pipeline_queue_stats');
}
export async function libraryCoverBackfillBatch(
serverIndexKey: string,
libraryServerId: string,
cursor?: string | null,
limit?: number,
): Promise<{ coverIds: string[]; nextCursor: string | null; exhausted: boolean }> {
const sqlServerId = librarySqlServerId(libraryServerId);
const diskKey = serverIndexKey || sqlServerId;
return invoke('library_cover_backfill_batch', {
serverIndexKey: diskKey,
libraryServerId: sqlServerId,
cursor,
limit,
});
}
export async function libraryCoverProgress(
serverIndexKey: string,
libraryServerId: string,
): Promise<{ totalDistinct: number; pending: number; done: number }> {
const sqlServerId = librarySqlServerId(libraryServerId);
const diskKey = serverIndexKey || sqlServerId;
return invoke('library_cover_progress', {
serverIndexKey: diskKey,
libraryServerId: sqlServerId,
});
}
export type LibraryCoverBackfillConfigureArgs = {
enabled: boolean;
serverIndexKey: string;
libraryServerId: string;
restBaseUrl: string;
username: string;
password: string;
};
export async function libraryCoverBackfillConfigure(
args: LibraryCoverBackfillConfigureArgs,
): Promise<void> {
return invoke('library_cover_backfill_configure', args);
}
export type CoverBackfillPulseResult = {
scheduled: number;
exhausted: boolean;
pending: number;
done: number;
total: number;
status: 'idle' | 'active' | 'blocked_sync' | 'blocked_pressure' | 'disabled' | string;
};
/** One backfill step (legacy); prefer `libraryCoverBackfillRunFullPass`. */
export async function libraryCoverBackfillPulse(): Promise<CoverBackfillPulseResult> {
return invoke<CoverBackfillPulseResult>('library_cover_backfill_pulse');
}
/**
* Start one full-catalog pass on the native runtime (works when the window is inactive).
* `force` bypasses the idle gate and clears the fetch-failed backoff so previously
* unfetchable (404) covers are retried — used by the manual "Run full pass now".
*/
export async function libraryCoverBackfillRunFullPass(
force = false,
): Promise<{ started: boolean }> {
return invoke<{ started: boolean }>('library_cover_backfill_run_full_pass', { force });
}
export async function libraryCoverBackfillResetCursor(): Promise<void> {
return invoke('library_cover_backfill_reset_cursor');
}
/** Yield native library backfill while the user navigates (visible covers first). */
export async function libraryCoverBackfillSetUiPriority(hold: boolean): Promise<void> {
return invoke('library_cover_backfill_set_ui_priority', { hold });
}
/** Perf-probe only: retune cover backfill threads (download + encode). Returns the clamped value applied. */
export async function libraryCoverBackfillSetParallel(threads: number): Promise<number> {
return invoke<number>('library_cover_backfill_set_parallel', { threads });
}
export async function libraryCoverClearFetchFailures(serverIndexKey: string): Promise<number> {
return invoke<number>('library_cover_clear_fetch_failures', { serverIndexKey });
}
export async function libraryCoverCatalogSize(libraryServerId: string): Promise<number> {
return invoke<number>('library_cover_catalog_size', {
libraryServerId: librarySqlServerId(libraryServerId),
});
}
export function coverCacheMayBackgroundDownload(): boolean {
return coverAutoDownloadEnabled;
}