Files
Psychotoxical-psysonic/src-tauri/src/cover_cache/mod.rs
T
cucadmuh 42aec6720c fix(cover): stop per-song over-fetch + log failed cover downloads (#944)
* fix(cover): stop per-song cover over-fetch (album/mf-* explosion)

album_has_distinct_disc_covers returned true as soon as two tracks on the same
disc had different cover ids. On Navidrome every song has its own mf-<id>
coverArt, so almost every album was flagged "distinct disc covers" and backfill
warmed one cover per track (~520k for ~170k tracks), filling album/ with mf-*
dirs instead of ~one cover per album.

Treat a release as having distinct disc covers only when each disc has a single
consistent cover that differs across discs (genuine box set); per-song ids now
collapse to one cover per album. Mirror the same fix in the TS
albumHasDistinctDiscCovers used by on-demand warming. Adds regression tests on
both sides.

* docs(changelog): record per-song cover over-fetch fix (PR #944)

Give the album/mf-* over-fetch fix its own [1.47.0] Fixed entry and a
settingsCredits line under PR #944.

* feat(cover): log failed cover downloads with album/artist name

A non-200 (or network-failed) getCoverArt download was swallowed silently. Now
the failure is logged with the resolved album/artist name and the server error,
so a server refusing covers under backfill load (5xx/429/timeouts) is visible.

- cover_resolve: describe_cover_entity() resolves a human label from the local
  index (album "Name" — Artist / artist "Name"), best-effort with id fallback.
- cover_cache: log_cover_fetch_failure() in ensure_inner logs on the download
  error path; threads optional library_server_id through CoverCacheEnsureArgs so
  the name lookup happens only on failure. Backfill logs at normal level,
  on-demand misses at debug level.
2026-06-02 10:58:39 +03:00

1362 lines
46 KiB
Rust

//! 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;
use image::{DynamicImage, ImageReader};
use psysonic_core::cover_cache_layout::{
count_entities_with_canonical_tier, cover_root_disk_usage, cover_server_dir,
server_cover_disk_usage,
};
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::atomic::{AtomicUsize, Ordering};
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,
}
/// Live cover HTTP / WebP-encode slots — mirrors analysis pipeline probe shape.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverPipelineQueueStatsDto {
pub http_max: u32,
pub http_active: u32,
pub cpu_ui_max: u32,
pub cpu_ui_active: u32,
pub cpu_backfill_max: u32,
pub cpu_backfill_active: u32,
pub library_backfill_http_max: u32,
pub library_backfill_http_active: u32,
pub library_backfill_pass_running: bool,
}
fn sem_active(sem: &Semaphore, max: u32) -> u32 {
max.saturating_sub(sem.available_permits() as u32)
}
pub(crate) fn cover_pipeline_queue_stats(
cache: &CoverCacheState,
backfill: Option<&backfill_worker::CoverBackfillWorker>,
) -> CoverPipelineQueueStatsDto {
let (library_backfill_http_max, library_backfill_http_active, library_backfill_pass_running) =
backfill
.map(backfill_worker::CoverBackfillWorker::pipeline_http_stats)
.unwrap_or((0, 0, false));
CoverPipelineQueueStatsDto {
http_max: COVER_HTTP_CONCURRENCY as u32,
http_active: sem_active(&cache.http_sem, COVER_HTTP_CONCURRENCY as u32),
cpu_ui_max: COVER_CPU_UI_CONCURRENCY as u32,
cpu_ui_active: sem_active(&cache.cover_cpu_ui_sem, COVER_CPU_UI_CONCURRENCY as u32),
cpu_backfill_max: cache.cover_backfill_cpu_parallel() as u32,
cpu_backfill_active: sem_active(
&cache.cover_cpu_backfill_sem,
cache.cover_backfill_cpu_parallel() as u32,
),
library_backfill_http_max,
library_backfill_http_active,
library_backfill_pass_running,
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverCacheEnsureArgs {
pub server_index_key: String,
/// `album` or `artist` — with `cache_entity_id` selects the SHA-256 cache directory.
pub cache_kind: String,
pub cache_entity_id: String,
/// Navidrome / Subsonic `getCoverArt` id (`al-*`, `ar-*`, …).
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,
/// Library server id (DB key) — set by backfill so a failed fetch can be logged
/// with the album/artist name. On-demand UI ensures leave it `None`.
#[serde(default)]
pub library_server_id: Option<String>,
}
fn cover_dir_for_args(root: &Path, args: &CoverCacheEnsureArgs) -> PathBuf {
cover_dir(root, &args.server_index_key, &args.cache_kind, &args.cache_entity_id)
}
/// Cap concurrent cover HTTP fetches for visible UI routes (library backfill uses its own pool).
const COVER_HTTP_CONCURRENCY: usize = 16;
/// UI-visible decode + WebP encode (grid, hero, player) — not shared with library backfill.
const COVER_CPU_UI_CONCURRENCY: usize = 2;
/// Library backfill encode ladder — separate pool so bulk warm-up cannot starve the webview.
/// Default only; runtime-tunable from the perf probe via `set_backfill_cpu_parallel`.
const COVER_CPU_BACKFILL_CONCURRENCY: usize = 2;
/// Upper bound for the runtime encode-pool knob (matches the worker cap).
const COVER_CPU_BACKFILL_MAX: 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>,
pub cover_cpu_ui_sem: Arc<Semaphore>,
pub cover_cpu_backfill_sem: Arc<Semaphore>,
/// Live permit count of `cover_cpu_backfill_sem` (the semaphore itself only
/// exposes *available* permits, not the configured ceiling).
cover_cpu_backfill_max: AtomicUsize,
}
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)),
cover_cpu_ui_sem: Arc::new(Semaphore::new(COVER_CPU_UI_CONCURRENCY)),
cover_cpu_backfill_sem: Arc::new(Semaphore::new(COVER_CPU_BACKFILL_CONCURRENCY)),
cover_cpu_backfill_max: AtomicUsize::new(COVER_CPU_BACKFILL_CONCURRENCY),
})
}
/// Current configured ceiling of the backfill encode pool.
pub fn cover_backfill_cpu_parallel(&self) -> usize {
self.cover_cpu_backfill_max.load(Ordering::Relaxed).max(1)
}
/// Retune the backfill encode pool to match the worker's download
/// concurrency. Grows/shrinks the semaphore permits in place.
pub fn set_backfill_cpu_parallel(&self, threads: usize) {
let next = threads.clamp(1, COVER_CPU_BACKFILL_MAX);
let prev = self.cover_cpu_backfill_max.swap(next, Ordering::SeqCst);
if next > prev {
self.cover_cpu_backfill_sem.add_permits(next - prev);
} else if next < prev {
let sem = self.cover_cpu_backfill_sem.clone();
let surplus = prev - next;
tauri::async_runtime::spawn(async move {
for _ in 0..surplus {
if let Ok(permit) = sem.acquire().await {
permit.forget();
}
}
});
}
}
fn cpu_sem_for(&self, library_bulk: bool) -> Arc<Semaphore> {
if library_bulk {
self.cover_cpu_backfill_sem.clone()
} else {
self.cover_cpu_ui_sem.clone()
}
}
fn pressure_from_bytes(&self, _bytes: u64) -> (String, bool) {
("ok".into(), true)
}
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_for_args(&this.root, args);
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,
});
}
// Cheap, no-IO gate. Previously this ran a full recursive disk walk of
// the entire cover cache (`pressure()` → `dir_usage_at_root`) on every
// ensure, under the global state lock — serializing the whole backfill
// pool onto filesystem stat work. The walked bytes were then discarded.
let (_, auto_dl) = this.pressure_from_bytes(0);
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());
let cover_cpu_sem = this.cpu_sem_for(args.library_bulk);
drop(this);
if cover_fetch_recently_failed(&dir) {
return Ok(CoverCacheEnsureResult {
hit: false,
path: String::new(),
tier: args.tier,
});
}
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()
};
enum CoverSource {
Image(DynamicImage),
Bytes(Vec<u8>),
}
let source = if let Some(img) = load_image_from_disk(&dir) {
CoverSource::Image(img)
} else {
match download_cover_payload(&dir, &client, &http_sem, args).await {
Ok(bytes) => CoverSource::Bytes(bytes),
Err(err) => {
log_cover_fetch_failure(app, args, &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,
});
}
}
};
let dir_bg = dir.clone();
let tiers_bg = tiers_now.clone();
let cpu_permit = cover_cpu_sem
.clone()
.acquire_owned()
.await
.map_err(|e| e.to_string())?;
let (mut wrote_requested, fresh_tiers) = tauri::async_runtime::spawn_blocking(
move || -> Result<(bool, Vec<(u32, PathBuf)>), String> {
let _cpu_permit = cpu_permit;
let img = match source {
CoverSource::Image(i) => i,
CoverSource::Bytes(b) => decode_image_bytes(&b)?,
};
std::fs::create_dir_all(&dir_bg).map_err(|e| e.to_string())?;
let mut wrote_requested = false;
let mut fresh = Vec::new();
if quiet {
disk::write_derived_webp_tiers(&dir_bg, &img, requested)?;
wrote_requested = tier_exists(&dir_bg, requested).is_some();
} else {
for tier in tiers_bg {
if tier_exists(&dir_bg, tier).is_some() {
if tier == requested {
wrote_requested = true;
}
continue;
}
let path = tier_path(&dir_bg, tier);
write_webp_tier(&img, tier, &path)?;
fresh.push((tier, path));
if tier == requested {
wrote_requested = true;
}
}
}
Ok((wrote_requested, fresh))
},
)
.await
.map_err(|e| e.to_string())??;
if !quiet {
for (tier, path) in fresh_tiers {
emit_tier_ready(app, args, tier, &path);
}
}
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 {
if let Some(img) = load_image_from_disk(&dir) {
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,
})
}
}
/// Log a non-200 / failed cover download with the album/artist name when known.
/// Backfill fetches (`library_bulk`) log at the normal level — the user wants to
/// see which covers a busy server refused; incidental on-demand UI misses stay at
/// the debug level so they don't spam the normal log.
fn log_cover_fetch_failure(app: &AppHandle, args: &CoverCacheEnsureArgs, err: &str) {
let label = args
.library_server_id
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|lib_id| {
app.try_state::<LibraryRuntime>().and_then(|rt| {
psysonic_library::cover_resolve::describe_cover_entity(
&rt.store,
lib_id,
&args.cache_kind,
&args.cache_entity_id,
)
})
})
.unwrap_or_else(|| format!("{} {}", args.cache_kind, args.cache_entity_id));
if args.library_bulk {
crate::app_eprintln!(
"[cover-backfill] fetch failed for {label} (coverArtId={}, tier={}): {err}",
args.cover_art_id,
args.tier
);
} else {
crate::app_deprintln!(
"[cover] fetch failed for {label} (coverArtId={}, tier={}): {err}",
args.cover_art_id,
args.tier
);
}
}
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,
"cacheKind": args.cache_kind,
"cacheEntityId": args.cache_entity_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 download_cover_payload(
_dir: &Path,
client: &Client,
http_sem: &Semaphore,
args: &CoverCacheEnsureArgs,
) -> Result<Vec<u8>, String> {
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,
);
fetch::fetch_cover_bytes(client, &url).await
}
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, cover_cpu_sem) = {
let guard = state.lock().await;
(
cover_dir_for_args(&guard.root, &args),
guard.cpu_sem_for(args.library_bulk),
)
};
let Ok(cpu_permit) = cover_cpu_sem.clone().acquire_owned().await else {
return;
};
let written = tauri::async_runtime::spawn_blocking(move || -> Vec<(u32, PathBuf)> {
let _cpu_permit = cpu_permit;
let mut fresh = Vec::new();
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() {
fresh.push((tier, path));
}
}
fresh
})
.await
.unwrap_or_default();
for (tier, path) in written {
emit_tier_ready(&app, &args, tier, &path);
}
});
}
/// Entity dirs with canonical `800.webp` under `album/` and `artist/` (segment layout).
/// Per-server only — must not borrow counts from sibling buckets (multi-server UI stats).
pub(crate) fn count_cached_cover_ids(root: &Path, server_index_key: &str) -> i64 {
count_entities_with_canonical_tier(&cover_server_dir(root, server_index_key))
}
pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) {
server_cover_disk_usage(&cover_server_dir(root, server_index_key))
}
/// TTL-memoized per-server cover dir walk. The "offline & cache" settings menu
/// polls byte usage + cached count every few seconds for every server; on a full
/// cache that is several full directory walks per tick. Reuse a recent walk so we
/// don't re-stat thousands of files when nothing has changed. Active backfill still
/// pushes live numbers through the `cover:library-progress` event, so a short TTL
/// only de-dupes the idle polling, it does not hide real progress.
const DIR_USAGE_CACHE_TTL: Duration = Duration::from_secs(10);
type DirUsageCache = std::sync::Mutex<HashMap<String, (std::time::Instant, (u64, u64))>>;
fn dir_usage_cache() -> &'static DirUsageCache {
static CACHE: std::sync::OnceLock<DirUsageCache> = std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
pub(crate) fn cached_dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) {
if let Ok(map) = dir_usage_cache().lock() {
if let Some((at, value)) = map.get(server_index_key) {
if at.elapsed() < DIR_USAGE_CACHE_TTL {
return *value;
}
}
}
let value = dir_usage_for_server(root, server_index_key);
if let Ok(mut map) = dir_usage_cache().lock() {
map.insert(server_index_key.to_string(), (std::time::Instant::now(), value));
}
value
}
pub(crate) fn invalidate_dir_usage_cache(server_index_key: &str) {
if let Ok(mut map) = dir_usage_cache().lock() {
map.remove(server_index_key);
}
}
pub(crate) fn dir_usage_at_root(root: &Path) -> (u64, u64) {
cover_root_disk_usage(root)
}
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 = psysonic_core::cover_cache_layout::LAYOUT_STAMP;
/// 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,
force: Option<bool>,
) -> Result<CoverBackfillRunDto, String> {
Ok(CoverBackfillRunDto {
started: try_schedule_full_pass(&app, force.unwrap_or(false)).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(())
}
/// Perf-probe tuning knob: set how many threads cover backfill uses (download
/// + encode pools move together). Not exposed in app Settings by design.
/// Returns the clamped value actually applied.
#[tauri::command]
pub async fn library_cover_backfill_set_parallel(
app: AppHandle,
threads: usize,
) -> Result<u32, String> {
let worker = app
.try_state::<Arc<CoverBackfillWorker>>()
.ok_or_else(|| "cover backfill worker not initialized".to_string())?;
let applied = worker.set_parallel(threads);
if let Ok(cache) = state(&app) {
cache.lock().await.set_backfill_cpu_parallel(applied);
}
Ok(applied as u32)
}
#[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, false).await;
}
Ok(())
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoverCachePeekItem {
pub server_index_key: String,
pub cache_kind: String,
pub cache_entity_id: String,
pub tier: u32,
/// Frontend `coverStorageKey` — echoed in the batch result map.
pub storage_key: String,
}
/// 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.cache_kind,
&item.cache_entity_id,
);
let path = peek_tier_path(&dir, item.tier);
if let Some(p) = path {
out.insert(item.storage_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,
args: CoverCacheEnsureArgs,
) -> Result<CoverCacheEnsureResult, String> {
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) = cached_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_get_pipeline_queue_stats(
app: AppHandle,
) -> Result<CoverPipelineQueueStatsDto, String> {
let st = state(&app)?;
let guard = st.lock().await;
let backfill = app.try_state::<Arc<backfill_worker::CoverBackfillWorker>>();
Ok(cover_pipeline_queue_stats(
&guard,
backfill.as_ref().map(|w| w.as_ref()),
))
}
#[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 = cover_server_dir(&guard.root, &server_index_key);
if path.is_dir() {
std::fs::remove_dir_all(&path).map_err(|e| e.to_string())?;
}
invalidate_dir_usage_cache(&server_index_key);
drop(guard);
// Clearing drops files the cheap idle-gate signature can't see, so re-arm
// the backfill worker — otherwise the next sync-idle would skip the rescan.
if let Some(worker) = app.try_state::<Arc<CoverBackfillWorker>>() {
worker.rearm_idle_gate().await;
}
let _ = app.emit(
"cover:cache-cleared",
serde_json::json!({ "serverIndexKey": server_index_key }),
);
Ok(())
}
/// Rename a server's cover-cache bucket on disk after the user edits the
/// primary URL (and the derived index key changes). Used by the URL-change
/// remigration pipeline (dual-server-address spec §8.3) so cached covers
/// stay reachable under the new key.
///
/// Sanitization: rejects path-separator characters and `..` components — keys
/// flow from `serverIndexKeyFromUrl(url)` which strips schemes and trailing
/// slashes, but defense in depth at the FS boundary is cheap.
///
/// Behaviour:
/// - `old_key == new_key` → no-op success.
/// - Old bucket missing → no-op success (nothing to migrate).
/// - New bucket missing → simple `rename` (fastest path).
/// - Both exist → recursive merge, **prefer existing** in destination (the
/// newer bucket wins on collision; the surviving file count goes up, never
/// loses data).
///
/// Always emits `cover:bucket-renamed` with `{oldKey, newKey}` on success so
/// the frontend in-memory disk-src cache can invalidate stale entries.
#[tauri::command]
pub async fn cover_cache_rename_server_bucket(
app: AppHandle,
old_key: String,
new_key: String,
) -> Result<(), String> {
let st = state(&app)?;
let guard = st.lock().await;
rename_bucket_inner(&guard.root, &old_key, &new_key)?;
drop(guard);
let _ = app.emit(
"cover:bucket-renamed",
serde_json::json!({ "oldKey": old_key, "newKey": new_key }),
);
Ok(())
}
/// FS-only worker for `cover_cache_rename_server_bucket`, lifted out so the
/// command-level behaviour (sanitization + every short-circuit + the merge
/// branch) is testable against a real `tempdir` without spinning up Tauri
/// State. The command wrapper above adds nothing the tests need to cover
/// except the event emit.
fn rename_bucket_inner(root: &std::path::Path, old_key: &str, new_key: &str) -> Result<(), String> {
if old_key.is_empty() || new_key.is_empty() {
return Err("cover_cache_rename_server_bucket: empty key".into());
}
if !is_safe_index_key(old_key) || !is_safe_index_key(new_key) {
return Err("cover_cache_rename_server_bucket: key contains path separator".into());
}
if old_key == new_key {
return Ok(());
}
let old_dir = root.join(old_key);
let new_dir = root.join(new_key);
if !old_dir.is_dir() {
return Ok(());
}
if !new_dir.exists() {
std::fs::rename(&old_dir, &new_dir).map_err(|e| e.to_string())?;
} else {
merge_cover_bucket(&old_dir, &new_dir)?;
let _ = std::fs::remove_dir_all(&old_dir);
}
Ok(())
}
fn is_safe_index_key(key: &str) -> bool {
// Real index keys are `host[:port][/sub/path]` shape — forward slashes
// are legitimate path components (Navidrome behind a reverse-proxy
// subpath, etc.). Everything below is defense-in-depth at the FS
// boundary; real keys come out of `serverIndexKeyFromUrl` and never
// start with a separator or carry the patterns we reject here.
if key.is_empty() {
return false;
}
// Absolute-path leaders — `root.join("/etc/...")` and `root.join("\\foo\\")`
// on Unix / Windows respectively REPLACE the base path with the absolute
// argument. Reject before that ever happens.
if key.starts_with('/') || key.starts_with('\\') {
return false;
}
// Windows drive-letter root (`C:`, `c:`). `Path::join("C:")` is also
// treated as absolute on Windows.
let bytes = key.as_bytes();
if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
return false;
}
// Backslash anywhere — separators are forward-slash only.
if key.contains('\\') {
return false;
}
// No `..` segments anywhere — would escape the cover-cache root.
for segment in key.split('/') {
if segment == ".." {
return false;
}
}
true
}
fn merge_cover_bucket(old_dir: &std::path::Path, new_dir: &std::path::Path) -> Result<(), String> {
let entries = std::fs::read_dir(old_dir).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let from = entry.path();
let to = new_dir.join(entry.file_name());
if to.exists() {
// Prefer existing in destination — newer bucket wins.
continue;
}
if from.is_dir() {
std::fs::create_dir_all(&to).map_err(|e| e.to_string())?;
merge_cover_bucket(&from, &to)?;
} else {
std::fs::rename(&from, &to).map_err(|e| e.to_string())?;
}
}
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);
if let Ok(mut map) = dir_usage_cache().lock() {
map.clear();
}
if let Some(worker) = app.try_state::<Arc<CoverBackfillWorker>>() {
worker.rearm_idle_gate().await;
}
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 = cached_dir_usage_for_server(&root, &index_key).1 as i64;
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 std::io::Cursor;
use image::{ImageBuffer, ImageFormat, Rgba};
use super::decode_image_bytes;
use super::disk::{cover_dir, tier_path};
use super::{count_cached_cover_ids, is_safe_index_key, merge_cover_bucket, rename_bucket_inner};
use psysonic_core::cover_cache_layout::CANONICAL_PROGRESS_TIER;
use std::fs;
use std::path::PathBuf;
#[test]
fn count_cached_cover_ids_is_per_server_bucket() {
let root = fresh_tmpdir("count-per-server");
let home = cover_dir(&root, "music.home.example", "album", "al-home");
fs::create_dir_all(&home).unwrap();
fs::write(
home.join(format!("{CANONICAL_PROGRESS_TIER}.webp")),
b"x",
)
.unwrap();
assert_eq!(count_cached_cover_ids(&root, "music.home.example"), 1);
assert_eq!(count_cached_cover_ids(&root, "music.other.example"), 0);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn disk_layout_paths() {
let root = std::path::Path::new("/tmp/cover-test");
let dir = cover_dir(root, "srv", "album", "al-1");
assert_eq!(dir, root.join("srv").join("album").join("al-1"));
assert_eq!(tier_path(&dir, 512), dir.join("512.webp"));
}
#[test]
fn decode_image_bytes_accepts_png() {
let img = ImageBuffer::from_pixel(2, 2, Rgba([1u8, 2, 3, 255]));
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, ImageFormat::Png).expect("png encode");
let decoded = decode_image_bytes(buf.get_ref()).expect("png decode");
assert_eq!(decoded.width(), 2);
assert_eq!(decoded.height(), 2);
}
#[test]
fn safe_index_key_accepts_real_keys() {
assert!(is_safe_index_key("music.example.com"));
assert!(is_safe_index_key("192.168.0.10:4533"));
assert!(is_safe_index_key("music.example.com/navidrome"));
assert!(is_safe_index_key("[fe80::1]:4533"));
}
#[test]
fn safe_index_key_rejects_path_traversal_and_backslashes() {
assert!(!is_safe_index_key("../etc"));
assert!(!is_safe_index_key("a/../b"));
assert!(!is_safe_index_key("a\\b"));
assert!(!is_safe_index_key("..\\evil"));
}
#[test]
fn safe_index_key_rejects_absolute_paths_and_drive_letters() {
// Path::join with an absolute argument replaces the base — must
// never accept keys that lead with a separator.
assert!(!is_safe_index_key("/etc/passwd"));
assert!(!is_safe_index_key("/"));
assert!(!is_safe_index_key("\\windows"));
// Windows drive-letter roots are also treated as absolute.
assert!(!is_safe_index_key("C:"));
assert!(!is_safe_index_key("C:/Windows"));
assert!(!is_safe_index_key("c:foo"));
// Empty key is meaningless and would join to the root itself.
assert!(!is_safe_index_key(""));
}
/// Build a unique tmpdir for the merge tests so parallel runs don't trip
/// on each other.
fn fresh_tmpdir(label: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
p.push(format!("psysonic-cover-merge-{}-{}", label, nanos));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn merge_bucket_moves_unique_files() {
let root = fresh_tmpdir("unique");
let old = root.join("old");
let new_ = root.join("new");
fs::create_dir_all(old.join("al-1")).unwrap();
fs::write(old.join("al-1").join("128.webp"), b"old-bytes").unwrap();
fs::create_dir_all(&new_).unwrap();
merge_cover_bucket(&old, &new_).unwrap();
assert!(new_.join("al-1").join("128.webp").exists());
assert_eq!(fs::read(new_.join("al-1").join("128.webp")).unwrap(), b"old-bytes");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn merge_bucket_prefers_existing_on_collision() {
let root = fresh_tmpdir("collision");
let old = root.join("old");
let new_ = root.join("new");
fs::create_dir_all(old.join("al-1")).unwrap();
fs::create_dir_all(new_.join("al-1")).unwrap();
fs::write(old.join("al-1").join("128.webp"), b"OLD").unwrap();
fs::write(new_.join("al-1").join("128.webp"), b"NEW").unwrap();
merge_cover_bucket(&old, &new_).unwrap();
// Existing destination wins; nothing was overwritten.
assert_eq!(fs::read(new_.join("al-1").join("128.webp")).unwrap(), b"NEW");
let _ = fs::remove_dir_all(&root);
}
// ── rename_bucket_inner — command-level behaviour ─────────────────────────
#[test]
fn rename_bucket_inner_rejects_empty_keys() {
let root = fresh_tmpdir("rename-empty");
assert!(rename_bucket_inner(&root, "", "new").is_err());
assert!(rename_bucket_inner(&root, "old", "").is_err());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn rename_bucket_inner_rejects_unsafe_keys() {
let root = fresh_tmpdir("rename-unsafe");
assert!(rename_bucket_inner(&root, "../escape", "new").is_err());
assert!(rename_bucket_inner(&root, "old", "/abs/path").is_err());
assert!(rename_bucket_inner(&root, "old", "C:/Windows").is_err());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn rename_bucket_inner_noop_when_old_missing() {
let root = fresh_tmpdir("rename-missing");
// No old dir exists at all — must succeed without creating new.
rename_bucket_inner(&root, "old", "new").unwrap();
assert!(!root.join("new").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn rename_bucket_inner_noop_when_keys_equal() {
let root = fresh_tmpdir("rename-equal");
fs::create_dir_all(root.join("same").join("al-1")).unwrap();
fs::write(root.join("same").join("al-1").join("128.webp"), b"x").unwrap();
rename_bucket_inner(&root, "same", "same").unwrap();
// Still exactly where it was; nothing renamed.
assert!(root.join("same").join("al-1").join("128.webp").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn rename_bucket_inner_simple_rename_when_new_missing() {
let root = fresh_tmpdir("rename-simple");
fs::create_dir_all(root.join("old").join("al-1")).unwrap();
fs::write(root.join("old").join("al-1").join("128.webp"), b"payload").unwrap();
rename_bucket_inner(&root, "old", "new").unwrap();
assert!(!root.join("old").exists());
assert_eq!(
fs::read(root.join("new").join("al-1").join("128.webp")).unwrap(),
b"payload",
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn rename_bucket_inner_merges_when_new_exists() {
let root = fresh_tmpdir("rename-merge");
fs::create_dir_all(root.join("old").join("al-1")).unwrap();
fs::create_dir_all(root.join("new").join("al-2")).unwrap();
fs::write(root.join("old").join("al-1").join("128.webp"), b"from-old").unwrap();
fs::write(root.join("new").join("al-2").join("128.webp"), b"from-new").unwrap();
// Collision on al-2 — destination wins.
fs::create_dir_all(root.join("old").join("al-2")).unwrap();
fs::write(root.join("old").join("al-2").join("128.webp"), b"overwrite-attempt").unwrap();
rename_bucket_inner(&root, "old", "new").unwrap();
// Old bucket gone.
assert!(!root.join("old").exists());
// al-1 moved in.
assert_eq!(
fs::read(root.join("new").join("al-1").join("128.webp")).unwrap(),
b"from-old",
);
// al-2 destination preserved (prefer-existing).
assert_eq!(
fs::read(root.join("new").join("al-2").join("128.webp")).unwrap(),
b"from-new",
);
let _ = fs::remove_dir_all(&root);
}
}