Files
psysonic/src-tauri/crates/psysonic-library/src/cover_backfill.rs
T
cucadmuh 418b25914a 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.
2026-05-26 19:35:08 +03:00

353 lines
11 KiB
Rust

//! 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
}
}