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:
Generated
+63
@@ -2216,6 +2216,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -2456,9 +2462,22 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2805,6 +2824,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.3.8"
|
||||
@@ -4014,6 +4043,7 @@ dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
"id3",
|
||||
"image",
|
||||
"libc",
|
||||
"lofty",
|
||||
"md5",
|
||||
@@ -4049,6 +4079,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-nvidia-quirk",
|
||||
"webp",
|
||||
"windows 0.62.2",
|
||||
"zbus 5.15.0",
|
||||
"zip 0.6.6",
|
||||
@@ -4173,6 +4204,12 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.3"
|
||||
@@ -5672,6 +5709,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"image",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
@@ -6907,6 +6945,16 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"libwebp-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
@@ -8083,6 +8131,21 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "3.15.2"
|
||||
|
||||
@@ -42,7 +42,7 @@ psysonic-audio = { path = "crates/psysonic-audio" }
|
||||
psysonic-library = { path = "crates/psysonic-library" }
|
||||
psysonic-syncfs = { path = "crates/psysonic-syncfs" }
|
||||
psysonic-integration = { path = "crates/psysonic-integration" }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
@@ -74,6 +74,8 @@ rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
dasp_sample = "0.11.0"
|
||||
zip = "0.6.6"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "webp"] }
|
||||
webp = "0.3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1094,6 +1094,9 @@ fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
|
||||
("year", EntityKind::Album) => Some("a.year"),
|
||||
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
|
||||
("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"),
|
||||
// SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample,
|
||||
// no index scan needed beyond the row-id range. Direction is ignored.
|
||||
("random", _) => Some("RANDOM()"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Library cursor scan for background cover disk warm-up.
|
||||
//!
|
||||
//! Cover IDs for backfill come from **track** + **album** rows using
|
||||
//! `COALESCE(cover_art_id, album_id)` (album table id as fallback).
|
||||
//! Artist IDs are excluded — `getCoverArt` with `artist_id` often 404s and stalled the queue.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
const DEFAULT_BATCH: u32 = 32;
|
||||
const MAX_BATCH: u32 = 48;
|
||||
const SCAN_PAGE: i64 = 256;
|
||||
const MAX_SCAN_PAGES: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverBackfillBatchDto {
|
||||
pub cover_ids: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverProgressDto {
|
||||
pub total_distinct: i64,
|
||||
pub pending: i64,
|
||||
pub done: i64,
|
||||
}
|
||||
|
||||
const COVER_ID_SUBQUERY: &str = "
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(album_id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
||||
FROM track
|
||||
WHERE server_id = ?1 AND deleted = 0
|
||||
AND (
|
||||
NULLIF(TRIM(album_id), '') IS NOT NULL
|
||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
||||
)
|
||||
UNION
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
||||
FROM album
|
||||
WHERE server_id = ?1
|
||||
AND (
|
||||
NULLIF(TRIM(id), '') IS NOT NULL
|
||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
||||
)";
|
||||
|
||||
pub const COVER_FETCH_FAIL_MARKER: &str = ".fetch-failed";
|
||||
|
||||
/// Recent HTTP failure — skip in backfill cursor so slots go to fetchable album art.
|
||||
pub fn cover_fetch_recently_failed(cover_dir: &Path) -> bool {
|
||||
let marker = cover_dir.join(COVER_FETCH_FAIL_MARKER);
|
||||
let Ok(meta) = std::fs::metadata(&marker) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(modified) = meta.modified() else {
|
||||
return true;
|
||||
};
|
||||
modified
|
||||
.elapsed()
|
||||
.map(|e| e < std::time::Duration::from_secs(30 * 60))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Remove `.fetch-failed` markers so the next library pass retries HTTP.
|
||||
pub fn clear_cover_fetch_failures(cover_root: &Path, server_index_key: &str) -> u32 {
|
||||
let server_dir = cover_root.join(server_index_key);
|
||||
let Ok(entries) = std::fs::read_dir(&server_dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut cleared = 0u32;
|
||||
for id_dir in entries.flatten() {
|
||||
let marker = id_dir.path().join(COVER_FETCH_FAIL_MARKER);
|
||||
if marker.is_file() && std::fs::remove_file(&marker).is_ok() {
|
||||
cleared += 1;
|
||||
}
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
fn fetch_cover_id_page(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
after: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<String>, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!(
|
||||
"SELECT id FROM ({COVER_ID_SUBQUERY})
|
||||
WHERE id > ?2
|
||||
ORDER BY id ASC
|
||||
LIMIT ?3"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let ids = stmt
|
||||
.query_map(rusqlite::params![library_server_id, after, limit], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ids)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count_distinct_cover_ids(store: &LibraryStore, library_server_id: &str) -> Result<i64, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!("SELECT COUNT(*) FROM ({COVER_ID_SUBQUERY})");
|
||||
conn.query_row(&sql, rusqlite::params![library_server_id], |row| row.get(0))
|
||||
})
|
||||
}
|
||||
|
||||
/// Library warm-up target tier — HTTP fetch size and progress heuristic.
|
||||
pub const LIBRARY_COVER_CANONICAL_TIER: u32 = 800;
|
||||
|
||||
/// WebP ladder written by aggressive backfill (must match `cover_cache::DERIVE_TIERS`).
|
||||
pub const LIBRARY_COVER_DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800];
|
||||
|
||||
fn tier_file_ready(dir: &Path, tier: u32) -> bool {
|
||||
let path = dir.join(format!("{tier}.webp"));
|
||||
path.is_file() && path.metadata().map(|m| m.len() > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn cover_ladder_complete_on_disk(dir: &Path) -> bool {
|
||||
LIBRARY_COVER_DERIVE_TIERS
|
||||
.iter()
|
||||
.all(|&tier| tier_file_ready(dir, tier))
|
||||
}
|
||||
|
||||
pub fn cover_canonical_cached_on_disk(
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cover_art_id: &str,
|
||||
) -> bool {
|
||||
let dir = cover_root.join(server_index_key).join(cover_art_id);
|
||||
tier_file_ready(&dir, LIBRARY_COVER_CANONICAL_TIER)
|
||||
}
|
||||
|
||||
pub fn cover_ladder_cached_on_disk(
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cover_art_id: &str,
|
||||
) -> bool {
|
||||
let dir = cover_root.join(server_index_key).join(cover_art_id);
|
||||
cover_ladder_complete_on_disk(&dir)
|
||||
}
|
||||
|
||||
pub fn collect_cover_backfill_batch(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<LibraryCoverBackfillBatchDto, String> {
|
||||
let want = limit.unwrap_or(DEFAULT_BATCH).min(MAX_BATCH) as usize;
|
||||
let mut after = cursor.map(str::to_string).unwrap_or_default();
|
||||
let mut pending = Vec::with_capacity(want);
|
||||
let mut sql_exhausted = false;
|
||||
|
||||
for _ in 0..MAX_SCAN_PAGES {
|
||||
if pending.len() >= want {
|
||||
break;
|
||||
}
|
||||
let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
if page.is_empty() {
|
||||
sql_exhausted = true;
|
||||
break;
|
||||
}
|
||||
for id in &page {
|
||||
after.clone_from(id);
|
||||
let dir = cover_root.join(server_index_key).join(id);
|
||||
if cover_canonical_cached_on_disk(cover_root, server_index_key, id)
|
||||
|| cover_fetch_recently_failed(&dir)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
pending.push(id.clone());
|
||||
if pending.len() >= want {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (page.len() as i64) < SCAN_PAGE {
|
||||
sql_exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LibraryCoverBackfillBatchDto {
|
||||
cover_ids: pending,
|
||||
next_cursor: if sql_exhausted { None } else { Some(after) },
|
||||
exhausted: sql_exhausted,
|
||||
})
|
||||
}
|
||||
|
||||
/// Distinct library cover IDs still missing canonical `800.webp` (not raw dir count on disk).
|
||||
pub fn count_pending_canonical_covers(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
) -> Result<i64, String> {
|
||||
let mut after = String::new();
|
||||
let mut pending = 0i64;
|
||||
loop {
|
||||
let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
for id in &page {
|
||||
after.clone_from(id);
|
||||
if !cover_canonical_cached_on_disk(cover_root, server_index_key, id) {
|
||||
pending += 1;
|
||||
}
|
||||
}
|
||||
if (page.len() as i64) < SCAN_PAGE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
/// UI progress — fast approximate counts (no full-library disk walk).
|
||||
pub fn collect_cover_progress(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
_cover_root: &Path,
|
||||
_server_index_key: &str,
|
||||
cached_dirs_with_canonical: i64,
|
||||
) -> Result<LibraryCoverProgressDto, String> {
|
||||
let total = count_distinct_cover_ids(store, library_server_id)?;
|
||||
let done = cached_dirs_with_canonical.min(total);
|
||||
Ok(LibraryCoverProgressDto {
|
||||
total_distinct: total,
|
||||
pending: (total - done).max(0),
|
||||
done,
|
||||
})
|
||||
}
|
||||
|
||||
/// Accurate pending count — expensive; run off the UI thread only.
|
||||
#[allow(dead_code)]
|
||||
pub fn collect_cover_progress_accurate(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
) -> Result<LibraryCoverProgressDto, String> {
|
||||
let total = count_distinct_cover_ids(store, library_server_id)?;
|
||||
let pending = count_pending_canonical_covers(
|
||||
store,
|
||||
library_server_id,
|
||||
cover_root,
|
||||
server_index_key,
|
||||
)?;
|
||||
let done = (total - pending).max(0);
|
||||
Ok(LibraryCoverProgressDto {
|
||||
total_distinct: total,
|
||||
pending,
|
||||
done,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, album_id: &str, cover: Option<&str>) {
|
||||
store
|
||||
.with_conn_mut("test_seed", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, duration_sec, deleted, synced_at, raw_json,
|
||||
cover_art_id
|
||||
) VALUES (?1, ?2, 't', 'al', ?3, 200, 0, 1, '{}', ?4)",
|
||||
rusqlite::params![server_id, track_id, album_id, cover],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_uses_track_album_id_when_cover_art_null() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-99", None);
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
Path::new("/tmp/empty-cover-root"),
|
||||
"srv-host",
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.cover_ids, vec!["al-99".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_skips_when_canonical_800_exists() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-partial", None);
|
||||
let root = std::env::temp_dir().join("psysonic-cover-backfill-test");
|
||||
let host = "srv-host";
|
||||
let id_dir = root.join(host).join("al-partial");
|
||||
std::fs::create_dir_all(&id_dir).unwrap();
|
||||
std::fs::write(id_dir.join("128.webp"), b"x").unwrap();
|
||||
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
&root,
|
||||
host,
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.cover_ids, vec!["al-partial".to_string()]);
|
||||
|
||||
std::fs::write(id_dir.join("800.webp"), b"canonical").unwrap();
|
||||
let batch2 = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
&root,
|
||||
host,
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(batch2.cover_ids.is_empty());
|
||||
|
||||
let _ = std::fs::remove_dir_all(root.join(host));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_distinct_includes_artist_ids() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-1", Some("cv-1"));
|
||||
store
|
||||
.with_conn_mut("test_artist", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json
|
||||
) VALUES ('srv', 'tr2', 't', 'al', 'al-2', 'ar-1', 200, 0, 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let n = count_distinct_cover_ids(&store, "srv").unwrap();
|
||||
assert_eq!(n, 2); // cv-1, al-1 — artist ids excluded from backfill catalog
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub(crate) mod bulk_ingest;
|
||||
pub mod advanced_search;
|
||||
mod advanced_search_mood;
|
||||
pub mod analysis_backfill;
|
||||
pub mod cover_backfill;
|
||||
pub mod canonical;
|
||||
pub mod commands;
|
||||
pub mod cross_server;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800];
|
||||
|
||||
/// `server_index_key` — host (+ optional path), same bucket as library `server_id`.
|
||||
pub fn cover_dir(root: &Path, server_index_key: &str, cover_art_id: &str) -> PathBuf {
|
||||
root.join(server_index_key).join(cover_art_id)
|
||||
}
|
||||
|
||||
pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
|
||||
dir.join(format!("{tier}.webp"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn meta_path(dir: &Path) -> PathBuf {
|
||||
dir.join("meta.json")
|
||||
}
|
||||
|
||||
pub fn tier_exists(dir: &Path, tier: u32) -> Option<PathBuf> {
|
||||
let p = tier_path(dir, tier);
|
||||
if p.is_file() { Some(p) } else { None }
|
||||
}
|
||||
|
||||
/// Write missing WebP tiers up to `max_tier` (used by library bulk backfill).
|
||||
pub fn write_derived_webp_tiers(
|
||||
dir: &Path,
|
||||
img: &image::DynamicImage,
|
||||
max_tier: u32,
|
||||
) -> Result<(), String> {
|
||||
use super::encode::write_webp_tier;
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
for &tier in DERIVE_TIERS.iter() {
|
||||
if tier > max_tier {
|
||||
continue;
|
||||
}
|
||||
if tier_exists(dir, tier).is_some() {
|
||||
continue;
|
||||
}
|
||||
write_webp_tier(img, tier, &tier_path(dir, tier))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use image::imageops::FilterType;
|
||||
use image::DynamicImage;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn resize_tier(img: &DynamicImage, tier: u32) -> DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let max_dim = w.max(h);
|
||||
if max_dim <= tier {
|
||||
return img.clone();
|
||||
}
|
||||
let scale = tier as f32 / max_dim as f32;
|
||||
let nw = (w as f32 * scale).round().max(1.0) as u32;
|
||||
let nh = (h as f32 * scale).round().max(1.0) as u32;
|
||||
img.resize(nw, nh, FilterType::Triangle)
|
||||
}
|
||||
|
||||
/// Lossy WebP quality (0–100). Larger tiers use lower Q — UI rarely shows 800px raw;
|
||||
/// dense grids cap at 512px (see `COVER_ART_DENSE_MAX_TIER`).
|
||||
pub fn webp_quality_for_tier(tier: u32) -> f32 {
|
||||
match tier {
|
||||
2000 => 82.0,
|
||||
800 => 70.0,
|
||||
512 => 73.0,
|
||||
256 => 76.0,
|
||||
128 => 78.0,
|
||||
_ => 74.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_webp(img: &DynamicImage, tier: u32) -> Result<Vec<u8>, String> {
|
||||
let rgba = img.to_rgba8();
|
||||
let enc = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height());
|
||||
Ok(enc.encode(webp_quality_for_tier(tier)).to_vec())
|
||||
}
|
||||
|
||||
pub fn write_webp_tier(img: &DynamicImage, tier: u32, path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let resized = resize_tier(img, tier);
|
||||
let bytes = encode_webp(&resized, tier)?;
|
||||
std::fs::write(path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::RgbaImage;
|
||||
|
||||
#[test]
|
||||
fn resize_tier_scales_down_large_canvas() {
|
||||
let img = DynamicImage::ImageRgba8(RgbaImage::new(800, 600));
|
||||
let out = resize_tier(&img, 128);
|
||||
assert!(out.width() <= 128);
|
||||
assert!(out.height() <= 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_quality_decreases_with_tier_size() {
|
||||
assert!(webp_quality_for_tier(800) < webp_quality_for_tier(512));
|
||||
assert!(webp_quality_for_tier(512) < webp_quality_for_tier(128));
|
||||
assert!(webp_quality_for_tier(800) < webp_quality_for_tier(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_encode_800_smaller_than_old_lossless_upper_bound() {
|
||||
let img = DynamicImage::ImageRgba8(RgbaImage::new(800, 800));
|
||||
let bytes = encode_webp(&img, 800).expect("webp");
|
||||
assert!(
|
||||
bytes.len() < 250_000,
|
||||
"expected lossy 800 webp well under legacy JPEG cap, got {} bytes",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use reqwest::Client;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
|
||||
const SUBSONIC_CLIENT: &str = "Psysonic";
|
||||
|
||||
fn random_salt() -> String {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
|
||||
pub fn build_cover_art_url(
|
||||
rest_base: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
cover_art_id: &str,
|
||||
size: u32,
|
||||
) -> String {
|
||||
let base = rest_base.trim_end_matches('/');
|
||||
let api_base = if base.ends_with("/rest") {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/rest")
|
||||
};
|
||||
let salt = random_salt();
|
||||
let token = format!("{:x}", md5::compute(format!("{password}{salt}")));
|
||||
let endpoint = format!("{api_base}/getCoverArt.view");
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("id", cover_art_id);
|
||||
serializer.append_pair("size", &size.to_string());
|
||||
serializer.append_pair("u", username);
|
||||
serializer.append_pair("t", &token);
|
||||
serializer.append_pair("s", &salt);
|
||||
serializer.append_pair("v", "1.16.1");
|
||||
serializer.append_pair("c", SUBSONIC_CLIENT);
|
||||
let query = serializer.finish();
|
||||
match Url::parse(&endpoint) {
|
||||
Ok(mut url) => {
|
||||
url.set_query(Some(&query));
|
||||
url.to_string()
|
||||
}
|
||||
Err(_) => format!("{endpoint}?{query}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_cover_bytes(client: &Client, url: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("cover HTTP {}", resp.status()));
|
||||
}
|
||||
resp.bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_cover_art_url;
|
||||
|
||||
#[test]
|
||||
fn cover_url_from_host_root() {
|
||||
let url = build_cover_art_url(
|
||||
"http://navidrome.local:4533",
|
||||
"u",
|
||||
"p",
|
||||
"al-1",
|
||||
800,
|
||||
);
|
||||
assert!(url.starts_with("http://navidrome.local:4533/rest/getCoverArt.view?"));
|
||||
assert!(url.contains("id=al-1"));
|
||||
assert!(url.contains("size=800"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_url_when_rest_suffix_already_present() {
|
||||
let url = build_cover_art_url(
|
||||
"http://navidrome.local:4533/rest",
|
||||
"u",
|
||||
"p",
|
||||
"al-1",
|
||||
128,
|
||||
);
|
||||
assert!(url.starts_with("http://navidrome.local:4533/rest/getCoverArt.view?"));
|
||||
assert!(!url.contains("/rest/rest/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_url_does_not_panic_on_malformed_base() {
|
||||
let url = build_cover_art_url("://bad-url", "u", "p", "al-1", 128);
|
||||
assert!(url.contains("/rest/getCoverArt.view?"));
|
||||
assert!(url.contains("id=al-1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
//! Cover art disk cache — WebP tiers, prefetch, revalidation (phase B).
|
||||
|
||||
mod backfill_worker;
|
||||
mod disk;
|
||||
mod encode;
|
||||
mod fetch;
|
||||
|
||||
use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS};
|
||||
use encode::write_webp_tier;
|
||||
use fetch::{build_cover_art_url, fetch_cover_bytes};
|
||||
use image::{DynamicImage, ImageReader};
|
||||
use psysonic_library::cover_backfill::{
|
||||
clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress,
|
||||
count_distinct_cover_ids, cover_fetch_recently_failed, LibraryCoverBackfillBatchDto,
|
||||
LibraryCoverProgressDto, COVER_FETCH_FAIL_MARKER,
|
||||
};
|
||||
use psysonic_library::LibraryRuntime;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCacheEnsureResult {
|
||||
pub hit: bool,
|
||||
pub path: String,
|
||||
pub tier: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCacheStatsDto {
|
||||
pub bytes: u64,
|
||||
pub count: u64,
|
||||
pub pressure: String,
|
||||
pub auto_download_enabled: bool,
|
||||
pub entry_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCacheEnsureArgs {
|
||||
pub server_index_key: String,
|
||||
pub cover_art_id: String,
|
||||
pub tier: u32,
|
||||
pub rest_base_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Library backfill: all derived tiers, no `cover:tier-ready` floods to the webview.
|
||||
#[serde(default)]
|
||||
pub library_bulk: bool,
|
||||
}
|
||||
|
||||
/// Cap concurrent cover HTTP fetches (library backfill + UI share this pool).
|
||||
const COVER_HTTP_CONCURRENCY: usize = 16;
|
||||
|
||||
pub struct CoverCacheState {
|
||||
pub root: PathBuf,
|
||||
pub client: Client,
|
||||
pub max_bytes: u64,
|
||||
pub high_watermark_pct: u64,
|
||||
pub resume_watermark_pct: u64,
|
||||
pub http_sem: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl CoverCacheState {
|
||||
pub fn new(root: PathBuf) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(&root).map_err(|e| e.to_string())?;
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(25))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
root,
|
||||
client,
|
||||
max_bytes: 10 * 1024 * 1024 * 1024,
|
||||
high_watermark_pct: 90,
|
||||
resume_watermark_pct: 85,
|
||||
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
|
||||
})
|
||||
}
|
||||
|
||||
fn pressure_from_bytes(&self, _bytes: u64) -> (String, bool) {
|
||||
("ok".into(), true)
|
||||
}
|
||||
|
||||
fn pressure(&self) -> (String, bool) {
|
||||
let (bytes, _) = dir_usage_at_root(&self.root);
|
||||
self.pressure_from_bytes(bytes)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_inner(
|
||||
state: &Arc<Mutex<CoverCacheState>>,
|
||||
app: &AppHandle,
|
||||
args: &CoverCacheEnsureArgs,
|
||||
http_sem_override: Option<Arc<Semaphore>>,
|
||||
) -> Result<CoverCacheEnsureResult, String> {
|
||||
let this = state.lock().await;
|
||||
let dir = cover_dir(&this.root, &args.server_index_key, &args.cover_art_id);
|
||||
if let Some(path) = peek_tier_path(&dir, args.tier) {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
|
||||
let (_, auto_dl) = this.pressure();
|
||||
if !auto_dl && args.tier != 2000 {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
|
||||
let client = this.client.clone();
|
||||
let root = this.root.clone();
|
||||
let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.clone());
|
||||
drop(this);
|
||||
|
||||
if cover_fetch_recently_failed(&dir) {
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
|
||||
let img = match load_cover_source(&dir, &client, &http_sem, args).await {
|
||||
Ok(img) => img,
|
||||
Err(_) => {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let _ = std::fs::write(dir.join(COVER_FETCH_FAIL_MARKER), b"1");
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: args.tier,
|
||||
});
|
||||
}
|
||||
};
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let requested = args.tier;
|
||||
let quiet = args.library_bulk;
|
||||
let tiers_now: Vec<u32> = if args.library_bulk {
|
||||
DERIVE_TIERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| *t <= requested)
|
||||
.collect()
|
||||
} else if requested == 2000 {
|
||||
vec![2000]
|
||||
} else {
|
||||
DERIVE_TIERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| *t <= requested)
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut wrote_requested = false;
|
||||
if quiet {
|
||||
let dir_bg = dir.clone();
|
||||
let img_bg = img.clone();
|
||||
let max_tier = requested;
|
||||
let wrote = tauri::async_runtime::spawn_blocking(move || -> Result<bool, String> {
|
||||
disk::write_derived_webp_tiers(&dir_bg, &img_bg, max_tier)?;
|
||||
Ok(tier_exists(&dir_bg, max_tier).is_some())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
wrote_requested = wrote;
|
||||
} else {
|
||||
for tier in tiers_now {
|
||||
if tier_exists(&dir, tier).is_some() {
|
||||
if tier == requested {
|
||||
wrote_requested = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let path = tier_path(&dir, tier);
|
||||
write_webp_tier(&img, tier, &path)?;
|
||||
emit_tier_ready(app, args, tier, &path);
|
||||
if tier == requested {
|
||||
wrote_requested = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !wrote_requested && tier_exists(&dir, requested).is_some() {
|
||||
wrote_requested = true;
|
||||
}
|
||||
|
||||
let out_path = tier_path(&dir, requested);
|
||||
if wrote_requested || out_path.is_file() {
|
||||
if !quiet {
|
||||
spawn_derive_remaining_tiers(
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
root,
|
||||
args.clone(),
|
||||
img,
|
||||
requested,
|
||||
);
|
||||
}
|
||||
return Ok(CoverCacheEnsureResult {
|
||||
hit: true,
|
||||
path: out_path.to_string_lossy().into_owned(),
|
||||
tier: requested,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CoverCacheEnsureResult {
|
||||
hit: false,
|
||||
path: String::new(),
|
||||
tier: requested,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_tier_ready(app: &AppHandle, args: &CoverCacheEnsureArgs, tier: u32, path: &Path) {
|
||||
let Ok(meta) = std::fs::metadata(path) else {
|
||||
return;
|
||||
};
|
||||
if !meta.is_file() || meta.len() == 0 {
|
||||
return;
|
||||
}
|
||||
let _ = app.emit(
|
||||
"cover:tier-ready",
|
||||
serde_json::json!({
|
||||
"serverIndexKey": args.server_index_key,
|
||||
"coverArtId": args.cover_art_id,
|
||||
"tier": tier,
|
||||
"path": path.to_string_lossy(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fn decode_image_bytes(bytes: &[u8]) -> Result<DynamicImage, String> {
|
||||
ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| e.to_string())?
|
||||
.decode()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn load_image_from_disk(dir: &Path) -> Option<DynamicImage> {
|
||||
for tier in [800u32, 512, 256, 128] {
|
||||
if let Some(path) = tier_exists(dir, tier) {
|
||||
if let Ok(img) = image::open(&path) {
|
||||
return Some(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn load_cover_source(
|
||||
dir: &Path,
|
||||
client: &Client,
|
||||
http_sem: &Semaphore,
|
||||
args: &CoverCacheEnsureArgs,
|
||||
) -> Result<DynamicImage, String> {
|
||||
if let Some(img) = load_image_from_disk(dir) {
|
||||
return Ok(img);
|
||||
}
|
||||
let _permit = http_sem
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fetch_size = if args.tier >= 2000 {
|
||||
2000
|
||||
} else {
|
||||
800
|
||||
};
|
||||
let url = build_cover_art_url(
|
||||
&args.rest_base_url,
|
||||
&args.username,
|
||||
&args.password,
|
||||
&args.cover_art_id,
|
||||
fetch_size,
|
||||
);
|
||||
let bytes = fetch_cover_bytes(client, &url).await?;
|
||||
decode_image_bytes(&bytes)
|
||||
}
|
||||
|
||||
fn spawn_derive_remaining_tiers(
|
||||
app: AppHandle,
|
||||
state: Arc<Mutex<CoverCacheState>>,
|
||||
_root: PathBuf,
|
||||
args: CoverCacheEnsureArgs,
|
||||
img: DynamicImage,
|
||||
requested: u32,
|
||||
) {
|
||||
let tiers_bg: Vec<u32> = if requested == 2000 {
|
||||
vec![]
|
||||
} else {
|
||||
DERIVE_TIERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| *t > requested && *t <= 800)
|
||||
.collect()
|
||||
};
|
||||
if tiers_bg.is_empty() {
|
||||
return;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let dir = {
|
||||
let guard = state.lock().await;
|
||||
cover_dir(&guard.root, &args.server_index_key, &args.cover_art_id)
|
||||
};
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||||
for tier in tiers_bg {
|
||||
if tier_exists(&dir, tier).is_some() {
|
||||
continue;
|
||||
}
|
||||
let path = tier_path(&dir, tier);
|
||||
if write_webp_tier(&img, tier, &path).is_ok() {
|
||||
emit_tier_ready(&app, &args, tier, &path);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
fn dir_has_any_cached_tier(dir: &Path) -> bool {
|
||||
if tier_exists(dir, 800).is_some() {
|
||||
return true;
|
||||
}
|
||||
for tier in DERIVE_TIERS {
|
||||
if tier != 800 && tier_exists(dir, tier).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
tier_exists(dir, 2000).is_some()
|
||||
}
|
||||
|
||||
fn count_cached_in_server_dir(server_dir: &Path) -> i64 {
|
||||
let Ok(entries) = std::fs::read_dir(server_dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter(|e| dir_has_any_cached_tier(&e.path()))
|
||||
.count() as i64
|
||||
}
|
||||
|
||||
/// Count cover ID dirs with any cached tier (UI progress — matches visible disk cache).
|
||||
pub(crate) fn count_cached_cover_ids(root: &Path, server_index_key: &str) -> i64 {
|
||||
let keyed = count_cached_in_server_dir(&root.join(server_index_key));
|
||||
if keyed > 0 {
|
||||
return keyed;
|
||||
}
|
||||
// Legacy profile-uuid bucket or host alias — don't show 0 when files exist elsewhere.
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.path().is_dir()
|
||||
&& e.file_name().to_string_lossy() != ".storage-layout"
|
||||
})
|
||||
.map(|e| count_cached_in_server_dir(&e.path()))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Disk usage for one server bucket only (cheaper than scanning all hosts).
|
||||
pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) {
|
||||
let mut bytes = 0u64;
|
||||
let mut count = 0u64;
|
||||
let server_dir = root.join(server_index_key);
|
||||
let Ok(ids) = std::fs::read_dir(&server_dir) else {
|
||||
return (0, 0);
|
||||
};
|
||||
for id_dir in ids.flatten() {
|
||||
if !id_dir.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
if dir_has_any_cached_tier(&id_dir.path()) {
|
||||
count += 1;
|
||||
}
|
||||
let Ok(files) = std::fs::read_dir(id_dir.path()) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
if let Ok(meta) = f.metadata() {
|
||||
bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
(bytes, count)
|
||||
}
|
||||
|
||||
pub(crate) fn dir_usage_at_root(root: &Path) -> (u64, u64) {
|
||||
let mut bytes = 0u64;
|
||||
let mut count = 0u64;
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return (0, 0);
|
||||
};
|
||||
for server in entries.flatten() {
|
||||
if server.file_name().to_string_lossy() == ".storage-layout" {
|
||||
continue;
|
||||
}
|
||||
if !server.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(ids) = std::fs::read_dir(server.path()) else {
|
||||
continue;
|
||||
};
|
||||
for id_dir in ids.flatten() {
|
||||
if !id_dir.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
if dir_has_any_cached_tier(&id_dir.path()) {
|
||||
count += 1;
|
||||
}
|
||||
let Ok(files) = std::fs::read_dir(id_dir.path()) else {
|
||||
continue;
|
||||
};
|
||||
for f in files.flatten() {
|
||||
if let Ok(meta) = f.metadata() {
|
||||
bytes += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(bytes, count)
|
||||
}
|
||||
|
||||
fn state(app: &AppHandle) -> Result<Arc<Mutex<CoverCacheState>>, String> {
|
||||
app.try_state::<Arc<Mutex<CoverCacheState>>>()
|
||||
.map(|s| s.inner().clone())
|
||||
.ok_or_else(|| "cover cache not initialized".into())
|
||||
}
|
||||
|
||||
const COVER_CACHE_LAYOUT_STAMP: &str = "index-key-v1";
|
||||
|
||||
/// Drop legacy profile-uuid directories when switching to host index keys (no migration).
|
||||
fn reset_cover_cache_for_index_key_layout(root: &Path) -> Result<(), String> {
|
||||
let stamp = root.join(".storage-layout");
|
||||
if stamp.is_file() {
|
||||
if let Ok(s) = std::fs::read_to_string(&stamp) {
|
||||
if s.trim() == COVER_CACHE_LAYOUT_STAMP {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.exists() {
|
||||
for entry in std::fs::read_dir(root).map_err(|e| e.to_string())?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(".storage-layout") {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&stamp, COVER_CACHE_LAYOUT_STAMP).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub use backfill_worker::{
|
||||
pulse_backfill, setup_library_sync_idle_listener, try_schedule_full_pass, CoverBackfillPulseDto,
|
||||
CoverBackfillRunDto, CoverBackfillSession, CoverBackfillWorker,
|
||||
};
|
||||
|
||||
pub fn init_cover_cache(app: &AppHandle) -> Result<(), String> {
|
||||
let root = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("cover-cache");
|
||||
reset_cover_cache_for_index_key_layout(&root)?;
|
||||
app.manage(Arc::new(Mutex::new(CoverCacheState::new(root)?)));
|
||||
app.manage(Arc::new(CoverBackfillWorker::new()));
|
||||
setup_library_sync_idle_listener(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_run_full_pass(app: AppHandle) -> Result<CoverBackfillRunDto, String> {
|
||||
Ok(CoverBackfillRunDto {
|
||||
started: try_schedule_full_pass(&app).await,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_pulse(app: AppHandle) -> Result<CoverBackfillPulseDto, String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
Ok(pulse_backfill(&app, &worker).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_reset_cursor(app: AppHandle) -> Result<(), String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
worker.reset_cursor().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pause library backfill while the user navigates / visible covers load (Rust pass yields).
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_set_ui_priority(
|
||||
app: AppHandle,
|
||||
hold: bool,
|
||||
) -> Result<(), String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
worker.set_ui_priority_hold(hold);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_configure(
|
||||
app: AppHandle,
|
||||
enabled: bool,
|
||||
server_index_key: String,
|
||||
library_server_id: String,
|
||||
rest_base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let worker = app
|
||||
.try_state::<Arc<CoverBackfillWorker>>()
|
||||
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
|
||||
let session = if enabled && !library_server_id.is_empty() && !server_index_key.is_empty() {
|
||||
Some(CoverBackfillSession {
|
||||
server_index_key,
|
||||
library_server_id,
|
||||
rest_base_url,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
worker
|
||||
.set_session(enabled && session.is_some(), session)
|
||||
.await;
|
||||
if enabled {
|
||||
let _ = try_schedule_full_pass(&app).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverCachePeekItem {
|
||||
pub server_index_key: String,
|
||||
pub cover_art_id: String,
|
||||
pub tier: u32,
|
||||
}
|
||||
|
||||
/// Best-effort disk hit without network (exact tier, then largest tier on disk ≤ wanted).
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_peek_batch(
|
||||
app: AppHandle,
|
||||
items: Vec<CoverCachePeekItem>,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let mut out = HashMap::new();
|
||||
for item in items {
|
||||
let dir = cover_dir(&root, &item.server_index_key, &item.cover_art_id);
|
||||
let path = peek_tier_path(&dir, item.tier);
|
||||
if let Some(p) = path {
|
||||
let key = format!(
|
||||
"{}:cover:{}:{}",
|
||||
item.server_index_key, item.cover_art_id, item.tier
|
||||
);
|
||||
out.insert(key, p.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn peek_fallback_tiers(want: u32) -> &'static [u32] {
|
||||
match want {
|
||||
512 => &[800, 256, 128],
|
||||
256 => &[800, 512, 128],
|
||||
128 => &[256, 512, 800],
|
||||
64 => &[128, 256, 512, 800],
|
||||
w if w > 512 && w < 800 => &[800, 512, 256, 128],
|
||||
w if w > 800 => &[512, 256, 128],
|
||||
_ => &[800, 512, 256, 128],
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk-only: exact tier, then grid-friendly upscales (512 → 800 before 128).
|
||||
fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
|
||||
if let Some(p) = tier_exists(dir, want) {
|
||||
return Some(p);
|
||||
}
|
||||
for &tier in peek_fallback_tiers(want) {
|
||||
if let Some(p) = tier_exists(dir, tier) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_ensure(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
cover_art_id: String,
|
||||
tier: u32,
|
||||
rest_base_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<CoverCacheEnsureResult, String> {
|
||||
let args = CoverCacheEnsureArgs {
|
||||
server_index_key,
|
||||
cover_art_id,
|
||||
tier,
|
||||
rest_base_url,
|
||||
username,
|
||||
password,
|
||||
library_bulk: false,
|
||||
};
|
||||
let st = state(&app)?;
|
||||
CoverCacheState::ensure_inner(&st, &app, &args, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_ensure_batch(
|
||||
app: AppHandle,
|
||||
items: Vec<CoverCacheEnsureArgs>,
|
||||
) -> Result<(), String> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let st = state(&app)?;
|
||||
for item in items {
|
||||
let st = st.clone();
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = CoverCacheState::ensure_inner(&st, &app, &item, None).await;
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_stats(app: AppHandle) -> Result<CoverCacheStatsDto, String> {
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let (bytes, entry_count) = tauri::async_runtime::spawn_blocking(move || dir_usage_at_root(&root))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let (pressure, auto_download_enabled) = guard.pressure_from_bytes(bytes);
|
||||
Ok(CoverCacheStatsDto {
|
||||
bytes,
|
||||
count: entry_count,
|
||||
pressure,
|
||||
auto_download_enabled,
|
||||
entry_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_evict_tick(_app: AppHandle) -> Result<u32, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_stats_server(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
) -> Result<CoverCacheStatsDto, String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let (bytes, entry_count) = dir_usage_for_server(&guard.root, &server_index_key);
|
||||
let (pressure, auto_download_enabled) = guard.pressure_from_bytes(bytes);
|
||||
Ok(CoverCacheStatsDto {
|
||||
bytes,
|
||||
count: entry_count,
|
||||
pressure,
|
||||
auto_download_enabled,
|
||||
entry_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_clear_server(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
) -> Result<(), String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
let path = guard.root.join(&server_index_key);
|
||||
if path.is_dir() {
|
||||
std::fs::remove_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
drop(guard);
|
||||
let _ = app.emit(
|
||||
"cover:cache-cleared",
|
||||
serde_json::json!({ "serverIndexKey": server_index_key }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_configure(
|
||||
app: AppHandle,
|
||||
max_mb: u64,
|
||||
high_watermark_pct: u64,
|
||||
resume_watermark_pct: u64,
|
||||
) -> Result<(), String> {
|
||||
let st = state(&app)?;
|
||||
let mut guard = st.lock().await;
|
||||
guard.max_bytes = max_mb.saturating_mul(1024 * 1024);
|
||||
guard.high_watermark_pct = high_watermark_pct.clamp(50, 99);
|
||||
guard.resume_watermark_pct = resume_watermark_pct.clamp(40, 95);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cover_cache_clear(app: AppHandle) -> Result<(), String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
if guard.root.exists() {
|
||||
for entry in std::fs::read_dir(&guard.root).map_err(|e| e.to_string())?.flatten() {
|
||||
let name = entry.file_name();
|
||||
if name.to_string_lossy() == ".storage-layout" {
|
||||
continue;
|
||||
}
|
||||
if entry.path().is_dir() {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
} else {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
let _ = app.emit("cover:cache-cleared", serde_json::json!({}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_backfill_batch(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
library_server_id: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<LibraryCoverBackfillBatchDto, String> {
|
||||
let runtime = app
|
||||
.try_state::<LibraryRuntime>()
|
||||
.ok_or_else(|| "LibraryRuntime not initialized".to_string())?;
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let store = runtime.store.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
collect_cover_backfill_batch(
|
||||
&store,
|
||||
&library_server_id,
|
||||
&root,
|
||||
&server_index_key,
|
||||
cursor.as_deref(),
|
||||
limit,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_progress(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
library_server_id: String,
|
||||
) -> Result<LibraryCoverProgressDto, String> {
|
||||
let runtime = app
|
||||
.try_state::<LibraryRuntime>()
|
||||
.ok_or_else(|| "LibraryRuntime not initialized".to_string())?;
|
||||
let st = state(&app)?;
|
||||
let root = {
|
||||
let guard = st.lock().await;
|
||||
guard.root.clone()
|
||||
};
|
||||
let index_key = server_index_key.clone();
|
||||
let store = runtime.store.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let cached_dirs = count_cached_cover_ids(&root, &index_key);
|
||||
collect_cover_progress(
|
||||
&store,
|
||||
&library_server_id,
|
||||
&root,
|
||||
&index_key,
|
||||
cached_dirs,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_clear_fetch_failures(
|
||||
app: AppHandle,
|
||||
server_index_key: String,
|
||||
) -> Result<u32, String> {
|
||||
let st = state(&app)?;
|
||||
let guard = st.lock().await;
|
||||
Ok(clear_cover_fetch_failures(&guard.root, &server_index_key))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cover_catalog_size(
|
||||
app: AppHandle,
|
||||
library_server_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let runtime = app
|
||||
.try_state::<LibraryRuntime>()
|
||||
.ok_or_else(|| "LibraryRuntime not initialized".to_string())?;
|
||||
let store = runtime.store.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
count_distinct_cover_ids(&store, &library_server_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cover_revalidate_enqueue() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cover_revalidate_tick(_cycle_days: Option<u32>) -> Result<u32, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cover_revalidate_batch() -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({
|
||||
"cursor": null,
|
||||
"processed": 0,
|
||||
"changed": 0
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::disk::{cover_dir, tier_path};
|
||||
|
||||
#[test]
|
||||
fn disk_layout_paths() {
|
||||
let root = std::path::Path::new("/tmp/cover-test");
|
||||
let dir = cover_dir(root, "srv", "al-1");
|
||||
assert_eq!(dir, root.join("srv").join("al-1"));
|
||||
assert_eq!(tier_path(&dir, 512), dir.join("512.webp"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
pub mod cli;
|
||||
mod cover_cache;
|
||||
mod lib_commands;
|
||||
|
||||
pub use psysonic_integration::discord;
|
||||
@@ -119,6 +120,9 @@ pub fn run() {
|
||||
app.manage(cache);
|
||||
}
|
||||
|
||||
cover_cache::init_cover_cache(app.handle())
|
||||
.map_err(|e| format!("cover cache init failed: {e}"))?;
|
||||
|
||||
// ── Library track store (psysonic-library, PR-5a + PR-5b) ─────
|
||||
// PR-5a brought up the read-only Tauri surface + LibraryRuntime.
|
||||
// PR-5b adds the mutating commands, sync session map, current-job
|
||||
@@ -726,6 +730,27 @@ pub fn run() {
|
||||
psysonic_library::commands::library_migrate_server_index_keys,
|
||||
psysonic_library::commands::library_delete_server_data,
|
||||
psysonic_library::commands::library_analysis_backfill_batch,
|
||||
cover_cache::cover_cache_peek_batch,
|
||||
cover_cache::cover_cache_ensure,
|
||||
cover_cache::cover_cache_ensure_batch,
|
||||
cover_cache::cover_cache_stats,
|
||||
cover_cache::cover_cache_evict_tick,
|
||||
cover_cache::cover_cache_configure,
|
||||
cover_cache::cover_cache_clear,
|
||||
cover_cache::cover_cache_clear_server,
|
||||
cover_cache::cover_cache_stats_server,
|
||||
cover_cache::library_cover_backfill_batch,
|
||||
cover_cache::library_cover_progress,
|
||||
cover_cache::library_cover_catalog_size,
|
||||
cover_cache::library_cover_clear_fetch_failures,
|
||||
cover_cache::library_cover_backfill_configure,
|
||||
cover_cache::library_cover_backfill_pulse,
|
||||
cover_cache::library_cover_backfill_reset_cursor,
|
||||
cover_cache::library_cover_backfill_set_ui_priority,
|
||||
cover_cache::library_cover_backfill_run_full_pass,
|
||||
cover_cache::cover_revalidate_enqueue,
|
||||
cover_cache::cover_revalidate_tick,
|
||||
cover_cache::cover_revalidate_batch,
|
||||
psysonic_syncfs::cache::offline::download_track_offline,
|
||||
psysonic_syncfs::cache::offline::cancel_offline_downloads,
|
||||
psysonic_syncfs::cache::offline::clear_offline_cancel,
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' ipc: http://ipc.localhost tauri:; connect-src 'self' ipc: http://ipc.localhost tauri: https: http: ws: wss:; img-src 'self' asset: http://asset.localhost https: http: data: blob:; media-src 'self' asset: http://asset.localhost https: http: data: blob:;",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["$APPDATA/**", "$APPDATA/cover-cache/**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
Reference in New Issue
Block a user