mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -48,7 +48,7 @@ export async function downscaleCoverBlob(
|
||||
};
|
||||
const onAbort = () => finish(null);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
canvas.toBlob(b => finish(b ?? null), 'image/jpeg', 0.88);
|
||||
canvas.toBlob(b => finish(b ?? null), 'image/webp', 0.82);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { getCachedBlob } from '../imageCache';
|
||||
import { coverArtRef } from '../../cover/ref';
|
||||
import { loadCoverBlobForExport } from '../../cover/integrations/export';
|
||||
import PsysonicLogo from '../../components/PsysonicLogo';
|
||||
|
||||
export type ExportFormat = 'story' | 'square' | 'twitter';
|
||||
@@ -61,10 +61,10 @@ function isLight(hex: string): boolean {
|
||||
return (r + g + b) / 3 > 160;
|
||||
}
|
||||
|
||||
async function loadAlbumCover(album: SubsonicAlbum, size: number, signal?: AbortSignal): Promise<ImageBitmap | null> {
|
||||
async function loadAlbumCover(album: SubsonicAlbum, displayCssPx: number, signal?: AbortSignal): Promise<ImageBitmap | null> {
|
||||
if (!album.coverArt) return null;
|
||||
try {
|
||||
const blob = await getCachedBlob(buildCoverArtUrl(album.coverArt, size), coverArtCacheKey(album.coverArt, size), signal);
|
||||
const blob = await loadCoverBlobForExport(coverArtRef(album.coverArt), displayCssPx, signal);
|
||||
if (!blob) return null;
|
||||
return await createImageBitmap(blob);
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildCoverArtUrl } from '../../api/subsonicStreamUrl';
|
||||
import { getAlbumList } from '../../api/subsonicLibrary';
|
||||
import { coverArtRef } from '../../cover/ref';
|
||||
import { loadCoverBlobForExport } from '../../cover/integrations/export';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { downloadDir, join } from '@tauri-apps/api/path';
|
||||
@@ -38,11 +39,12 @@ function clampText(ctx: CanvasRenderingContext2D, text: string, maxW: number): s
|
||||
return t + '…';
|
||||
}
|
||||
|
||||
async function loadImage(url: string): Promise<ImageBitmap | null> {
|
||||
async function loadAlbumCoverBitmap(album: SubsonicAlbum): Promise<ImageBitmap | null> {
|
||||
if (!album.coverArt) return null;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return await createImageBitmap(await res.blob());
|
||||
const blob = await loadCoverBlobForExport(coverArtRef(album.coverArt), COVER_SIZE);
|
||||
if (!blob) return null;
|
||||
return await createImageBitmap(blob);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -268,7 +270,7 @@ export async function exportNewAlbumsImage(since: number): Promise<{ count: numb
|
||||
for (let p = 0; p < pages.length; p++) {
|
||||
const page = pages[p];
|
||||
const covers = await Promise.all(
|
||||
page.map(a => a.coverArt ? loadImage(buildCoverArtUrl(a.coverArt, 160)) : Promise.resolve(null))
|
||||
page.map(a => loadAlbumCoverBitmap(a)),
|
||||
);
|
||||
|
||||
const blob = await renderPage(page, covers, logo, now, newAlbums.length, p + 1, pages.length, p * MAX_PER_PAGE);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { COVER_ART_REGISTERED_SIZES } from '../cover/coverArtRegisteredSizes';
|
||||
import { COVER_ART_TIERS } from '../../cover/tiers';
|
||||
|
||||
/** Tier ladder for sibling probe (exclude on-demand 2000). */
|
||||
const COVER_SIBLING_TIERS = COVER_ART_TIERS.filter(t => t !== 2000);
|
||||
import { downscaleCoverBlob } from '../cover/coverBlobDownscale';
|
||||
import { blobCache, rememberBlob } from './blobCache';
|
||||
import { purgeUrlEntry } from './urlPool';
|
||||
@@ -6,7 +9,7 @@ import { mapBlobsFromIDB, putBlob } from './idbStore';
|
||||
import { acquireNetFetchSlot, releaseNetFetchSlot } from './netFetchScheduler';
|
||||
|
||||
/** Prefer larger blobs as provisional placeholders — downscaled in `<img>` for sharpness. */
|
||||
const COVER_ART_CACHE_SIZES_DESC = [...COVER_ART_REGISTERED_SIZES].sort((a, b) => b - a);
|
||||
const COVER_ART_CACHE_SIZES_DESC = [...COVER_SIBLING_TIERS].sort((a, b) => b - a);
|
||||
|
||||
export function parseCoverCacheKey(cacheKey: string): { stem: string; size: number } | null {
|
||||
const colon = cacheKey.lastIndexOf(':');
|
||||
|
||||
@@ -338,6 +338,58 @@ function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] {
|
||||
return [{ field: 'name', dir: 'asc' }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Random track sample from the local `track` table — SQLite `ORDER BY RANDOM() LIMIT N`.
|
||||
* Returns null when the index is unavailable (caller falls back to the network).
|
||||
*/
|
||||
export async function runLocalRandomSongs(
|
||||
serverId: string | null | undefined,
|
||||
limit: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['track'],
|
||||
sort: [{ field: 'random', dir: 'asc' }],
|
||||
limit,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.tracks.map(trackToSong);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Random album sample from the local `album` table — SQLite `ORDER BY RANDOM() LIMIT N`.
|
||||
* Returns null when the index is unavailable (caller falls back to the network).
|
||||
*/
|
||||
export async function runLocalRandomAlbums(
|
||||
serverId: string | null | undefined,
|
||||
limit: number,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['album'],
|
||||
sort: [{ field: 'random', dir: 'asc' }],
|
||||
limit,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.albums.map(albumToAlbum);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated All Albums browse from the local `album` table (F1). */
|
||||
export async function runLocalAlbumBrowsePage(
|
||||
serverId: string | null | undefined,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Wake a new full library cover pass (until catalog cursor exhausted).
|
||||
* Not used for periodic revalidate — that stays in `cover_revalidate_*`.
|
||||
*/
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function wakeLibraryCoverBackfill(): void {
|
||||
for (const fn of listeners) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeLibraryCoverBackfillWake(handler: () => void): () => void {
|
||||
listeners.add(handler);
|
||||
return () => listeners.delete(handler);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const COVER_CACHE_STRATEGIES = ['lazy', 'aggressive'] as const;
|
||||
|
||||
export type CoverCacheStrategy = (typeof COVER_CACHE_STRATEGIES)[number];
|
||||
|
||||
export const DEFAULT_COVER_CACHE_STRATEGY: CoverCacheStrategy = 'lazy';
|
||||
|
||||
export function coverStrategyAllowsRoutePrefetch(_strategy: CoverCacheStrategy): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function coverStrategyAllowsLibraryBackfill(strategy: CoverCacheStrategy): boolean {
|
||||
return strategy === 'aggressive';
|
||||
}
|
||||
|
||||
/** Map legacy auth-store `coverPrefetchStrategy` to per-server strategy. */
|
||||
export function coverStrategyFromLegacyPrefetch(
|
||||
legacy: string | undefined,
|
||||
): CoverCacheStrategy {
|
||||
if (legacy === 'library') return 'aggressive';
|
||||
return 'lazy';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { coverEnsureQueueBacklog } from '../../cover/ensureQueue';
|
||||
|
||||
/** Target in-flight + queued ensures ≈ workers × multiplier (mirror analysis backfill). */
|
||||
export const LIBRARY_COVER_BACKLOG_DEPTH_MULTIPLIER = 3;
|
||||
export const LIBRARY_COVER_BACKLOG_MIN = 8;
|
||||
export const LIBRARY_COVER_BACKLOG_MAX = 48;
|
||||
|
||||
export function computeLibraryCoverBackfillTargetDepth(workers: number): number {
|
||||
const w = Math.max(1, Math.round(workers));
|
||||
return Math.min(
|
||||
LIBRARY_COVER_BACKLOG_MAX,
|
||||
Math.max(LIBRARY_COVER_BACKLOG_MIN, w * LIBRARY_COVER_BACKLOG_DEPTH_MULTIPLIER),
|
||||
);
|
||||
}
|
||||
|
||||
export function libraryCoverBackfillNeedsTopUp(workers: number): boolean {
|
||||
return coverEnsureQueueBacklog() < computeLibraryCoverBackfillTargetDepth(workers);
|
||||
}
|
||||
|
||||
export function libraryCoverBackfillTopUpLimit(workers: number, maxBatch: number): number {
|
||||
const target = computeLibraryCoverBackfillTargetDepth(workers);
|
||||
const deficit = target - coverEnsureQueueBacklog();
|
||||
if (deficit <= 0) return 0;
|
||||
return Math.min(maxBatch, deficit);
|
||||
}
|
||||
@@ -46,6 +46,13 @@ export function syncIngestDisplayCount(
|
||||
);
|
||||
}
|
||||
|
||||
/** True while library sync holds SQLite — pause cover backfill / heavy cover RPC. */
|
||||
export function librarySyncBlocksCoverWork(
|
||||
status: Pick<SyncStateDto, 'syncPhase'>,
|
||||
): boolean {
|
||||
return status.syncPhase === 'initial_sync' || status.syncPhase === 'probing';
|
||||
}
|
||||
|
||||
export async function libraryIsReady(serverId: string | null | undefined): Promise<boolean> {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { COVER_ART_TIERS } from '../../cover/tiers';
|
||||
import { COVER_ART_REGISTERED_SIZES } from '../cover/coverArtRegisteredSizes';
|
||||
import { invalidateCacheKey } from '../imageCache';
|
||||
import { STORE_NAME } from '../imageCache/constants';
|
||||
import { openDB } from '../imageCache/idbStore';
|
||||
|
||||
const MIGRATION_FLAG = 'psysonic_cover_tier_idb_cleared_v1';
|
||||
|
||||
/**
|
||||
* One-time clear of legacy multi-size IndexedDB cover keys (spec §4 — no import).
|
||||
*/
|
||||
export async function runCoverIdbUpgradeMigration(): Promise<void> {
|
||||
try {
|
||||
if (localStorage.getItem(MIGRATION_FLAG) === '1') return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacySizes = new Set<number>([
|
||||
...COVER_ART_REGISTERED_SIZES,
|
||||
...COVER_ART_TIERS,
|
||||
]);
|
||||
|
||||
try {
|
||||
const db = await openDB();
|
||||
const keys = await new Promise<string[]>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.getAllKeys();
|
||||
req.onsuccess = () => resolve((req.result as IDBValidKey[]).map(String));
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
for (const key of keys) {
|
||||
if (!key.includes(':cover:')) continue;
|
||||
const tail = key.slice(key.lastIndexOf(':') + 1);
|
||||
const size = Number(tail);
|
||||
if (!Number.isFinite(size) || legacySizes.has(size)) {
|
||||
await invalidateCacheKey(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(MIGRATION_FLAG, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
buildOfflineTracksForAlbum,
|
||||
ensureServerForOfflineAlbum,
|
||||
hasAnyOfflineAlbums,
|
||||
offlineAlbumCoverArt,
|
||||
offlineAlbumCoverScope,
|
||||
offlineTrackCount,
|
||||
} from './offlineLibraryHelpers';
|
||||
import { coverStorageKey } from '../../cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '../../cover/tiers';
|
||||
import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore';
|
||||
|
||||
vi.mock('../server/switchActiveServer', () => ({
|
||||
@@ -49,20 +51,21 @@ describe('offlineLibraryHelpers', () => {
|
||||
expect(offlineTrackCount(album, tracks)).toBe(1);
|
||||
});
|
||||
|
||||
it('offlineAlbumCoverArt returns empty when server profile is missing', () => {
|
||||
it('offlineAlbumCoverScope is null when server profile is missing', () => {
|
||||
const album: OfflineAlbumMeta = {
|
||||
id: 'al1', serverId: 'gone', name: 'Al', artist: 'Ar', coverArt: 'ca1', trackIds: [],
|
||||
};
|
||||
expect(offlineAlbumCoverArt(album, 300)).toEqual({ src: '', cacheKey: '' });
|
||||
expect(offlineAlbumCoverScope(album)).toBeNull();
|
||||
});
|
||||
|
||||
it('offlineAlbumCoverArt builds url when server exists', () => {
|
||||
it('offlineAlbumCoverScope uses host index key compatible with disk cache', () => {
|
||||
const album: OfflineAlbumMeta = {
|
||||
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', coverArt: 'ca1', trackIds: [],
|
||||
};
|
||||
const { src, cacheKey } = offlineAlbumCoverArt(album, 300);
|
||||
expect(src).toContain('ca1');
|
||||
expect(cacheKey).toBe('a:cover:ca1:300');
|
||||
const scope = offlineAlbumCoverScope(album);
|
||||
expect(scope).toMatchObject({ kind: 'server', serverId: 'a' });
|
||||
const tier = resolveCoverDisplayTier(300, { surface: 'dense' });
|
||||
expect(coverStorageKey(scope!, 'ca1', tier)).toBe('a.test:cover:ca1:512');
|
||||
});
|
||||
|
||||
it('ensureServerForOfflineAlbum skips switch when already active', async () => {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
buildCoverArtUrlForServer,
|
||||
coverArtCacheKeyForServer,
|
||||
} from '../../api/subsonicStreamUrl';
|
||||
import type { CoverServerScope } from '../../cover/types';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore';
|
||||
import { switchActiveServer } from '../server/switchActiveServer';
|
||||
@@ -41,16 +38,17 @@ export function buildOfflineTracksForAlbum(
|
||||
});
|
||||
}
|
||||
|
||||
export function offlineAlbumCoverArt(
|
||||
album: OfflineAlbumMeta,
|
||||
size: number,
|
||||
): { src: string; cacheKey: string } {
|
||||
if (!album.coverArt) return { src: '', cacheKey: '' };
|
||||
/** Server scope for offline album covers — same host index key as main library disk cache. */
|
||||
export function offlineAlbumCoverScope(album: OfflineAlbumMeta): CoverServerScope | null {
|
||||
if (!album.coverArt) return null;
|
||||
const server = findServerByIdOrIndexKey(album.serverId);
|
||||
if (!server) return { src: '', cacheKey: '' };
|
||||
if (!server) return null;
|
||||
return {
|
||||
src: buildCoverArtUrlForServer(server.url, server.username, server.password, album.coverArt, size),
|
||||
cacheKey: coverArtCacheKeyForServer(server.id, album.coverArt, size),
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('playbackServer', () => {
|
||||
useAuthStore.setState({ activeServerId: 'b' });
|
||||
const { src, cacheKey } = playbackCoverArtForId('cov1', 128);
|
||||
expect(src).toContain('a.test');
|
||||
expect(cacheKey).toBe('a:cover:cov1:128');
|
||||
expect(cacheKey).toBe('a.test:cover:cov1:128');
|
||||
});
|
||||
|
||||
it('shouldBindQueueServerForPlay detects queue replacement', () => {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildCoverArtUrlForServer,
|
||||
coverArtCacheKey,
|
||||
coverArtCacheKeyForServer,
|
||||
} from '../../api/subsonicStreamUrl';
|
||||
import { buildCoverArtFetchUrl } from '../../cover/fetchUrl';
|
||||
import { coverArtRef, resolvePlaybackCoverScope } from '../../cover/ref';
|
||||
import { coverStorageKey } from '../../cover/storageKeys';
|
||||
import { resolveCoverDisplayTier } from '../../cover/tiers';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { switchActiveServer } from '../server/switchActiveServer';
|
||||
@@ -92,22 +90,13 @@ export async function ensurePlaybackServerActive(): Promise<boolean> {
|
||||
return switchActiveServer(server);
|
||||
}
|
||||
|
||||
/** Cover URLs for queue / player UI when playback uses a non-active saved server. */
|
||||
export function playbackCoverArtForId(coverId: string, size: number): { src: string; cacheKey: string } {
|
||||
const playbackSid = getPlaybackServerId();
|
||||
const activeSid = useAuthStore.getState().activeServerId;
|
||||
if (playbackSid && activeSid && playbackSid !== activeSid) {
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === playbackSid);
|
||||
if (server) {
|
||||
return {
|
||||
src: buildCoverArtUrlForServer(server.url, server.username, server.password, coverId, size),
|
||||
cacheKey: coverArtCacheKeyForServer(server.id, coverId, size),
|
||||
};
|
||||
}
|
||||
}
|
||||
/** Cover fetch URL + storage key for queue prefetch (displayCssPx = layout CSS px). */
|
||||
export function playbackCoverArtForId(coverId: string, displayCssPx: number): { src: string; cacheKey: string } {
|
||||
const ref = coverArtRef(coverId, resolvePlaybackCoverScope());
|
||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
|
||||
return {
|
||||
src: buildCoverArtUrl(coverId, size),
|
||||
cacheKey: coverArtCacheKey(coverId, size),
|
||||
src: buildCoverArtFetchUrl(ref, tier),
|
||||
cacheKey: coverStorageKey(ref.serverScope, coverId, tier),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
|
||||
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
|
||||
import { useHotCacheStore } from '../../store/hotCacheStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { useOfflineStore } from '../../store/offlineStore';
|
||||
@@ -107,5 +108,6 @@ export async function rewriteFrontendStoreKeys(servers: ServerProfile[]): Promis
|
||||
rewriteAnalysisStrategyStoreKeys(mappings);
|
||||
// Keep migration explicit: Zustand persist writes the current state snapshot.
|
||||
useAnalysisStrategyStore.getState().migrateServerOverrides(servers);
|
||||
useCoverStrategyStore.getState().migrateServerOverrides(servers);
|
||||
useLibraryIndexStore.setState(state => ({ masterEnabled: state.masterEnabled }));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
import {
|
||||
coverTrafficBeginServerSwitch,
|
||||
coverTrafficEndServerSwitch,
|
||||
} from '../../cover/coverTraffic';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useOrbitStore } from '../../store/orbitStore';
|
||||
import { endOrbitSession, leaveOrbitSession } from '../orbit';
|
||||
|
||||
export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
|
||||
coverTrafficBeginServerSwitch();
|
||||
try {
|
||||
const ping = await pingWithCredentials(server.url, server.username, server.password);
|
||||
if (!ping.ok) return false;
|
||||
@@ -39,5 +44,7 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
coverTrafficEndServerSwitch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ describe('resolveIntersectionScrollRoot', () => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('prefers a horizontal scroll ancestor (mainstage album rails)', () => {
|
||||
const rail = document.createElement('div');
|
||||
const img = document.createElement('img');
|
||||
Object.defineProperty(rail, 'scrollWidth', { value: 4000, configurable: true });
|
||||
Object.defineProperty(rail, 'clientWidth', { value: 800, configurable: true });
|
||||
rail.style.overflowX = 'auto';
|
||||
rail.appendChild(img);
|
||||
document.body.appendChild(rail);
|
||||
|
||||
expect(resolveIntersectionScrollRoot(img)).toBe(rail);
|
||||
});
|
||||
|
||||
it('prefers the nearest scrolling ancestor', () => {
|
||||
const outer = document.createElement('div');
|
||||
const scroller = document.createElement('div');
|
||||
|
||||
@@ -8,11 +8,14 @@ import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
|
||||
export function resolveIntersectionScrollRoot(node: HTMLElement): Element | null {
|
||||
let parent = node.parentElement;
|
||||
while (parent) {
|
||||
const { overflowY } = window.getComputedStyle(parent);
|
||||
if (
|
||||
const { overflowX, overflowY } = window.getComputedStyle(parent);
|
||||
const scrollableY =
|
||||
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay')
|
||||
&& parent.scrollHeight > parent.clientHeight + 2
|
||||
) {
|
||||
&& parent.scrollHeight > parent.clientHeight + 2;
|
||||
const scrollableX =
|
||||
(overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay')
|
||||
&& parent.scrollWidth > parent.clientWidth + 2;
|
||||
if (scrollableY || scrollableX) {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
|
||||
Reference in New Issue
Block a user