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
@@ -78,10 +78,6 @@ export default function AnalyticsStrategySection() {
return Array.from(known).filter(id => !activeServerIds.has(id));
}, [strategyByServer, advancedParallelismByServer, activeServerIds]);
const anyAggressive = useMemo(() => {
return servers.some(server => getStrategyForServer(server.id) === 'advanced');
}, [servers, getStrategyForServer]);
useEffect(() => {
if (servers.length === 0) return;
let cancelled = false;
@@ -257,10 +253,6 @@ export default function AnalyticsStrategySection() {
icon={<BarChart3 size={16} />}
>
<div className="settings-card">
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.analyticsStrategyDesc')}
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
<thead>
@@ -398,24 +390,9 @@ export default function AnalyticsStrategySection() {
</table>
</div>
<div
style={{
marginTop: '0.85rem',
padding: '0.65rem 0.75rem',
borderRadius: 8,
background: 'var(--surface-elevated, rgba(255,255,255,0.03))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
}}
>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: '0.45rem', color: 'var(--text-secondary)' }}>
{t('settings.analyticsStrategyPriorityTitle')}
</div>
<ul style={{ margin: 0, paddingLeft: '1.1rem', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
<li>{t('settings.analyticsStrategyPriorityHigh')}</li>
<li>{t('settings.analyticsStrategyPriorityMiddle')}</li>
<li>{t('settings.analyticsStrategyPriorityLow')}</li>
</ul>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
{t('settings.analyticsStrategyDesc')}
</p>
<div
style={{
@@ -445,18 +422,16 @@ export default function AnalyticsStrategySection() {
</div>
</div>
{anyAggressive && (
<div
className="settings-hint settings-hint-info"
role="note"
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
>
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
{t('settings.analyticsStrategyAdvancedWarning')}
</span>
</div>
)}
<div
className="settings-hint settings-hint-info"
role="note"
style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}
>
<AlertTriangle size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2, color: 'var(--color-warning, #f59e0b)' }} />
<span style={{ fontSize: 12, lineHeight: 1.5 }}>
{t('settings.analyticsStrategyAdvancedWarning')}
</span>
</div>
</div>
{clearTarget &&
@@ -0,0 +1,318 @@
import { Image, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event';
import SettingsSubSection from '../SettingsSubSection';
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
import { useAuthStore } from '../../store/authStore';
import {
coverCacheClearServer,
coverCacheStatsServer,
libraryCoverCatalogSize,
libraryCoverProgress,
} from '../../api/coverCache';
import { clearDiskSrcCacheForServer } from '../../cover/diskSrcCache';
import { serverListDisplayLabel } from '../../utils/server/serverDisplayName';
import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey';
import { showToast } from '../../utils/ui/toast';
import { formatBytes } from '../../utils/format/formatBytes';
import { wakeLibraryCoverBackfill } from '../../utils/library/coverBackfillWake';
import {
COVER_CACHE_STRATEGIES,
type CoverCacheStrategy,
} from '../../utils/library/coverStrategy';
type ClearTarget = {
serverId: string;
indexKey: string;
label: string;
};
type ServerRowState = {
bytes: number;
entryCount: number;
done: number;
total: number;
pending: number;
};
export default function CoverCacheStrategySection() {
const { t } = useTranslation();
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const { strategyByServer, setServerStrategy, getStrategyForServer } = useCoverStrategyStore();
const [rowState, setRowState] = useState<Record<string, ServerRowState>>({});
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
const [clearingKey, setClearingKey] = useState<string | null>(null);
const activeIndexKeys = useMemo(
() => new Set(servers.map(server => serverIndexKeyForProfile(server))),
[servers],
);
const removedServerKeys = useMemo(() => {
const known = new Set(Object.keys(strategyByServer));
return Array.from(known).filter(key => !activeIndexKeys.has(key));
}, [strategyByServer, activeIndexKeys]);
const refreshRow = useCallback(async (serverId: string, indexKey: string) => {
const [stats, progress, catalog] = await Promise.all([
coverCacheStatsServer(indexKey).catch(() => ({ bytes: 0, entryCount: 0 })),
libraryCoverProgress(indexKey, serverId).catch(() => ({ done: 0, totalDistinct: 0, pending: 0 })),
libraryCoverCatalogSize(serverId).catch(() => 0),
]);
const total = Math.max(progress.totalDistinct, catalog);
setRowState(prev => ({
...prev,
[indexKey]: {
bytes: stats.bytes,
entryCount: stats.entryCount,
done: progress.done,
total,
pending: Math.max(progress.pending, total > 0 ? total - progress.done : 0),
},
}));
}, []);
const refreshAll = useCallback(() => {
void Promise.all(
servers.map(server => refreshRow(server.id, serverIndexKeyForProfile(server))),
);
}, [servers, refreshRow]);
useEffect(() => {
refreshAll();
const id = window.setInterval(refreshAll, 15_000);
return () => window.clearInterval(id);
}, [refreshAll]);
useEffect(() => {
const unsubs: Array<() => void> = [];
void (async () => {
unsubs.push(await listen<{
serverIndexKey?: string;
done?: number;
total?: number;
pending?: number;
bytes?: number;
entryCount?: number;
}>('cover:library-progress', e => {
const key = e.payload.serverIndexKey;
if (!key) return;
setRowState(prev => {
const cur = prev[key];
if (!cur) return prev;
const done = typeof e.payload.done === 'number' ? e.payload.done : cur.done;
const total = typeof e.payload.total === 'number' ? e.payload.total : cur.total;
return {
...prev,
[key]: {
...cur,
done,
total,
pending: e.payload.pending ?? Math.max(0, total - done),
bytes: typeof e.payload.bytes === 'number' ? e.payload.bytes : cur.bytes,
entryCount:
typeof e.payload.entryCount === 'number' ? e.payload.entryCount : cur.entryCount,
},
};
});
}));
unsubs.push(await listen<{ serverIndexKey?: string }>('cover:cache-cleared', e => {
const key = e.payload.serverIndexKey;
if (key) {
setRowState(prev => ({
...prev,
[key]: { bytes: 0, entryCount: 0, done: 0, total: prev[key]?.total ?? 0, pending: prev[key]?.total ?? 0 },
}));
} else {
refreshAll();
}
}));
unsubs.push(await listen('cover:tier-ready', () => {
refreshAll();
}));
})();
return () => {
for (const u of unsubs) u();
};
}, [refreshAll]);
const strategyLabel = (s: CoverCacheStrategy) => {
switch (s) {
case 'lazy':
return t('settings.coverCacheStrategyLazy');
case 'aggressive':
return t('settings.coverCacheStrategyAggressive');
}
};
const progressLabel = (row: ServerRowState | undefined, strategy: CoverCacheStrategy) => {
if (!row || strategy !== 'aggressive' || row.total <= 0) {
return row ? t('settings.coverCacheStrategyDiskUsage', { size: formatBytes(row.bytes) }) : '—';
}
const percent = Math.max(0, Math.min(100, Math.round((row.done / row.total) * 100)));
return t('settings.coverCacheStrategyProgressValue', {
percent,
done: row.done.toLocaleString(),
total: row.total.toLocaleString(),
size: formatBytes(row.bytes),
});
};
const handleStrategyChange = (serverId: string, strategy: CoverCacheStrategy) => {
setServerStrategy(serverId, strategy);
if (serverId === activeServerId) {
wakeLibraryCoverBackfill();
}
};
const handleClearCoverCache = async () => {
if (!clearTarget) return;
setClearingKey(clearTarget.indexKey);
try {
await coverCacheClearServer(clearTarget.indexKey);
clearDiskSrcCacheForServer(clearTarget.indexKey);
await refreshRow(clearTarget.serverId, clearTarget.indexKey);
showToast(t('settings.coverCacheStrategyClearSuccess'), 4000, 'success');
} catch {
showToast(t('settings.coverCacheStrategyClearError'), 5000, 'error');
} finally {
setClearingKey(null);
setClearTarget(null);
}
};
return (
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
<div className="settings-card">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 520 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyServerLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyProgressLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.coverCacheStrategyActionsLabel')}
</th>
</tr>
</thead>
<tbody>
{servers.map(server => {
const strategy = getStrategyForServer(server.id);
const key = serverIndexKeyForProfile(server);
const row = rowState[key];
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
<td style={{ padding: '10px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{COVER_CACHE_STRATEGIES.map(s => (
<button
key={s}
type="button"
className={`btn btn-sm ${strategy === s ? 'btn-primary' : 'btn-surface'}`}
onClick={() => handleStrategyChange(server.id, s)}
>
{strategyLabel(s)}
</button>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, lineHeight: 1.4 }}>
{strategy === 'aggressive'
? t('settings.coverCacheStrategyAggressiveDesc')
: t('settings.coverCacheStrategyLazyDesc')}
</div>
</td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
{progressLabel(row, strategy)}
</td>
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() =>
setClearTarget({ serverId: server.id, indexKey: key, label })}
>
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
</button>
</td>
</tr>
);
})}
{removedServerKeys.map(key => (
<tr key={`removed-${key}`} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
{key}
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
</td>
<td colSpan={2} />
<td style={{ padding: '10px' }}>
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={() =>
setClearTarget({ serverId: key, indexKey: key, label: key })}
>
<Trash2 size={14} /> {t('settings.coverCacheStrategyClearAction')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginTop: '0.9rem', lineHeight: 1.5 }}>
{t('settings.coverCacheStrategyDesc')}
</p>
{clearTarget && (
<div
style={{
marginTop: 16,
background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '10px 14px',
fontSize: 13,
}}
>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{t('settings.coverCacheStrategyClearTitle')}</div>
<div style={{ marginBottom: 10, lineHeight: 1.5 }}>
{t('settings.coverCacheStrategyClearDesc', { server: clearTarget.label })}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
disabled={clearingKey !== null}
onClick={() => void handleClearCoverCache()}
>
{t('settings.coverCacheStrategyClearConfirm')}
</button>
<button
type="button"
className="btn btn-ghost"
style={{ fontSize: 13 }}
disabled={clearingKey !== null}
onClick={() => setClearTarget(null)}
>
{t('settings.coverCacheStrategyClearCancel')}
</button>
</div>
</div>
)}
</div>
</SettingsSubSection>
);
}
-1
View File
@@ -6,7 +6,6 @@ import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
import SettingsSubSection from '../SettingsSubSection';
import StarRating from '../StarRating';
import AnalyticsStrategySection from './AnalyticsStrategySection';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
export function LibraryTab() {
+4 -32
View File
@@ -6,11 +6,10 @@ import { Download, FolderOpen, Trash2, X } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
import { useOfflineStore } from '../../store/offlineStore';
import { usePlayerStore } from '../../store/playerStore';
import { clearImageCache, getImageCacheSize } from '../../utils/imageCache';
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
import { showToast } from '../../utils/ui/toast';
import SettingsSubSection from '../SettingsSubSection';
import CoverCacheStrategySection from './CoverCacheStrategySection';
export function StorageTab() {
const { t } = useTranslation();
@@ -75,26 +74,6 @@ export function StorageTab() {
setClearing(false);
}, [clearAllOffline, serverId, auth.offlineDownloadDir]);
const handleClearWaveformCache = useCallback(async () => {
setClearing(true);
try {
const deleted = await invoke<number>('analysis_delete_all_waveforms');
usePlayerStore.setState({
waveformBins: null,
});
showToast(
t('settings.waveformCacheCleared', { count: deleted }),
3500,
'success',
);
} catch (e) {
console.error(e);
showToast(t('settings.waveformCacheClearFailed'), 4500, 'error');
} finally {
setClearing(false);
}
}, [t]);
const pickOfflineDir = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
if (selected && typeof selected === 'string') {
@@ -190,6 +169,7 @@ export function StorageTab() {
/>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
</div>
{showClearConfirm ? (
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
@@ -212,19 +192,11 @@ export function StorageTab() {
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
)}
<div style={{ marginTop: 8 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 13 }}
onClick={handleClearWaveformCache}
disabled={clearing}
>
<Trash2 size={14} /> {t('settings.waveformCacheClearBtn')}
</button>
</div>
</div>
</SettingsSubSection>
<CoverCacheStrategySection />
{/* Buffering */}
<SettingsSubSection
title={t('settings.nextTrackBufferingTitle')}
+1
View File
@@ -54,6 +54,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
{ tab: 'library', titleKey: 'settings.analyticsStrategyTitle', keywords: 'analytics strategy analysis bpm enrichment waveform lazy advanced library backfill' },
{ tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
{ tab: 'storage', titleKey: 'settings.coverCacheStrategyTitle', keywords: 'cover art cache webp aggressive lazy disk per server' },
{ tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
{ tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' },
{ tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },