fix(cover): Windows thumbnails, tier fallback, PNG decode, coverArt id (#878)

* fix(cover): tier fallback for sparse surfaces and Windows asset URLs

Sparse UI (player bar, queue) now reads disk covers via the same tier
ladder as dense grids, so a warm 800.webp satisfies a 128px request.
Reject non-asset convertFileSrc results on Windows, widen Tauri asset
scope, and seed ladder keys on cover:tier-ready. applyDiskPath uses
seedGridDiskSrcCache only to avoid notify/subscriber infinite loops.

* fix(artist): top-track thumb uses album coverArt already warm in grid

Song coverArt ids often differ from album cover ids (e.g. Octastorium in
the grid vs empty track thumb). Prefer the album row's coverArt on artist
pages and ensure high priority for 32px dense cells.

* fix(cover): albumId for playback/queue; no broken img until disk URL ready

Prefer albumId over track-id coverArt (Navidrome). Wire queue to CoverArtImage
with playback scope. CoverArtImage renders a placeholder div until asset src
exists to avoid the browser broken-image icon.

* fix(test): add song id to resolveArtistPageSongCoverArtId fixture

Pick<SubsonicSong, …> requires id; fixes tsc in CI/build.

* fix(cover): resolve albumId for Now Playing and artist top tracks

Prefer albumId when album.coverArt echoes track id; use sparse surface
on artist suggestion thumbs; apply resolveSubsonicSongCoverArtId across
playback surfaces (Now Playing, fullscreen, mobile, mini).

* fix(cover): decode PNG from Subsonic before WebP tier encode

Enable `png` in the image crate — some servers return PNG cover art;
failed decode left `.fetch-failed` and empty thumbs for those albums.

* refactor(cover): consolidate cover id resolution and align tests

Move resolveSubsonicSongCoverArtId helpers to src/cover/resolveCoverArtId.ts
with resolvePlaybackTrackCoverArtId for player surfaces; co-locate tests;
fix FullscreenPlayer expectations for albumId-first resolution.

* docs: CHANGELOG and credits for PR #878

* fix(cover): keep per-track coverArt when distinct from song id

Address PR #878 review (b): albumId only when coverArt is missing or
echoes track id; pin case with unit test; comment isRawFsPath symmetry.

* chore(cover): address PR #878 review nits (scope, tests, rename)

Narrow asset scope to cover-cache dirs only; add diskSrcCache Windows-path
tests; rename ArtistTopTrackCover; CHANGELOG symptom-first wording.

* fix(cover): restore asset scope to app data dirs (Windows regression)

$APPDATA/cover-cache/** did not match Tauri scope resolution — covers
were blocked after load. Use $APPDATA/** and $APPLOCALDATA/** (no $DATA).

* fix(cover): Windows asset URLs — restore DATA scope, path normalize

Regression after review nits: dropped $DATA/** and strict isAssetProtocolUrl
blocked valid http://asset.localhost URLs on Windows. Normalize C:/ paths
before convertFileSrc; CoverArtImage/Hero hide broken img on load error.

* fix(cover): disk peek fallbacks when cache folder id differs

Small surfaces resolve albumId while cover-cache often stores WebP under
track id or album.coverArt from the grid. Peek batch now tries legacy ids;
playback scope resolves server index key by URL key, not UUID-only lookup.

* fix(cover): Navidrome al-* vs mf-* disk id mismatch

UI used mf-* coverArtId while library backfill only cached al-* folders.
Prefer album id for display/peek when coverArt is mf-*; backfill now
queues both distinct album_id and cover_art_id values.

* fix(cover): mf→al disk peek when mf folder missing in cache

Navidrome Subsonic often returns mf-* coverArtId while backfill only
creates al-* folders. Peek mf first, then al-* from hints; load albumId
from library when Subsonic omits it; ensure fallback uses al-* id.

* feat(cover): CoverArtRef, segment disk layout, library-index backfill

Normalize cover caching around stable entity ids from the local library
and Navidrome fetch ids. Disk paths live in psysonic_core::cover_cache_layout
(album/<entityId>/); UI uses CoverArtRef with cacheEntityId + fetchCoverArtId.

- Remove SQLite/mf peek helpers (diskPeekIds, peekCoverOnDisk, mergeDiskIdHints)
- Backfill reads album/artist rows from library SQLite (bare Navidrome ids ok)
- Use stored cover_art_id for HTTP; per-disc dirs only when discs differ
- Migrate call sites to albumCoverRef / albumCoverRefForPlayback

* feat(cover): central CoverEntry resolver (artist, album, track)

Add resolveEntry.ts and Rust CoverEntry helpers as the single source of
truth for cache_entity_id vs fetch_cover_art_id. ref.ts delegates to them;
resolveCoverArtId becomes a thin compatibility shim.

* feat(cover): resolve cover entries from local library index

Add library_resolve_cover_entry IPC and cover_resolve.rs so album,
artist, and track covers use SQLite cover_art_id + disc detection.
TypeScript helpers in resolveEntryLibrary.ts prefer the index over
live API fields when rows exist.

* feat(cover): library-first hooks for grids and playback UI

Add useAlbumCoverRef, useArtistCoverRef, useTrackCoverRef, and
usePlaybackTrackCoverRef — sync fallback then SQLite index upgrade.
Wire album/artist cards, album header, song card, and all player
surfaces to resolve covers from the local library when indexed.

* feat(cover): complete library-first migration across all UI surfaces

Add Album/Artist/TrackCoverArtImage, useLibraryCoverPrefetch, and batch
resolve helpers. Migrate grids, search, home, playback sidecars, warm
peek, playlists, and share flows to hooks that upgrade from SQLite.
Backfill normalizes album rows through cover_resolve; document paths in
COVER_PATHS.md. Radio remains a deliberate non-library exception.

* fix(cover): stop render loop from unstable serverScope in library hooks

Default param `{ kind: 'active' }` created a new object every render, so
every grid cell re-ran library_resolve IPC and setState in a loop. Use
COVER_SCOPE_ACTIVE singleton, coverScopeKey deps, and guarded sync updates.

* chore(cover): remove COVER_PATHS.md from app tree (lives in workdocs)

Audit doc is team spec — see workdocs 2026-05-cover-art-pipeline/cover-paths-audit.md.

* fix(cover): unstick library backfill after route changes (PR #870 regression)

useCoverNavigationPriority cleanup called beginNavigation instead of end,
leaking navigationHoldDepth so ui_priority_hold never released and backfill
never downloaded. Also skip disk check after cover_resolve normalization.

* fix(cover): segment progress, cap backfill CPU, include artists in catalog

Progress and disk size now scan album/ and artist/ segments (canonical 800.webp).
Prune legacy flat server/al-* dirs on startup and backfill pass.

Backfill: max 2 concurrent ensures; JPEG decode and WebP encode run on the
blocking pool behind a shared 2-permit semaphore so Tokio workers stay cool.

Artists were missing because the catalog only read the empty artist table;
add distinct artist_id from track and album rows. Paginate with a composite
(kind, id) cursor so album and artist rows are not skipped.

* fix(cover): drop legacy prune; backfill per-disc and artist catalog

Remove prune_legacy_* and cover_cache_catalog_entry — layout is only
cover_dir (album|artist segments); stale flat dirs clear on LAYOUT_STAMP change.

Backfill: artists from track/album artist_id; expand albums to per-CD mf-* slots
when discs differ; fix resolve_album_cover_entry when album row is missing.

* fix(cover): reduce library IPC storms and fix multi-disc player art

Skip per-row library_resolve on live search and artist album grids; warm
grids from API coverArt after mount instead of blocking layout. Dedupe and
cap concurrent library_resolve calls. Restore per-disc cache keys in the
player and queue when track mf-* art differs from the album bucket.

* fix(cover): skip library resolve on advanced and full search rows

Use API coverArt for album/artist rails and lazy viewport artwork so
result pages do not fire hundreds of library_resolve IPC calls at once.

* fix(cover): default libraryResolve off for browse grids and rails

Skip per-card library_resolve on album/artist/song browse UI by default;
keep it on album/artist headers, playback queue rows, and orbit approval.

* fix(cover): split UI/backfill CPU pools and restore mainstage hero carousel

Library backfill no longer shares the 2-permit JPEG/WebP semaphore with
visible cover ensures. Hero initializes albums from props, re-binds scroll
visibility after mount, updates backdrop on slide change, and uses library
resolve for correct cover art on the banner.

* fix(analysis): resume full-library scan after candidates phase

Reset the SQL cursor when entering full-library mode so tracks with
partial analysis are not skipped. Tighten TS backfill completion and
CPU queue watermarking; align cover-cache key tests with album-scoped
storage keys.

* fix(library): remove useless map_err in cover_resolve (clippy)

CI treats clippy::useless-conversion as error on rusqlite optional() chains.

* fix(cover): satisfy clippy on cover_cache_ensure IPC args

Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine
positional fields; align frontend invoke payload with { args }.
This commit is contained in:
cucadmuh
2026-05-28 03:15:08 +03:00
committed by GitHub
parent ee5068c98c
commit df3533bb5a
124 changed files with 3640 additions and 887 deletions
+14 -7
View File
@@ -17,8 +17,8 @@ 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;
/// Concurrent library downloads + encodes (hard cap — avoids saturating all CPU cores).
const LIBRARY_BACKFILL_PARALLEL: usize = 2;
const BATCH_SIZE: u32 = 24;
const PENDING_RESTART_THRESHOLD: i64 = 32;
const SYNC_WAIT_MS: u64 = 5000;
@@ -160,14 +160,16 @@ async fn ensure_one(
http_sem: Arc<Semaphore>,
app: AppHandle,
session: CoverBackfillSession,
cover_art_id: String,
item: psysonic_library::cover_backfill::CoverBackfillItem,
) {
if worker.ui_priority_hold.load(Ordering::Relaxed) {
return;
}
let args = CoverCacheEnsureArgs {
server_index_key: session.server_index_key,
cover_art_id,
cache_kind: item.cache_kind,
cache_entity_id: item.cache_entity_id,
cover_art_id: item.fetch_cover_art_id,
tier: LIBRARY_COVER_CANONICAL_TIER,
rest_base_url: session.rest_base_url,
username: session.username,
@@ -258,10 +260,11 @@ async fn run_full_pass(app: AppHandle, worker: Arc<CoverBackfillWorker>) {
if !session_still_focused(&worker, &session).await {
break;
}
let ids = batch.cover_ids.clone();
let items = batch.items.clone();
let mut paused_for_ui_priority = false;
let batch_slots = Arc::new(Semaphore::new(LIBRARY_BACKFILL_PARALLEL));
let mut set = tokio::task::JoinSet::new();
for id in ids {
for item in items {
if worker.ui_priority_hold.load(Ordering::Relaxed) {
paused_for_ui_priority = true;
break;
@@ -271,8 +274,12 @@ async fn run_full_pass(app: AppHandle, worker: Arc<CoverBackfillWorker>) {
let app = app.clone();
let session = session.clone();
let worker_arc = worker.clone();
let batch_slots = batch_slots.clone();
set.spawn(async move {
ensure_one(worker_arc.as_ref(), st, http_sem, app, session, id).await;
let Ok(_slot) = batch_slots.acquire().await else {
return;
};
ensure_one(worker_arc.as_ref(), st, http_sem, app, session, item).await;
});
}
while set.join_next().await.is_some() {}
+5 -3
View File
@@ -1,10 +1,12 @@
use std::path::{Path, PathBuf};
pub use psysonic_core::cover_cache_layout;
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)
/// Delegates to [`cover_cache_layout::cover_dir`] — disk path format lives in `psysonic-core`.
pub fn cover_dir(root: &Path, server_index_key: &str, cache_kind: &str, cache_entity_id: &str) -> PathBuf {
cover_cache_layout::cover_dir(root, server_index_key, cache_kind, cache_entity_id)
}
pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
+164 -172
View File
@@ -7,8 +7,11 @@ 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 fetch::build_cover_art_url;
use image::{DynamicImage, ImageReader};
use psysonic_core::cover_cache_layout::{
count_entities_with_canonical_tier, cover_root_disk_usage, 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,
@@ -47,6 +50,10 @@ pub struct CoverCacheStatsDto {
#[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,
@@ -57,8 +64,16 @@ pub struct CoverCacheEnsureArgs {
pub library_bulk: bool,
}
/// Cap concurrent cover HTTP fetches (library backfill + UI share this pool).
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.
const COVER_CPU_BACKFILL_CONCURRENCY: usize = 2;
pub struct CoverCacheState {
pub root: PathBuf,
@@ -67,6 +82,8 @@ pub struct CoverCacheState {
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>,
}
impl CoverCacheState {
@@ -84,9 +101,19 @@ impl CoverCacheState {
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)),
})
}
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)
}
@@ -103,7 +130,7 @@ impl CoverCacheState {
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);
let dir = cover_dir_for_args(&this.root, args);
if let Some(path) = peek_tier_path(&dir, args.tier) {
return Ok(CoverCacheEnsureResult {
hit: true,
@@ -124,6 +151,7 @@ impl CoverCacheState {
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) {
@@ -134,20 +162,6 @@ impl CoverCacheState {
});
}
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 {
@@ -166,32 +180,72 @@ impl CoverCacheState {
.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;
enum CoverSource {
Image(DynamicImage),
Bytes(Vec<u8>),
}
let source = if let Some(img) = load_image_from_disk(&dir) {
CoverSource::Image(img)
} else {
for tier in tiers_now {
if tier_exists(&dir, tier).is_some() {
if tier == requested {
wrote_requested = true;
match download_cover_payload(&dir, &client, &http_sem, args).await {
Ok(bytes) => CoverSource::Bytes(bytes),
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 cover_cpu_sem_bg = cover_cpu_sem.clone();
let tiers_bg = tiers_now.clone();
let (mut wrote_requested, fresh_tiers) = tauri::async_runtime::spawn_blocking(
move || -> Result<(bool, Vec<(u32, PathBuf)>), String> {
let rt = tokio::runtime::Handle::current();
let _permit = rt
.block_on(cover_cpu_sem_bg.acquire())
.map_err(|e| e.to_string())?;
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;
}
}
continue;
}
let path = tier_path(&dir, tier);
write_webp_tier(&img, tier, &path)?;
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 tier == requested {
wrote_requested = true;
}
}
}
@@ -202,14 +256,16 @@ impl CoverCacheState {
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,
);
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,
@@ -237,7 +293,8 @@ fn emit_tier_ready(app: &AppHandle, args: &CoverCacheEnsureArgs, tier: u32, path
"cover:tier-ready",
serde_json::json!({
"serverIndexKey": args.server_index_key,
"coverArtId": args.cover_art_id,
"cacheKind": args.cache_kind,
"cacheEntityId": args.cache_entity_id,
"tier": tier,
"path": path.to_string_lossy(),
}),
@@ -263,15 +320,12 @@ fn load_image_from_disk(dir: &Path) -> Option<DynamicImage> {
None
}
async fn load_cover_source(
dir: &Path,
async fn download_cover_payload(
_dir: &Path,
client: &Client,
http_sem: &Semaphore,
args: &CoverCacheEnsureArgs,
) -> Result<DynamicImage, String> {
if let Some(img) = load_image_from_disk(dir) {
return Ok(img);
}
) -> Result<Vec<u8>, String> {
let _permit = http_sem
.acquire()
.await
@@ -288,8 +342,7 @@ async fn load_cover_source(
&args.cover_art_id,
fetch_size,
);
let bytes = fetch_cover_bytes(client, &url).await?;
decode_image_bytes(&bytes)
fetch::fetch_cover_bytes(client, &url).await
}
fn spawn_derive_remaining_tiers(
@@ -313,130 +366,64 @@ fn spawn_derive_remaining_tiers(
return;
}
tauri::async_runtime::spawn(async move {
let dir = {
let (dir, cover_cpu_sem) = {
let guard = state.lock().await;
cover_dir(&guard.root, &args.server_index_key, &args.cover_art_id)
(
cover_dir_for_args(&guard.root, &args),
guard.cpu_sem_for(args.library_bulk),
)
};
let _ = tauri::async_runtime::spawn_blocking(move || {
let written = tauri::async_runtime::spawn_blocking(move || -> Vec<(u32, PathBuf)> {
let rt = tokio::runtime::Handle::current();
let Ok(_permit) = rt.block_on(cover_cpu_sem.acquire()) else {
return Vec::new();
};
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() {
emit_tier_ready(&app, &args, tier, &path);
fresh.push((tier, path));
}
}
fresh
})
.await;
.await
.unwrap_or_default();
for (tier, path) in written {
emit_tier_ready(&app, &args, tier, &path);
}
});
}
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).
/// Entity dirs with canonical `800.webp` under `album/` and `artist/` (segment layout).
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));
let keyed = count_entities_with_canonical_tier(&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.
// Host alias / legacy bucket name — pick the best segment count among siblings.
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"
e.path().is_dir() && e.file_name().to_string_lossy() != ".storage-layout"
})
.map(|e| count_cached_in_server_dir(&e.path()))
.map(|e| count_entities_with_canonical_tier(&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)
server_cover_disk_usage(&root.join(server_index_key))
}
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)
cover_root_disk_usage(root)
}
fn state(app: &AppHandle) -> Result<Arc<Mutex<CoverCacheState>>, String> {
@@ -445,7 +432,7 @@ fn state(app: &AppHandle) -> Result<Arc<Mutex<CoverCacheState>>, String> {
.ok_or_else(|| "cover cache not initialized".into())
}
const COVER_CACHE_LAYOUT_STAMP: &str = "index-key-v1";
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> {
@@ -567,8 +554,11 @@ pub async fn library_cover_backfill_configure(
#[serde(rename_all = "camelCase")]
pub struct CoverCachePeekItem {
pub server_index_key: String,
pub cover_art_id: 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).
@@ -584,14 +574,15 @@ pub async fn cover_cache_peek_batch(
};
let mut out = HashMap::new();
for item in items {
let dir = cover_dir(&root, &item.server_index_key, &item.cover_art_id);
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 {
let key = format!(
"{}:cover:{}:{}",
item.server_index_key, item.cover_art_id, item.tier
);
out.insert(key, p.to_string_lossy().into_owned());
out.insert(item.storage_key, p.to_string_lossy().into_owned());
}
}
Ok(out)
@@ -625,22 +616,8 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
#[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,
args: CoverCacheEnsureArgs,
) -> 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
}
@@ -873,13 +850,28 @@ pub fn cover_revalidate_batch() -> Result<serde_json::Value, String> {
#[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};
#[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"));
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);
}
}