feat(cover): unify cover pipeline and stabilize mainstage/now-playing (#870)

* chore(cover): scaffold cover module and rust cover_cache stub

Wave 0: src/cover/ skeleton per contracts.md §12, stub IPC commands
in cover_cache/mod.rs (no-op returns until phase B).

* feat(cover): add unified cover module and tier resolver (phase A)

Wave 1A: tiers, storage keys, resolveJs with cold/sibling races,
useCoverArt, CoverArtImage, layoutSizes, playback scope helpers,
coverSiblings tier ladder, deprecated shims on subsonicStreamUrl.

* feat(cover): rust disk cache and tier-ready events (phase B)

Wave 1B: cover_cache module with WebP tier encode, HTTP canonical 800 fetch,
cover_cache_* commands, cover:tier-ready / cover:evicted events, disk layout tests.

* feat(cover): prefetch hook, tier-ready handoff, library backfill IPC (phase B/C)

Wave 2: useCoverArtPrefetch, cover:tier-ready/evicted bridge, one-time IDB
cover key clear, prefetch registry drain, MainApp wiring.

* feat(cover): migrate dense grids to CoverArtImage and prefetch (phase D)

Wave 3A: dense surfaces use layout-native displayCssPx, surface=dense,
coverPrefetchRegister on Home/Albums/search; AlbumCard cell width from grid.

* feat(cover): migrate sparse surfaces and integrations (phase E sparse)

Wave 3B: sparse CoverArtImage/useCoverArt, lightbox tier 2000, ArtistHeroCover,
MPRIS/Discord/export integrations, playback chrome and detail heroes.

* feat(cover): revalidation scheduler and disk pressure gate (phase E+)

Wave 4: coverCacheMaxMb settings (en/ru), StorageTab disk usage, cover_cache_configure,
useCoverRevalidateScheduler, playbackServer uses cover fetchUrl; pressure watermarks.

* docs: CHANGELOG and credits for cover art pipeline PR #869

* fix(cover): stop webview getCoverArt storm on dense grids (429)

Dense surfaces no longer put rotating getCoverArt URLs in img src; load
disk via Rust ensure + convertFileSrc. Tier-ready notifies listeners instead
of invalidating IDB. Throttle background prefetch and cap Home registry.

* fix(cover): omit empty img src until cover URL is ready

React 19 warns on src=""; CoverArtImage uses undefined until disk/IDB
resolves; queue current track shows placeholder when src is still empty.

* fix(cover): disk cache by host index key, parallel ensure, asset protocol

Bind cover storage to serverIndexKey (library host), rename cover IPC/events,
fix REST base URL and Tauri flat args, enable protocol-asset for disk paths,
add prioritized ensure queue, and wipe legacy profile-UUID cache once.
Limit Vite dep scan to index.html so research/target HTML is ignored.

* fix(cover): WebP tiers, disk peek, home cache, asset URLs for mainstage

Encode lossy WebP (~82), write only missing tiers, library cover backfill,
and cover_cache_peek_batch for fast paint from disk. diskSrcCache + CSP
asset protocol; no IDB fallback when server is up. Session Home feed cache
with warm peek on return; BecauseYouLike deduped cover hook and high prefetch.

* feat(cover): per-server cache strategy and native library backfill

Move cover disk cache settings to Offline & cache with Lazy/Aggressive
per server, per-server clear, and no size cap. Run full-catalog backfill
on the Rust runtime (sync-idle wake, bounded HTTP, bulk 800px writes
without flooding the webview). Drop global prefetch limits from auth store
and waveform clear from the offline storage block.

* fix(build): CSP connect-src for Subsonic API; quieter prod nix build

Prod webview blocked axios ping after cover CSP (missing connect-src).
Drop cargo tauri -v in flake build, raise Vite chunk limit, ignore tsbuildinfo.

* fix(cover): complete WebP ladder in library bulk backfill

Aggressive backfill now writes all derived tiers (128–800), skips IDs
only when the full ladder exists (not 800 alone), avoids fetch-failed
markers on bulk HTTP errors, and stops the pass when the active server
changes.

* fix(cover,home): navigation-priority backfill and Because You Like UX

Pause library cover backfill while navigating; split peek/ensure traffic
so grids and rails win over bulk work. Disk src lookup, grid warm hooks,
and non-blocking mainstage prime for faster visible covers.

Because You Like: session snapshot, staggered horizontal skeleton row,
text hidden until cover is ready, and layout aligned with loaded cards.

* feat(random-albums,library): local-first album fetch + cover art pipeline

Random Albums теперь запрашивает локальный SQLite-индекс (ORDER BY RANDOM()
LIMIT N) вместо сетевого запроса к серверу. При готовом индексе спиннер
исчезает практически мгновенно; сеть используется только как фолбэк.

- advanced_search.rs: добавляет `("random", _) => RANDOM()` в allowlist сортировок
- browseTextSearch.ts: runLocalRandomAlbums — SQLite-рандом для Albums
- RandomAlbums.tsx: doFetchRandomAlbums local-first для обоих путей (без жанра
  и с жанром через runLocalAlbumsByGenres + JS-shuffle); speculative reserve
  прогревает следующий батч в фоне после каждого Refresh

Также: обновление пайплайна обложек (coverTraffic, peekQueue, ensureQueue,
diskSrcLookup, warmDiskPeek, prefetchRegistry, useCoverArt, useWarmGridCovers,
useCoverNavigationPriority, resolveIntersectionScrollRoot и сопутствующие
компоненты/хуки).

* fix(random-albums): prevent double-load on Zustand rehydration

useEffect([selectedGenres, load]) fired twice on every visit: first with
default store values, then again ~50 ms later when Zustand rehydrated
mixMinRatingFilterEnabled/minAlbum/minArtist from localStorage.

Previously this was invisible because the first network fetch took ~1.5 s,
so loadingRef.current was still true on the second fire. With the new
local-first SQLite path the first load completes in ~50 ms, leaving the
guard cleared before rehydration triggers a second random batch.

Fix: ref-pattern — keep loadRef.current fresh on every render, effect
depends only on selectedGenres. Manual Refresh and genre-filter changes
still call the latest closure correctly.

* fix(random-albums): stop warmCoverDiskSrcBatch in fillReserve from causing visual flash

fillReserve вызывал warmCoverDiskSrcBatch для обложек резервного батча, что
вызывало bumpDiskSrcCache() для каждой новой обложки (~30+ вызовов). Это будило
всех подписчиков useCoverArt на текущей странице, провоцируя видимую перерисовку
примерно через ~1.5 с после загрузки (когда filterAlbumsByMixRatings делает
сетевые запросы к рейтингам артистов).

- fillReserve: убран warmCoverDiskSrcBatch — обложки прогреваются лениво при
  consume резерва через primeAlbumCoversForDisplay
- reserve-путь в load(): добавлен primeAlbumCoversForDisplay перед setAlbums
  (аналогично non-reserve пути; при уже прогретом кэше — мгновенно)

* feat(because-you-like): reserve-first pattern — instant display on return visits

Каждый визит на Mainstage после первого теперь отдаёт готовую заготовку
мгновенно, вместо spinner → сетевые запросы → контент.

Архитектура:
- resolvePicks / fetchBecauseYouLike вынесены на уровень модуля (выход из
  замыкания useEffect); читают текущий localStorage, возвращают
  { anchor, recs, nextAnchorHistory, nextPicksHistory }
- fillBecauseReserve — fire-and-forget фоновая функция: запускается сразу
  после отображения результата, кладёт следующий батч в _becauseReserve.
  Covers намеренно не прогреваются (bumpDiskSrcCache на текущей странице не
  нужен); они прогреваются через primeAlbumCoversForDisplay при consume.
- useLayoutEffect: если reserve готов — не сбрасывает стейт в skeleton
  (контент появляется без мигания)
- useEffect: reserve-first path — consume → primeCovers → setState → fill;
  full-fetch path сохранён как fallback при первом визите или промахе

Поведение:
- Визит 1: full fetch (как раньше) → показ → fillReserve R1
- Визит 2+: consume R1 → мгновенный показ → fillReserve R2
- При сетевом сбое: restore из session cache (как раньше)

* fix(because-you-like): initialise state from reserve — no skeleton flash on remount

При ремаунте компонент стартовал с refreshing=true/anchor=null/recs=[] и
показывал skeleton на один тик до того как useEffect отработает.

Теперь useState() использует lazy initializers, которые читают _becauseReserve
прямо в первом рендере: если reserve валиден — state сразу refreshing=false,
anchor=X, recs=[...] и skeleton не показывается вообще. Covers уже в diskSrcCache
(из предыдущего показа) и появляются без дополнительных запросов.

useLayoutEffect упрощён: вызывает hasValidReserve() и сбрасывает в skeleton
только если reserve отсутствует (для случая navigation без ремаунта).

* fix(because-you-like): apply reserve in useLayoutEffect to handle async pool arrival

Lazy initializers не могли применить reserve при первом рендере, потому что
mostPlayed/recentlyPlayed/starred приходят из Home.tsx асинхронно — pool=[]
на первом рендере, poolKey не совпадает с reserve.

useLayoutEffect теперь активно ставит стейт из reserve (а не просто не сбрасывает):
когда pool обновляется до реальных данных, useLayoutEffect срабатывает синхронно
до paint, проверяет reserve и сразу применяет anchor/recs/refreshing=false.
При отсутствии reserve — сбрасывает в skeleton как прежде.

* fix(because-you-like): reserve > cache > skeleton — eliminate skeleton flash on mount

Корневая причина: Home.tsx загружает mostPlayed асинхронно через useEffect,
поэтому на первом рендере pool=[], poolKey=''. Reserve хранится с реальным
poolKey → mismatch → lazy initializers запускали skeleton.

Теперь двухуровневый fallback без зависимости от poolKey:
1. reserve (serverId + poolKey совпадают) → мгновенный новый батч
2. becauseYouLikeCache (только serverId) → stale-while-revalidate, контент
   доступен сразу с mount, обновляется тихо в фоне
3. skeleton → только при полном отсутствии данных (первый визит)

Применяется одинаково в lazy useState initializers, useLayoutEffect и
full-fetch path useEffect (не сбрасывать в skeleton пока есть cached контент).

* fix(because-you-like): key reserve by serverId only; guard useEffect on empty pool

Проблема: reserve хранился с poolKey, но на первом рендере pool=[] → poolKey=''
→ mismatch → показывался кэш (предыдущий набор) ~500ms пока Home.tsx не загружал
mostPlayed.

Исправления:
- BecauseReserve: убран poolKey — reserve валиден для любого pool-состояния
  на том же сервере. Pool (топ-артисты) меняется медленно; один раз показать
  reserve с чуть устаревшим anchor лучше чем показывать предыдущий набор 500ms
- hasValidReserve: проверяет только serverId
- fillBecauseReserve: убран poolKey из сигнатуры и хранилища
- useEffect: guard pool.length === 0 → возврат без fetch/consume;
  effect перезапустится когда pool заполнится (реальные deps изменятся)
  → reserve применяется из useLayoutEffect ещё до pool, без стале-флэша

Итоговый порядок: reserve (instant, serverId) > cache (stale-while-revalidate)
> skeleton (только первый визит)

* fix(home): remove mix-rating deps from feed useEffect — prevent Zustand rehydration double-fetch

Корень: useAuthStore(mixMinRatingFilterEnabled/Album/Artist) были в deps
useEffect. Zustand persist реhydrates асинхронно — сначала activeServerId,
потом mix-rating значения. Это вызывало двойной запуск эффекта:
- Первый запуск: homeFeedCache hit → показывает набор предыдущего просмотра
- Второй запуск (после rehydration): cache miss или повторный fetch с
  реальными mix-настройками → ~500ms → новый набор

Итог: Hero, AlbumRow, BecauseYouLikeRail показывали предыдущий набор
первые ~500ms при каждом возврате на Mainstage.

Fix: убраны mixMinRatingFilterEnabled/Album/Artist из deps. getMixMinRatingsConfigFromAuth()
читается внутри эффекта через getState() — всегда актуальные значения без
пересоздания замыкания. Mix-настройки по-прежнему применяются при fetch,
но не вызывают двойной запуск при rehydration.

* feat(home): local-first discover songs via SQLite ORDER BY RANDOM()

Добавлена runLocalRandomSongs (аналог runLocalRandomAlbums для треков)
в browseTextSearch.ts — использует libraryAdvancedSearch с sort random,
field уже поддерживается Rust-кодом через wildcarded ("random", _) ветку.

В Home.tsx: discoverSongs теперь сначала пробует локальный индекс,
и только при недоступности (индекс не готов, ошибка) падает обратно
на getRandomSongs.view. Ускоряет первую загрузку Mainstage — треки
берутся из SSD вместо сети.

* fix(home): pre-populate state from cache at mount — eliminate empty-state flash on return visits

Причина: Home.tsx размонтируется при навигации. При возврате первый рендер
всегда с пустыми массивами (heroAlbums=[], mostPlayed=[] и т.д.), потом
useEffect читает homeFeedCache и заполняет state. Даже один кадр с пустым
состоянием вызывает перерисовку Hero и BecauseYouLikeRail (pool=[]).

Решение: getInitialHomeFeed() читает homeFeedCache синхронно через
useAuthStore.getState() (не hook) в lazy useState initializers. К моменту
повторного визита store уже rehydrated — все state получают кэшированные
данные до первого рендера.

Дополнительно: wasPrePopulated предотвращает повторный applyFeedSnapshot
в useEffect когда state уже заполнен — иначе новые ссылки на массивы
вызывали бы ненужные ре-рендеры дочерних компонентов с теми же данными.

* fix(mainstage): keep refresh without return flicker

Keep Home and Because You Like visually stable during a single visit while still refreshing data for the next re-enter. Improve mainstage cover warmup by ensuring and pre-decoding above-the-fold artwork so hero and top rails appear instantly after navigation.

* fix(mainstage): stabilize because rail and hero background framing

Measure Because You Like layout before first paint to avoid width snap flicker, and render hero background as centered cover-fit images so the frame no longer jumps from top to middle on mount.

* fix(now-playing): prewarm track data and prevent stale carry-over

Warm Now Playing fetch caches and playback cover art on track change so entering the page no longer waits on first-load requests. Gate key-based sections (top songs, tour, Last.fm) by the active track/artist keys to avoid briefly rendering values from the previous track.

* fix(cover,test): refresh playback scope and default tauri cover mocks

Recompute playback cover scope when queue/server context changes so now-playing art resolves against the correct server after handoffs. Add default cover-cache invoke handlers to the shared Tauri test harness to prevent unhandled rejections in suites that mount cover-aware UI.

* fix(cover,now-playing,test): align prewarm scopes and tighten tauri mocks

Make cover-cache invoke defaults opt-in for tests, align radio prewarm scope with active rendering scope, and add targeted hook tests for prewarm + playback-scope reactivity. Also harden Rust cover URL building to avoid panic on malformed base URLs.

* test(cover): hoist mocked useCoverArt and clean EOF whitespace

Fix the new playback-scope hook test to use a hoisted vi.mock-safe stub and keep branch-wide diff checks clean by removing an accidental trailing blank line.

* fix(cover): align playback ensure auth and harden backfill retry flow

Use playback-server credentials for playback-scoped cover ensures, persist fetch-failed markers for bulk library backfill failures, and avoid advancing backfill cursor when UI-priority hold interrupts a batch.

* fix(ci): resolve clippy lint and update frontend node runtime

Move fetch helper before the test module to satisfy clippy's items-after-test-module rule, and modernize frontend CI to setup-node v6 with lts/* instead of pinned Node 20.

* chore(settings): simplify cover and analytics strategy copy

Move strategy summaries below tables, simplify Lazy/Aggressive wording, keep analytics warning always visible, and localize Russian texts to plain language without technical jargon.
This commit is contained in:
cucadmuh
2026-05-26 19:35:08 +03:00
committed by GitHub
parent 91e7195e0f
commit 418b25914a
160 changed files with 7551 additions and 914 deletions
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { makeTrack } from '../test/helpers/factories';
import { resetAllStores } from '../test/helpers/storeReset';
import { invokeMock, onInvoke } from '../test/mocks/tauri';
import { coverArtRef } from '../cover/ref';
import { coverCacheEnsure, coverCacheRestHost, librarySqlServerId } from './coverCache';
describe('librarySqlServerId', () => {
beforeEach(() => {
resetAllStores();
useAuthStore.setState({
servers: [{ id: 'profile-uuid', name: 'Home', url: 'http://music.example:4533', username: 'u', password: 'p' }],
activeServerId: 'profile-uuid',
});
});
it('maps auth profile UUID to host index key for SQLite', () => {
expect(librarySqlServerId('profile-uuid')).toBe('music.example:4533');
});
it('passes through values that are already index keys', () => {
expect(librarySqlServerId('music.example:4533')).toBe('music.example:4533');
});
});
describe('coverCacheRestHost', () => {
it('strips /rest for Rust cover fetch', () => {
expect(coverCacheRestHost('http://music.example:4533')).toBe('http://music.example:4533');
expect(coverCacheRestHost('http://music.example:4533/')).toBe('http://music.example:4533');
});
});
describe('coverCacheEnsure', () => {
beforeEach(() => {
resetAllStores();
});
it('uses playback server credentials when scope kind is playback', async () => {
const activeServerId = useAuthStore.getState().addServer({
name: 'Active',
url: 'http://active.example:4533',
username: 'active-user',
password: 'active-pass',
});
const playbackServerId = useAuthStore.getState().addServer({
name: 'Playback',
url: 'http://playback.example:5533',
username: 'playback-user',
password: 'playback-pass',
});
useAuthStore.getState().setActiveServer(activeServerId);
const track = makeTrack({ id: 'q1', coverArt: 'cover-1' });
usePlayerStore.setState({
queue: [track],
queueIndex: 0,
queueServerId: playbackServerId,
currentTrack: track,
});
onInvoke('cover_cache_ensure', () => ({ hit: false, path: '', tier: 256 }));
await coverCacheEnsure(coverArtRef('cover-1', { kind: 'playback' }), 256);
const call = invokeMock.mock.calls.find(c => c[0] === 'cover_cache_ensure');
expect(call).toBeTruthy();
const payload = call?.[1] as Record<string, unknown>;
expect(payload.restBaseUrl).toBe('http://playback.example:5533');
expect(payload.username).toBe('playback-user');
expect(payload.password).toBe('playback-pass');
});
});
+221
View File
@@ -0,0 +1,221 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { coverIndexKeyFromRef } from '../cover/storageKeys';
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;
};
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') {
return {
serverIndexKey: coverIndexKeyFromRef(ref),
coverArtId: ref.coverArtId,
tier,
restBaseUrl: coverCacheRestHost(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?.url || getBaseUrl();
return {
serverIndexKey: coverIndexKeyFromRef(ref),
coverArtId: ref.coverArtId,
tier,
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
username: server?.username ?? '',
password: server?.password ?? '',
};
}
export type CoverCachePeekItem = {
serverIndexKey: string;
coverArtId: string;
tier: CoverArtTier;
};
/** Disk-only — no HTTP. Returns map storageKey → absolute .webp path. */
export async function coverCachePeekBatch(
items: CoverCachePeekItem[],
): Promise<Record<string, string>> {
if (items.length === 0) return {};
const raw = await invoke<Record<string, string>>('cover_cache_peek_batch', { items });
const out: Record<string, string> = {};
for (const item of items) {
const key = `${item.serverIndexKey}:cover:${item.coverArtId}:${item.tier}`;
if (raw[key]) out[key] = raw[key];
}
return out;
}
export async function coverCacheEnsure(
ref: CoverArtRef,
tier: CoverArtTier,
_priority?: string,
): Promise<CoverCacheEnsureResult> {
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', 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 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). */
export async function libraryCoverBackfillRunFullPass(): Promise<{ started: boolean }> {
return invoke<{ started: boolean }>('library_cover_backfill_run_full_pass');
}
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 });
}
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;
}
+7 -6
View File
@@ -128,10 +128,10 @@ describe('getClient', () => {
});
describe('coverArtCacheKey', () => {
it('uses serverId + entity id + size as a stable cache key', () => {
const id = setUpServer();
expect(coverArtCacheKey('cover-1')).toBe(`${id}:cover:cover-1:256`);
expect(coverArtCacheKey('cover-1', 200)).toBe(`${id}:cover:cover-1:200`);
it('uses host index key + entity id + tier as a stable cache key', () => {
setUpServer();
expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:cover-1:256');
expect(coverArtCacheKey('cover-1', 200)).toBe('music.example.com:cover:cover-1:200');
});
it('falls back to "_" as the server-id segment when no server is active', () => {
@@ -212,8 +212,9 @@ describe('buildCoverArtUrlForServer', () => {
});
describe('coverArtCacheKeyForServer', () => {
it('scopes cache keys by server id', () => {
expect(coverArtCacheKeyForServer('srv-b', 'cover-1', 80)).toBe('srv-b:cover:cover-1:80');
it('scopes cache keys by host index key when profile is known', () => {
const profileId = setUpServer({ url: 'https://b.example' });
expect(coverArtCacheKeyForServer(profileId, 'cover-1', 80)).toBe('b.example:cover:cover-1:80');
});
});
+21 -6
View File
@@ -1,4 +1,6 @@
import md5 from 'md5';
import { coverStorageKey } from '../cover/storageKeys';
import type { CoverArtTier } from '../cover/types';
import { useAuthStore } from '../store/authStore';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient';
@@ -53,16 +55,29 @@ export function buildStreamUrl(id: string): string {
return streamUrlFromProfile(server.url, server.username, server.password, id);
}
/** Stable cache key for cover art — does not include ephemeral auth params. */
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
export function coverArtCacheKey(id: string, size = 256): string {
const server = useAuthStore.getState().getActiveServer();
return coverArtCacheKeyForServer(server?.id ?? '_', id, size);
return coverStorageKey({ kind: 'active' }, id, size as CoverArtTier);
}
export function coverArtCacheKeyForServer(serverId: string, id: string, size = 256): string {
return `${serverId}:cover:${id}:${size}`;
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, size = 256): string {
const server = findServerByIdOrIndexKey(serverIdOrKey);
if (!server) return `${serverIdOrKey}:cover:${id}:${size}`;
return coverStorageKey(
{
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
},
id,
size as CoverArtTier,
);
}
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
export function buildCoverArtUrl(id: string, size = 256): string {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
@@ -71,7 +86,7 @@ export function buildCoverArtUrl(id: string, size = 256): string {
return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`;
}
/** Cover art for a specific saved server (e.g. share-search preview on a non-active server). */
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
export function buildCoverArtUrlForServer(
serverUrl: string,
username: string,