mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
2224ddbe78
* 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.
265 lines
8.8 KiB
TypeScript
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;
|
|
}
|