mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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:
@@ -0,0 +1,405 @@
|
||||
//! Library cover backfill — one background pass per wake (native, not webview timers).
|
||||
|
||||
use super::{state, CoverCacheEnsureArgs, CoverCacheState};
|
||||
use psysonic_library::cover_backfill::{
|
||||
clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress,
|
||||
LibraryCoverBackfillBatchDto, LIBRARY_COVER_CANONICAL_TIER,
|
||||
};
|
||||
use psysonic_library::payload::LibrarySyncProgressPayload;
|
||||
use psysonic_library::repos::sync_state::SyncStateRepository;
|
||||
use psysonic_library::LibraryRuntime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Listener, Manager};
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
use super::{count_cached_cover_ids, dir_usage_for_server};
|
||||
|
||||
/// Concurrent library downloads (encode runs on blocking pool; no webview tier events).
|
||||
const LIBRARY_BACKFILL_PARALLEL: usize = 4;
|
||||
const BATCH_SIZE: u32 = 24;
|
||||
const PENDING_RESTART_THRESHOLD: i64 = 32;
|
||||
const SYNC_WAIT_MS: u64 = 5000;
|
||||
const PROGRESS_EVERY_BATCHES: u32 = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CoverBackfillSession {
|
||||
pub server_index_key: String,
|
||||
pub library_server_id: String,
|
||||
pub rest_base_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub struct CoverBackfillWorker {
|
||||
pub enabled: AtomicBool,
|
||||
/// When true, the active pass yields so visible-route cover IPC is not starved.
|
||||
pub ui_priority_hold: AtomicBool,
|
||||
session: Mutex<Option<CoverBackfillSession>>,
|
||||
cursor: Mutex<String>,
|
||||
pass_running: AtomicBool,
|
||||
backfill_http: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverBackfillPulseDto {
|
||||
pub scheduled: u32,
|
||||
pub exhausted: bool,
|
||||
pub pending: i64,
|
||||
pub done: i64,
|
||||
pub total: i64,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverBackfillRunDto {
|
||||
pub started: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncIdlePayload {
|
||||
server_id: String,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
impl CoverBackfillWorker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
enabled: AtomicBool::new(false),
|
||||
ui_priority_hold: AtomicBool::new(false),
|
||||
session: Mutex::new(None),
|
||||
cursor: Mutex::new(String::new()),
|
||||
pass_running: AtomicBool::new(false),
|
||||
backfill_http: Arc::new(Semaphore::new(LIBRARY_BACKFILL_PARALLEL)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ui_priority_hold(&self, hold: bool) {
|
||||
self.ui_priority_hold.store(hold, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub async fn set_session(&self, enabled: bool, session: Option<CoverBackfillSession>) {
|
||||
self.enabled.store(enabled, Ordering::Relaxed);
|
||||
*self.session.lock().await = session;
|
||||
if !enabled {
|
||||
*self.cursor.lock().await = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reset_cursor(&self) {
|
||||
*self.cursor.lock().await = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_allows_cover_backfill(store: &psysonic_library::store::LibraryStore, server_id: &str) -> bool {
|
||||
let repo = SyncStateRepository::new(store);
|
||||
match repo.get_sync_phase(server_id, "") {
|
||||
Ok(Some(phase)) => phase != "initial_sync" && phase != "probing",
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_matches_server(session: &CoverBackfillSession, server_id: &str) -> bool {
|
||||
server_id == session.server_index_key || server_id == session.library_server_id
|
||||
}
|
||||
|
||||
/// Backfill runs only while this session is still the configured focus (active server).
|
||||
async fn session_still_focused(worker: &CoverBackfillWorker, expected: &CoverBackfillSession) -> bool {
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
worker
|
||||
.session
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.server_index_key == expected.server_index_key)
|
||||
}
|
||||
|
||||
async fn progress_snapshot(
|
||||
store: &psysonic_library::store::LibraryStore,
|
||||
root: &std::path::Path,
|
||||
library_server_id: &str,
|
||||
server_index_key: &str,
|
||||
) -> Result<(i64, i64, i64), String> {
|
||||
let cached = count_cached_cover_ids(root, server_index_key);
|
||||
let p = collect_cover_progress(store, library_server_id, root, server_index_key, cached)?;
|
||||
Ok((p.done, p.total_distinct, p.pending))
|
||||
}
|
||||
|
||||
async fn emit_library_progress(
|
||||
app: &AppHandle,
|
||||
session: &CoverBackfillSession,
|
||||
done: i64,
|
||||
total: i64,
|
||||
pending: i64,
|
||||
root: &std::path::Path,
|
||||
) {
|
||||
let (bytes, entry_count) = dir_usage_for_server(root, &session.server_index_key);
|
||||
let _ = app.emit(
|
||||
"cover:library-progress",
|
||||
serde_json::json!({
|
||||
"serverIndexKey": session.server_index_key,
|
||||
"done": done,
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"bytes": bytes,
|
||||
"entryCount": entry_count,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fn ensure_one(
|
||||
worker: &CoverBackfillWorker,
|
||||
st: Arc<tokio::sync::Mutex<CoverCacheState>>,
|
||||
http_sem: Arc<Semaphore>,
|
||||
app: AppHandle,
|
||||
session: CoverBackfillSession,
|
||||
cover_art_id: String,
|
||||
) {
|
||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let args = CoverCacheEnsureArgs {
|
||||
server_index_key: session.server_index_key,
|
||||
cover_art_id,
|
||||
tier: LIBRARY_COVER_CANONICAL_TIER,
|
||||
rest_base_url: session.rest_base_url,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
library_bulk: true,
|
||||
};
|
||||
let _ = CoverCacheState::ensure_inner(&st, &app, &args, Some(http_sem)).await;
|
||||
}
|
||||
|
||||
async fn run_full_pass(app: AppHandle, worker: Arc<CoverBackfillWorker>) {
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let session = worker.session.lock().await.clone();
|
||||
let Some(session) = session else {
|
||||
return;
|
||||
};
|
||||
|
||||
let runtime = match app.try_state::<LibraryRuntime>() {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
|
||||
while !sync_allows_cover_backfill(&runtime.store, &session.library_server_id) {
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(SYNC_WAIT_MS)).await;
|
||||
}
|
||||
|
||||
let st = match state(&app) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let st_arc = st.clone();
|
||||
|
||||
worker.reset_cursor().await;
|
||||
let http_sem = worker.backfill_http.clone();
|
||||
let mut batch_count = 0u32;
|
||||
|
||||
loop {
|
||||
if !session_still_focused(&worker, &session).await {
|
||||
break;
|
||||
}
|
||||
|
||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = worker.cursor.lock().await.clone();
|
||||
let cursor_opt = if cursor.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cursor)
|
||||
};
|
||||
let store = runtime.store.clone();
|
||||
let lib_id = session.library_server_id.clone();
|
||||
let index_key = session.server_index_key.clone();
|
||||
let root_for_batch = root.clone();
|
||||
|
||||
let batch: Option<LibraryCoverBackfillBatchDto> =
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
collect_cover_backfill_batch(
|
||||
&store,
|
||||
&lib_id,
|
||||
&root_for_batch,
|
||||
&index_key,
|
||||
cursor_opt.as_deref(),
|
||||
Some(BATCH_SIZE),
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(b)) => Some(b),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let Some(batch) = batch else {
|
||||
break;
|
||||
};
|
||||
|
||||
batch_count += 1;
|
||||
if !session_still_focused(&worker, &session).await {
|
||||
break;
|
||||
}
|
||||
let ids = batch.cover_ids.clone();
|
||||
let mut paused_for_ui_priority = false;
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for id in ids {
|
||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
paused_for_ui_priority = true;
|
||||
break;
|
||||
}
|
||||
let st = st_arc.clone();
|
||||
let http_sem = http_sem.clone();
|
||||
let app = app.clone();
|
||||
let session = session.clone();
|
||||
let worker_arc = worker.clone();
|
||||
set.spawn(async move {
|
||||
ensure_one(worker_arc.as_ref(), st, http_sem, app, session, id).await;
|
||||
});
|
||||
}
|
||||
while set.join_next().await.is_some() {}
|
||||
if paused_for_ui_priority || worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if batch_count.is_multiple_of(PROGRESS_EVERY_BATCHES) {
|
||||
if let Ok((done, total, pending)) = progress_snapshot(
|
||||
&runtime.store,
|
||||
&root,
|
||||
&session.library_server_id,
|
||||
&session.server_index_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
emit_library_progress(&app, &session, done, total, pending, &root).await;
|
||||
}
|
||||
}
|
||||
|
||||
if batch.exhausted {
|
||||
worker.cursor.lock().await.clear();
|
||||
if let Ok((done, total, pending)) = progress_snapshot(
|
||||
&runtime.store,
|
||||
&root,
|
||||
&session.library_server_id,
|
||||
&session.server_index_key,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if pending > PENDING_RESTART_THRESHOLD {
|
||||
let root3 = root.clone();
|
||||
let index_key3 = session.server_index_key.clone();
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
clear_cover_fetch_failures(&root3, &index_key3)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
emit_library_progress(&app, &session, done, total, pending, &root).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(next) = batch.next_cursor {
|
||||
*worker.cursor.lock().await = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start one full-catalog pass on the Tokio runtime (survives inactive webview).
|
||||
pub async fn try_schedule_full_pass(app: &AppHandle) -> bool {
|
||||
let worker = match app.try_state::<Arc<CoverBackfillWorker>>() {
|
||||
Some(w) => w.inner().clone(),
|
||||
None => return false,
|
||||
};
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
if worker
|
||||
.pass_running
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_full_pass(app, worker.clone()).await;
|
||||
worker.pass_running.store(false, Ordering::SeqCst);
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn on_sync_idle(app: &AppHandle, payload: SyncIdlePayload) {
|
||||
if !payload.ok {
|
||||
return;
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let worker = match app.try_state::<Arc<CoverBackfillWorker>>() {
|
||||
Some(w) => w.inner().clone(),
|
||||
None => return,
|
||||
};
|
||||
if !worker.enabled.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let session = worker.session.lock().await.clone();
|
||||
let Some(session) = session else {
|
||||
return;
|
||||
};
|
||||
if !session_matches_server(&session, &payload.server_id) {
|
||||
return;
|
||||
}
|
||||
let _ = try_schedule_full_pass(&app).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Listen for library sync completion in native code (not throttled with the webview).
|
||||
pub fn setup_library_sync_idle_listener(app: &AppHandle) {
|
||||
let app_handle = app.clone();
|
||||
let _ = app.listen(LibrarySyncProgressPayload::IDLE_EVENT_NAME, move |event| {
|
||||
let Ok(payload) = serde_json::from_str::<SyncIdlePayload>(event.payload()) else {
|
||||
return;
|
||||
};
|
||||
on_sync_idle(&app_handle, payload);
|
||||
});
|
||||
}
|
||||
|
||||
/// Legacy single-step API (optional diagnostics).
|
||||
pub async fn pulse_backfill(app: &AppHandle, _worker: &Arc<CoverBackfillWorker>) -> CoverBackfillPulseDto {
|
||||
if try_schedule_full_pass(app).await {
|
||||
return CoverBackfillPulseDto {
|
||||
scheduled: 0,
|
||||
exhausted: false,
|
||||
pending: 0,
|
||||
done: 0,
|
||||
total: 0,
|
||||
status: "active".into(),
|
||||
};
|
||||
}
|
||||
CoverBackfillPulseDto {
|
||||
scheduled: 0,
|
||||
exhausted: true,
|
||||
pending: 0,
|
||||
done: 0,
|
||||
total: 0,
|
||||
status: "disabled".into(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user