Files
psysonic/src/api/coverCache.ts
T
cucadmuh 47832632fd fix(cover): follow connect-URL flips in library cover backfill (#952)
* fix(cover): follow connect-URL flips in library cover backfill

The native cover backfill was configured once with a snapshot of the runtime
connect URL. When a laptop moved off the LAN, the smart endpoint switch flipped
the sticky connect URL to the public address (playback/UI covers rebuild it per
request and followed it), but backfill kept fetching from the now-unreachable
local address — flooding the log with "error sending request" failures.

Make the connect cache observable (notify on effective flips) and have the
backfill hook reconfigure when the resolved URL changes, forcing a pass so the
.fetch-failed backoff from the stale address is cleared and those covers retry
on the reachable endpoint.

* docs(changelog): note cover backfill endpoint-switch fix (PR #952)

* fix(cover): abort stale backfill pass + rerun on connect-URL flip

Frontend reconfigure alone didn't fully cover the boot case: at startup the
first backfill pass starts on the primary (LAN) URL before the reachability
probe resolves, so when the probe flips to public the forced rerun was dropped
by the pass_running guard and the slow LAN pass (every cover timing out) ran to
completion with nothing re-running on the reachable address.

set_session now bumps a session generation; the running pass checks it on every
focus gate and abandons promptly when the URL flips (same server_index_key, new
rest_base_url). try_schedule_full_pass records a rerun when a pass is in flight
and drains it once the abandoned pass returns, so a fresh forced pass runs on
the new address.

* fix(cover-backfill): resolve connect URL per fetch instead of baking it into the queue

The backfill worklist no longer carries a URL. Each cover fetch reads the
current reachable address live from a single worker cell, so a LAN↔public flip
is honoured even by the pass already in flight — its remaining covers download
against the new endpoint without aborting/rebuilding the worklist.

- Drop rest_base_url from CoverBackfillSession; add live base_url cell read in
  ensure_one. Remove the session-generation abort machinery (no longer needed).
- New lightweight library_cover_backfill_set_base_url command pushes the URL on
  every connect-cache flip; a real change clears the stale .fetch-failed backoff
  and runs a forced pass so covers that timed out on the old address retry.
- Split useLibraryCoverBackfill into a configure effect (server/creds/strategy)
  and a flip effect that only pushes the URL.
- Keep a single rerun_pending flag for the boot case (flip mid-pass), since the
  finished pass re-arms the idle gate.
2026-06-02 16:01:01 +03:00

275 lines
9.2 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);
}
/**
* Push the current reachable connect URL to the native backfill worker without
* rebuilding the session. The worklist is URL-agnostic; each fetch reads this
* value live, so a LAN→public flip is honoured by the in-flight pass too. A real
* change clears the stale fetch-failed backoff and kicks a retry pass.
*/
export async function libraryCoverBackfillSetBaseUrl(restBaseUrl: string): Promise<void> {
return invoke('library_cover_backfill_set_base_url', { restBaseUrl });
}
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;
}