diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d6b3fa..5f9a18a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -401,6 +401,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Cover art β€” Windows thumbnails, PNG decode, and Subsonic cover ids + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#878](https://github.com/Psychotoxical/psysonic/pull/878)** + +* Small cover surfaces on Windows (player bar, queue, artist top tracks) no longer stay empty while large album art loads β€” tier ladder disk lookup, valid `asset://` URLs only, and broader Tauri asset scope. +* Fixes a startup/UI freeze when disk paths were applied via `rememberGridDiskSrc` (notify loop); seeds cache without waking subscribers. +* Resolves Subsonic `coverArt` when it equals the track id β€” prefers `albumId` and warmed album-grid art on playback and artist pages. +* No broken-image flash on cover surfaces while disk tiers warm (placeholder until a loadable URL exists). +* Rust cover pipeline decodes **PNG** bytes from the server (previously JPEG/WebP only); failed decode no longer leaves albums stuck with `.fetch-failed`. + + + ## [1.46.0] - 2026-05-18 > **πŸ™ Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) β€” several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bfc0eb09..02b82802 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -74,7 +74,7 @@ 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"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } webp = "0.3" [target.'cfg(unix)'.dependencies] diff --git a/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs new file mode 100644 index 00000000..598a046d --- /dev/null +++ b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs @@ -0,0 +1,242 @@ +//! Cover disk cache layout β€” **single place** to change directory naming. +//! +//! Callers pass `cache_kind` (`album` | `artist`) and `cache_entity_id` (server ids: +//! Navidrome `album.id` is often a bare hash/snowflake; `coverArt` may use `al-*`. +//! Rarely `mf-*` / `dc-*` on disk when UI enables per-disc art. Path shape: +//! +//! `{root}/{server_index_key}/{kind}/{entity_id}/128.webp` +//! +//! Bump [`LAYOUT_STAMP`] when the on-disk format changes (app wipes legacy dirs on startup). + +use std::path::{Path, PathBuf}; + +/// Written to `{cover_root}/.storage-layout` β€” mismatch triggers cache reset. +pub const LAYOUT_STAMP: &str = "canonical-segment-v3"; + +/// True for ids that are only valid as `getCoverArt` targets, not library entity keys. +pub fn is_fetch_only_cover_id(id: &str) -> bool { + let id = id.trim(); + id.starts_with("mf-") + || id.starts_with("tr-") + || id.starts_with("pl-") + || id.starts_with("dc-") + || id.starts_with("ra-") +} + +/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe). +pub fn sanitize_path_segment(segment: &str) -> String { + const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|']; + let trimmed = segment.trim(); + if trimmed.is_empty() { + return "_".to_string(); + } + trimmed + .chars() + .map(|c| if FORBIDDEN.contains(&c) { '_' } else { c }) + .collect() +} + +/// Relative path under `{root}/{server_index_key}/` β€” change format here only. +pub fn cover_entity_relative_dir(cache_kind: &str, cache_entity_id: &str) -> PathBuf { + let kind = sanitize_path_segment(cache_kind); + let entity = sanitize_path_segment(cache_entity_id); + PathBuf::from(kind).join(entity) +} + +/// Absolute directory for one cover entity (`…/album/al-…/` or `…/artist/ar-…/`). +pub fn cover_dir( + root: &Path, + server_index_key: &str, + cache_kind: &str, + cache_entity_id: &str, +) -> PathBuf { + root.join(server_index_key).join(cover_entity_relative_dir(cache_kind, cache_entity_id)) +} + +/// Resolved cover identity β€” keep in sync with TS `src/cover/resolveEntry.ts`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoverEntry { + pub cache_kind: &'static str, + pub cache_entity_id: String, + pub fetch_cover_art_id: String, +} + +/// Album β€” one disk slot per album; per-disc ids only when `distinct_disc_covers`. +pub fn resolve_album_cover( + album_id: &str, + cover_art_id: Option<&str>, + distinct_disc_covers: bool, +) -> Option { + let album = album_id.trim(); + if album.is_empty() { + return None; + } + let fetch = cover_art_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(album); + let cache_entity_id = if distinct_disc_covers && fetch != album { + fetch.to_string() + } else { + album.to_string() + }; + Some(CoverEntry { + cache_kind: "album", + cache_entity_id, + fetch_cover_art_id: fetch.to_string(), + }) +} + +/// Segment roots under `{server_index_key}/` (canonical layout). +pub const SEGMENT_KINDS: [&str; 2] = ["album", "artist"]; + +/// Progress / backfill β€œdone” heuristic β€” matches `LIBRARY_COVER_CANONICAL_TIER` in the library crate. +pub const CANONICAL_PROGRESS_TIER: u32 = 800; + +fn tier_webp_ready(path: &Path) -> bool { + path.is_file() && path.metadata().map(|m| m.len() > 0).unwrap_or(false) +} + +/// True when `{entity_dir}/{CANONICAL_PROGRESS_TIER}.webp` exists and is non-empty. +pub fn entity_dir_has_canonical_tier(entity_dir: &Path) -> bool { + tier_webp_ready(&entity_dir.join(format!("{CANONICAL_PROGRESS_TIER}.webp"))) +} + +/// Distinct album/artist entity dirs with canonical tier (segment layout only). +pub fn count_entities_with_canonical_tier(server_dir: &Path) -> i64 { + let mut n = 0i64; + for kind in SEGMENT_KINDS { + let kind_dir = server_dir.join(kind); + let Ok(entries) = std::fs::read_dir(&kind_dir) else { + continue; + }; + for ent in entries.flatten() { + if ent.path().is_dir() && entity_dir_has_canonical_tier(&ent.path()) { + n += 1; + } + } + } + n +} + +fn sum_webp_bytes_rec(dir: &Path) -> u64 { + let mut bytes = 0u64; + let Ok(entries) = std::fs::read_dir(dir) else { + return bytes; + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_dir() { + bytes += sum_webp_bytes_rec(&p); + } else if p.extension().and_then(|s| s.to_str()) == Some("webp") { + if let Ok(meta) = ent.metadata() { + bytes += meta.len(); + } + } + } + bytes +} + +/// All `.webp` bytes under one server bucket + entity count (canonical tier, segment dirs). +pub fn server_cover_disk_usage(server_dir: &Path) -> (u64, u64) { + ( + sum_webp_bytes_rec(server_dir), + count_entities_with_canonical_tier(server_dir) as u64, + ) +} + +/// Sum usage across every server subdirectory under `cover_root`. +pub fn cover_root_disk_usage(cover_root: &Path) -> (u64, u64) { + let mut bytes = 0u64; + let mut count = 0u64; + let Ok(entries) = std::fs::read_dir(cover_root) else { + return (0, 0); + }; + for ent in entries.flatten() { + let fname = ent.file_name(); + let name = fname.to_string_lossy(); + if name == ".storage-layout" || !ent.path().is_dir() { + continue; + } + let (b, c) = server_cover_disk_usage(&ent.path()); + bytes += b; + count += c; + } + (bytes, count) +} + +/// Artist β€” one disk slot per artist id. +pub fn resolve_artist_cover(artist_id: &str, cover_art_id: Option<&str>) -> Option { + let artist = artist_id.trim(); + if artist.is_empty() { + return None; + } + let fetch = cover_art_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(artist); + Some(CoverEntry { + cache_kind: "artist", + cache_entity_id: artist.to_string(), + fetch_cover_art_id: fetch.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_paths_use_kind_and_entity_id() { + let root = Path::new("/tmp/cover"); + let dir = cover_dir(root, "srv", "album", "al-1"); + assert_eq!(dir, root.join("srv").join("album").join("al-1")); + } + + #[test] + fn album_and_artist_segments_differ() { + let al = cover_entity_relative_dir("album", "al-1"); + let ar = cover_entity_relative_dir("artist", "ar-1"); + assert_ne!(al, ar); + } + + #[test] + fn per_disc_mf_entity_gets_own_dir() { + let d = cover_entity_relative_dir("album", "mf-disc2_abc"); + assert_eq!(d, PathBuf::from("album").join("mf-disc2_abc")); + } + + #[test] + fn resolve_album_bare_navidrome_id() { + let e = resolve_album_cover("0DurV2S7arIOBQVEknOPWX", Some("al-0Dur_abc"), false).unwrap(); + assert_eq!(e.cache_entity_id, "0DurV2S7arIOBQVEknOPWX"); + assert_eq!(e.fetch_cover_art_id, "al-0Dur_abc"); + } + + #[test] + fn resolve_album_per_disc_changes_cache_entity() { + let e = resolve_album_cover("al-box", Some("mf-d2"), true).unwrap(); + assert_eq!(e.cache_entity_id, "mf-d2"); + } + + fn test_server_dir(label: &str) -> std::path::PathBuf { + let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}")); + let _ = std::fs::remove_dir_all(&base); + base + } + + #[test] + fn segment_disk_usage_counts_canonical_only() { + let server = test_server_dir("usage"); + let entity = server.join("album").join("al-1"); + std::fs::create_dir_all(&entity).unwrap(); + std::fs::write(entity.join("128.webp"), b"x").unwrap(); + assert_eq!(count_entities_with_canonical_tier(&server), 0); + std::fs::write(entity.join("800.webp"), b"yy").unwrap(); + assert_eq!(count_entities_with_canonical_tier(&server), 1); + let (bytes, count) = server_cover_disk_usage(&server); + assert_eq!(count, 1); + assert!(bytes >= 3); + let _ = std::fs::remove_dir_all(&server); + } +} diff --git a/src-tauri/crates/psysonic-core/src/lib.rs b/src-tauri/crates/psysonic-core/src/lib.rs index 14224c52..c28dd60c 100644 --- a/src-tauri/crates/psysonic-core/src/lib.rs +++ b/src-tauri/crates/psysonic-core/src/lib.rs @@ -4,6 +4,7 @@ //! macros) and the cross-crate port traits used to break dependency cycles //! between `psysonic-audio`, `psysonic-analysis`, and other domain crates. +pub mod cover_cache_layout; pub mod logging; pub mod ports; pub mod track_analysis; diff --git a/src-tauri/crates/psysonic-library/src/analysis_backfill.rs b/src-tauri/crates/psysonic-library/src/analysis_backfill.rs index bf969244..1ddbe623 100644 --- a/src-tauri/crates/psysonic-library/src/analysis_backfill.rs +++ b/src-tauri/crates/psysonic-library/src/analysis_backfill.rs @@ -34,6 +34,13 @@ enum ScanMode { Full, } +/// Candidate SQL skips tracks that already have `content_hash` and an analysis BPM +/// fact. Those rows can still need waveform/LUFS β€” the full-table pass must start +/// from the first id, not from the last candidate cursor. +fn begin_full_library_scan() -> (ScanMode, Option) { + (ScanMode::Full, None) +} + pub fn collect_analysis_backfill_batch( app: &AppHandle, runtime: &LibraryRuntime, @@ -63,7 +70,7 @@ pub fn collect_analysis_backfill_batch( if page.is_empty() { match mode { ScanMode::Candidates => { - mode = ScanMode::Full; + (mode, after) = begin_full_library_scan(); continue; } ScanMode::Full => { @@ -98,7 +105,7 @@ pub fn collect_analysis_backfill_batch( if page_len < SCAN_CHUNK { match mode { ScanMode::Candidates => { - mode = ScanMode::Full; + (mode, after) = begin_full_library_scan(); } ScanMode::Full => { return Ok(LibraryAnalysisBackfillBatchDto { diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 7884784b..9f2c7e4d 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -16,6 +16,7 @@ use psysonic_integration::subsonic::SubsonicClient; use crate::advanced_search; use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto}; +use crate::cover_resolve::CoverEntryDto; use crate::cross_server; use crate::dto::{ count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto, @@ -51,6 +52,28 @@ pub struct LibraryServerKeyMigrationDto { pub index_key: String, } +/// Resolve cover disk + fetch ids from the local library (`album` | `artist` | `track`). +#[tauri::command] +pub fn library_resolve_cover_entry( + runtime: State<'_, LibraryRuntime>, + server_id: String, + entity: String, + entity_id: String, +) -> Result, String> { + let server_id = server_id.trim(); + let entity_id = entity_id.trim(); + if server_id.is_empty() || entity_id.is_empty() { + return Ok(None); + } + let store = &runtime.store; + match entity.trim() { + "album" => crate::cover_resolve::resolve_album_cover_entry(store, server_id, entity_id), + "artist" => crate::cover_resolve::resolve_artist_cover_entry(store, server_id, entity_id), + "track" => crate::cover_resolve::resolve_track_cover_entry(store, server_id, entity_id), + other => Err(format!("unknown cover entity kind: `{other}` (expected album|artist|track)")), + } +} + #[tauri::command] pub fn library_analysis_backfill_batch( app: AppHandle, diff --git a/src-tauri/crates/psysonic-library/src/cover_backfill.rs b/src-tauri/crates/psysonic-library/src/cover_backfill.rs index 1caa3513..f3842a1d 100644 --- a/src-tauri/crates/psysonic-library/src/cover_backfill.rs +++ b/src-tauri/crates/psysonic-library/src/cover_backfill.rs @@ -1,11 +1,15 @@ //! 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. +//! Catalog rows come from SQLite (`album` / `artist` tables) with explicit `kind`. +//! On-disk paths β€” `psysonic_core::cover_cache_layout`. -use std::path::Path; +use std::path::{Path, PathBuf}; +use psysonic_core::cover_cache_layout::{self, is_fetch_only_cover_id}; +use crate::cover_resolve::{ + cover_backfill_items_for_album, resolve_album_cover_entry, resolve_artist_cover_entry, + CoverEntryDto, +}; use crate::store::LibraryStore; const DEFAULT_BATCH: u32 = 32; @@ -13,9 +17,19 @@ 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 CoverBackfillItem { + pub cache_kind: String, + pub cache_entity_id: String, + pub fetch_cover_art_id: String, +} + #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct LibraryCoverBackfillBatchDto { + pub items: Vec, + /// Entity ids only β€” compatibility shim for older callers. pub cover_ids: Vec, pub next_cursor: Option, pub exhausted: bool, @@ -29,26 +43,108 @@ pub struct LibraryCoverProgressDto { 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 +/// `kind`, entity `id`, and HTTP `getCoverArt` id (Navidrome `cover_art_id` or fallback to entity id). +/// +/// The `artist` table is often empty (IS-4 only stores a watermark). Artists are also taken from +/// `track.artist_id` and `album.artist_id` so backfill matches library browse / prefetch. +const COVER_CATALOG_SUBQUERY: &str = " + SELECT 'album' AS kind, + TRIM(id) AS id, + COALESCE(NULLIF(TRIM(cover_art_id), ''), TRIM(id)) AS fetch_id FROM album - WHERE server_id = ?1 - AND ( - NULLIF(TRIM(id), '') IS NOT NULL - OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL - )"; + WHERE server_id = ?1 AND NULLIF(TRIM(id), '') IS NOT NULL + UNION ALL + SELECT 'album', + TRIM(album_id), + COALESCE(NULLIF(TRIM(cover_art_id), ''), TRIM(album_id)) + FROM track + WHERE server_id = ?1 AND deleted = 0 AND NULLIF(TRIM(album_id), '') IS NOT NULL + UNION ALL + SELECT 'artist', + TRIM(id), + TRIM(id) + FROM artist + WHERE server_id = ?1 AND NULLIF(TRIM(id), '') IS NOT NULL + UNION ALL + SELECT 'artist', + TRIM(artist_id), + TRIM(artist_id) + FROM track + WHERE server_id = ?1 AND deleted = 0 AND NULLIF(TRIM(artist_id), '') IS NOT NULL + UNION ALL + SELECT 'artist', + TRIM(artist_id), + TRIM(artist_id) + FROM album + WHERE server_id = ?1 AND NULLIF(TRIM(artist_id), '') IS NOT NULL"; + +/// Composite catalog cursor: `{kind}\x1f{id}` β€” avoids skipping rows when ids collide across kinds. +const CURSOR_SEP: char = '\x1f'; + +fn format_catalog_cursor(kind: &str, id: &str) -> String { + format!("{kind}{CURSOR_SEP}{id}") +} + +fn parse_catalog_cursor(cursor: &str) -> (String, String) { + if let Some((kind, id)) = cursor.split_once(CURSOR_SEP) { + return (kind.to_string(), id.to_string()); + } + // Legacy id-only cursors (pre composite): continue album scan by id. + ("album".to_string(), cursor.to_string()) +} pub const COVER_FETCH_FAIL_MARKER: &str = ".fetch-failed"; /// Recent HTTP failure β€” skip in backfill cursor so slots go to fetchable album art. +fn dto_to_backfill_item(dto: CoverEntryDto) -> CoverBackfillItem { + CoverBackfillItem { + cache_kind: dto.cache_kind, + cache_entity_id: dto.cache_entity_id, + fetch_cover_art_id: dto.fetch_cover_art_id, + } +} + +/// Re-resolve catalog row through `cover_resolve` (multi-CD per-disc `mf-*` slots, …). +fn expand_backfill_items( + store: &LibraryStore, + library_server_id: &str, + item: CoverBackfillItem, +) -> Result, String> { + match item.cache_kind.as_str() { + "album" => Ok(cover_backfill_items_for_album( + store, + library_server_id, + &item.cache_entity_id, + )? + .into_iter() + .map(dto_to_backfill_item) + .collect()), + "artist" => Ok(resolve_artist_cover_entry(store, library_server_id, &item.cache_entity_id)? + .into_iter() + .map(dto_to_backfill_item) + .collect()), + _ => { + let has_album_row: bool = store.with_read_conn(|conn| { + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM album WHERE server_id = ?1 AND id = ?2 + )", + rusqlite::params![library_server_id, item.cache_entity_id], + |row| row.get(0), + ) + })?; + if has_album_row { + Ok(resolve_album_cover_entry(store, library_server_id, &item.cache_entity_id)? + .into_iter() + .map(dto_to_backfill_item) + .collect()) + } else { + Ok(vec![item]) + } + } + } +} + 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 { @@ -66,45 +162,76 @@ pub fn cover_fetch_recently_failed(cover_dir: &Path) -> bool { /// 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; + for kind in cover_cache_layout::SEGMENT_KINDS { + let kind_dir = server_dir.join(kind); + let Ok(entries) = std::fs::read_dir(&kind_dir) else { + continue; + }; + for ent in entries.flatten() { + if !ent.path().is_dir() { + continue; + } + let marker = ent.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( +fn fetch_catalog_page( store: &LibraryStore, library_server_id: &str, after: &str, limit: i64, -) -> Result, String> { +) -> Result, String> { store.with_read_conn(|conn| { + let (after_kind, after_id) = parse_catalog_cursor(after); let sql = format!( - "SELECT id FROM ({COVER_ID_SUBQUERY}) - WHERE id > ?2 - ORDER BY id ASC - LIMIT ?3" + "SELECT kind, id, fetch_id FROM ( + SELECT kind, id, MAX(fetch_id) AS fetch_id + FROM ({COVER_CATALOG_SUBQUERY}) + GROUP BY kind, id + ) + WHERE kind > ?2 OR (kind = ?2 AND id > ?3) + ORDER BY kind ASC, id ASC + LIMIT ?4" ); let mut stmt = conn.prepare(&sql)?; - let ids = stmt - .query_map(rusqlite::params![library_server_id, after, limit], |row| { - row.get::<_, String>(0) + let rows = stmt + .query_map( + rusqlite::params![library_server_id, after_kind, after_id, limit], + |row| { + let kind: String = row.get(0)?; + let id: String = row.get(1)?; + let fetch_id: String = row.get(2)?; + Ok(CoverBackfillItem { + cache_kind: kind, + cache_entity_id: id.clone(), + fetch_cover_art_id: fetch_id, + }) })? .collect::, _>>()?; - Ok(ids) + Ok(rows + .into_iter() + .filter(|item| { + !item.cache_entity_id.is_empty() + && !is_fetch_only_cover_id(&item.cache_entity_id) + }) + .collect()) }) } pub fn count_distinct_cover_ids(store: &LibraryStore, library_server_id: &str) -> Result { store.with_read_conn(|conn| { - let sql = format!("SELECT COUNT(*) FROM ({COVER_ID_SUBQUERY})"); + let sql = format!( + "SELECT COUNT(*) FROM ( + SELECT kind, id FROM ({COVER_CATALOG_SUBQUERY}) + GROUP BY kind, id + )" + ); conn.query_row(&sql, rusqlite::params![library_server_id], |row| row.get(0)) }) } @@ -126,21 +253,27 @@ fn cover_ladder_complete_on_disk(dir: &Path) -> bool { .all(|&tier| tier_file_ready(dir, tier)) } +fn cover_cache_dir(cover_root: &Path, server_index_key: &str, kind: &str, entity_id: &str) -> PathBuf { + cover_cache_layout::cover_dir(cover_root, server_index_key, kind, entity_id) +} + pub fn cover_canonical_cached_on_disk( cover_root: &Path, server_index_key: &str, - cover_art_id: &str, + cache_kind: &str, + cache_entity_id: &str, ) -> bool { - let dir = cover_root.join(server_index_key).join(cover_art_id); + let dir = cover_cache_dir(cover_root, server_index_key, cache_kind, cache_entity_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, + cache_kind: &str, + cache_entity_id: &str, ) -> bool { - let dir = cover_root.join(server_index_key).join(cover_art_id); + let dir = cover_cache_dir(cover_root, server_index_key, cache_kind, cache_entity_id); cover_ladder_complete_on_disk(&dir) } @@ -161,32 +294,51 @@ pub fn collect_cover_backfill_batch( if pending.len() >= want { break; } - let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?; + let page = fetch_catalog_page(store, library_server_id, &after, SCAN_PAGE)?; + let page_len = page.len(); 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; + for item in page { + after = format_catalog_cursor(&item.cache_kind, &item.cache_entity_id); + for normalized in expand_backfill_items(store, library_server_id, item)? { + if cover_canonical_cached_on_disk( + cover_root, + server_index_key, + &normalized.cache_kind, + &normalized.cache_entity_id, + ) || cover_fetch_recently_failed(&cover_cache_dir( + cover_root, + server_index_key, + &normalized.cache_kind, + &normalized.cache_entity_id, + )) { + continue; + } + pending.push(normalized); + if pending.len() >= want { + break; + } } - pending.push(id.clone()); if pending.len() >= want { break; } } - if (page.len() as i64) < SCAN_PAGE { + if (page_len as i64) < SCAN_PAGE { sql_exhausted = true; break; } } + let cover_ids = pending + .iter() + .map(|i| i.cache_entity_id.clone()) + .collect(); + Ok(LibraryCoverBackfillBatchDto { - cover_ids: pending, + items: pending, + cover_ids, next_cursor: if sql_exhausted { None } else { Some(after) }, exhausted: sql_exhausted, }) @@ -202,17 +354,25 @@ pub fn count_pending_canonical_covers( let mut after = String::new(); let mut pending = 0i64; loop { - let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?; + let page = fetch_catalog_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; + let page_len = page.len(); + for item in page { + after = format_catalog_cursor(&item.cache_kind, &item.cache_entity_id); + for normalized in expand_backfill_items(store, library_server_id, item)? { + if !cover_canonical_cached_on_disk( + cover_root, + server_index_key, + &normalized.cache_kind, + &normalized.cache_entity_id, + ) { + pending += 1; + } } } - if (page.len() as i64) < SCAN_PAGE { + if (page_len as i64) < SCAN_PAGE { break; } } @@ -279,6 +439,24 @@ mod tests { .unwrap(); } + #[test] + fn backfill_includes_navidrome_bare_album_id() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "srv", "tr1", "0DurV2S7arIOBQVEknOPWX", 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!["0DurV2S7arIOBQVEknOPWX".to_string()]); + assert_eq!(batch.items[0].cache_kind, "album"); + assert_eq!(batch.items[0].fetch_cover_art_id, "0DurV2S7arIOBQVEknOPWX"); + } + #[test] fn backfill_uses_track_album_id_when_cover_art_null() { let store = LibraryStore::open_in_memory(); @@ -295,13 +473,39 @@ mod tests { assert_eq!(batch.cover_ids, vec!["al-99".to_string()]); } + #[test] + fn backfill_uses_stored_cover_art_id_for_fetch() { + let store = LibraryStore::open_in_memory(); + seed_track( + &store, + "srv", + "tr1", + "ca78bec6a62f3cb0ff31b2682ba05410", + Some("al-ca78bec6a62f3cb0ff31b2682ba05410_60fc987f"), + ); + let batch = collect_cover_backfill_batch( + &store, + "srv", + Path::new("/tmp/empty-cover-root"), + "srv-host", + None, + Some(10), + ) + .unwrap(); + assert_eq!(batch.items[0].cache_entity_id, "ca78bec6a62f3cb0ff31b2682ba05410"); + assert_eq!( + batch.items[0].fetch_cover_art_id, + "al-ca78bec6a62f3cb0ff31b2682ba05410_60fc987f" + ); + } + #[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"); + let id_dir = cover_cache_layout::cover_dir(&root, host, "album", "al-partial"); std::fs::create_dir_all(&id_dir).unwrap(); std::fs::write(id_dir.join("128.webp"), b"x").unwrap(); @@ -332,11 +536,120 @@ mod tests { } #[test] - fn count_distinct_includes_artist_ids() { + fn backfill_includes_per_disc_mf_when_discs_differ() { let store = LibraryStore::open_in_memory(); - seed_track(&store, "srv", "tr1", "al-1", Some("cv-1")); + store + .with_conn_mut("seed_box", |conn| { + conn.execute( + "INSERT INTO album (server_id, id, name, synced_at, raw_json) + VALUES ('srv', 'al-box', 'Box', 1, '{}')", + [], + )?; + conn.execute( + "INSERT INTO track ( + server_id, id, title, album, album_id, disc_number, duration_sec, deleted, synced_at, raw_json, cover_art_id + ) VALUES ('srv', 'tr1', 't', 'Box', 'al-box', 1, 200, 0, 1, '{}', 'mf-a')", + [], + )?; + conn.execute( + "INSERT INTO track ( + server_id, id, title, album, album_id, disc_number, duration_sec, deleted, synced_at, raw_json, cover_art_id + ) VALUES ('srv', 'tr2', 't', 'Box', 'al-box', 2, 200, 0, 1, '{}', 'mf-b')", + [], + )?; + Ok(()) + }) + .unwrap(); + let batch = collect_cover_backfill_batch( + &store, + "srv", + Path::new("/tmp/empty-cover-root"), + "srv-host", + None, + Some(10), + ) + .unwrap(); + let ids: Vec<_> = batch + .items + .iter() + .map(|i| i.cache_entity_id.as_str()) + .collect(); + assert!(ids.contains(&"mf-a")); + assert!(ids.contains(&"mf-b")); + } + + #[test] + fn backfill_includes_artists_from_track_without_artist_table() { + let store = LibraryStore::open_in_memory(); + store + .with_conn_mut("test_artist_track", |conn| { + conn.execute( + "INSERT INTO track ( + server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json + ) VALUES ('srv', 'tr1', 't', 'al', 'al-1', 'ar-from-track', 200, 0, 1, '{}')", + [], + )?; + Ok(()) + }) + .unwrap(); + let batch = collect_cover_backfill_batch( + &store, + "srv", + Path::new("/tmp/empty-cover-root"), + "srv-host", + None, + Some(10), + ) + .unwrap(); + assert_eq!(batch.items.len(), 2); + assert!(batch.items.iter().any(|i| i.cache_kind == "album" && i.cache_entity_id == "al-1")); + assert!( + batch + .items + .iter() + .any(|i| i.cache_kind == "artist" && i.cache_entity_id == "ar-from-track") + ); + } + + #[test] + fn catalog_cursor_kind_then_id_orders_artists_after_albums() { + let store = LibraryStore::open_in_memory(); + store + .with_conn_mut("seed", |conn| { + conn.execute( + "INSERT INTO track ( + server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json + ) VALUES ('srv', 'tr1', 't', 'al', 'al-z-last', 'ar-1', 200, 0, 1, '{}')", + [], + )?; + Ok(()) + }) + .unwrap(); + let batch = collect_cover_backfill_batch( + &store, + "srv", + Path::new("/tmp/x"), + "host", + Some("album\x1fal-z-last"), + Some(10), + ) + .unwrap(); + assert_eq!(batch.items.len(), 1); + assert_eq!(batch.items[0].cache_kind, "artist"); + assert_eq!(batch.items[0].cache_entity_id, "ar-1"); + } + + #[test] + fn count_distinct_includes_albums_and_artists_not_mf() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "srv", "tr1", "al-1", Some("mf-1")); store .with_conn_mut("test_artist", |conn| { + conn.execute( + "INSERT INTO artist (server_id, id, name, synced_at, raw_json) + VALUES ('srv', 'ar-1', 'A', 1, '{}')", + [], + )?; conn.execute( "INSERT INTO track ( server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json @@ -347,6 +660,6 @@ mod tests { }) .unwrap(); let n = count_distinct_cover_ids(&store, "srv").unwrap(); - assert_eq!(n, 2); // cv-1, al-1 β€” artist ids excluded from backfill catalog + assert_eq!(n, 3); // al-1, al-2, ar-1 β€” mf-1 is not an entity id } } diff --git a/src-tauri/crates/psysonic-library/src/cover_resolve.rs b/src-tauri/crates/psysonic-library/src/cover_resolve.rs new file mode 100644 index 00000000..02b61fd0 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/cover_resolve.rs @@ -0,0 +1,332 @@ +//! Resolve cover cache keys from the local library index β€” same rules as +//! `psysonic_core::cover_cache_layout` / TS `resolveEntry.ts`. + +use psysonic_core::cover_cache_layout::{resolve_album_cover, resolve_artist_cover, CoverEntry}; +use rusqlite::OptionalExtension; + +use crate::store::LibraryStore; + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverEntryDto { + pub cache_kind: String, + pub cache_entity_id: String, + pub fetch_cover_art_id: String, +} + +impl From for CoverEntryDto { + fn from(e: CoverEntry) -> Self { + Self { + cache_kind: e.cache_kind.to_string(), + cache_entity_id: e.cache_entity_id, + fetch_cover_art_id: e.fetch_cover_art_id, + } + } +} + +fn song_fetch_cover_art_id(cover_art_id: Option<&str>, song_id: &str, album_id: &str) -> String { + let album = album_id.trim(); + let song_id = song_id.trim(); + if let Some(cover) = cover_art_id.map(str::trim).filter(|s| !s.is_empty()) { + if song_id.is_empty() || cover != song_id { + return cover.to_string(); + } + } + album.to_string() +} + +pub fn album_has_distinct_disc_covers( + store: &LibraryStore, + library_server_id: &str, + album_id: &str, +) -> Result { + store.with_read_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT id, disc_number, cover_art_id, album_id + FROM track + WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0", + )?; + let rows = stmt.query_map(rusqlite::params![library_server_id, album_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + let mut art_by_disc: std::collections::HashMap = std::collections::HashMap::new(); + for row in rows { + let (track_id, disc_number, cover_art_id, row_album_id) = row?; + let disc = disc_number.unwrap_or(1); + let al = row_album_id + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(album_id); + let fetch = song_fetch_cover_art_id(cover_art_id.as_deref(), &track_id, al); + if let Some(prev) = art_by_disc.get(&disc) { + if prev != &fetch { + return Ok(true); + } + } else { + art_by_disc.insert(disc, fetch); + } + } + if art_by_disc.len() <= 1 { + return Ok(false); + } + let unique: std::collections::HashSet<_> = art_by_disc.values().collect(); + Ok(unique.len() > 1) + }) +} + +pub fn resolve_album_cover_entry( + store: &LibraryStore, + library_server_id: &str, + album_id: &str, +) -> Result, String> { + let album_id = album_id.trim(); + if album_id.is_empty() { + return Ok(None); + } + let cover_art_id = match store.with_read_conn(|conn| { + conn.query_row( + "SELECT cover_art_id FROM album WHERE server_id = ?1 AND id = ?2", + rusqlite::params![library_server_id, album_id], + |row| row.get::<_, Option>(0), + ) + .optional() + })? { + None => return Ok(None), + Some(v) => v, + }; + let distinct = album_has_distinct_disc_covers(store, library_server_id, album_id)?; + Ok(resolve_album_cover(album_id, cover_art_id.as_deref(), distinct).map(Into::into)) +} + +/// Album id appears only on `track` rows (no `album` table row) β€” mirror catalog `fetch_id`. +fn track_only_album_backfill_entry( + store: &LibraryStore, + library_server_id: &str, + album_id: &str, +) -> Result, String> { + store + .with_read_conn(|conn| { + conn.query_row( + "SELECT COALESCE(NULLIF(TRIM(cover_art_id), ''), TRIM(album_id)) + FROM track + WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 + ORDER BY id ASC + LIMIT 1", + rusqlite::params![library_server_id, album_id], + |row| { + let fetch: String = row.get(0)?; + Ok(resolve_album_cover(album_id, Some(fetch.as_str()), false).map(Into::into)) + }, + ) + .optional() + }) + .map(|opt| opt.flatten()) +} + +/// All disk slots to warm for one album β€” includes per-CD `mf-*` / `dc-*` dirs when discs differ. +pub fn cover_backfill_items_for_album( + store: &LibraryStore, + library_server_id: &str, + album_id: &str, +) -> Result, String> { + let album_id = album_id.trim(); + if album_id.is_empty() { + return Ok(Vec::new()); + } + let distinct = album_has_distinct_disc_covers(store, library_server_id, album_id)?; + if !distinct { + if let Some(dto) = resolve_album_cover_entry(store, library_server_id, album_id)? { + return Ok(vec![dto]); + } + return Ok(track_only_album_backfill_entry(store, library_server_id, album_id)? + .into_iter() + .collect()); + } + + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + let mut push = |dto: CoverEntryDto| { + if seen.insert(dto.cache_entity_id.clone()) { + out.push(dto); + } + }; + + if let Some(dto) = resolve_album_cover_entry(store, library_server_id, album_id)? { + push(dto); + } + + store.with_read_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT id, disc_number, cover_art_id, album_id + FROM track + WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0", + )?; + let rows = stmt.query_map(rusqlite::params![library_server_id, album_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + for row in rows { + let (track_id, disc_number, cover_art_id, row_album_id) = row?; + let _disc = disc_number.unwrap_or(1); + let al = row_album_id + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(album_id); + let fetch = song_fetch_cover_art_id(cover_art_id.as_deref(), &track_id, al); + if let Some(entry) = resolve_album_cover(album_id, Some(fetch.as_str()), true) { + push(entry.into()); + } + } + Ok(()) + })?; + + Ok(out) +} + +pub fn resolve_artist_cover_entry( + _store: &LibraryStore, + _library_server_id: &str, + artist_id: &str, +) -> Result, String> { + let artist_id = artist_id.trim(); + if artist_id.is_empty() { + return Ok(None); + } + Ok(resolve_artist_cover(artist_id, None).map(Into::into)) +} + +pub fn resolve_track_cover_entry( + store: &LibraryStore, + library_server_id: &str, + track_id: &str, +) -> Result, String> { + let track_id = track_id.trim(); + if track_id.is_empty() { + return Ok(None); + } + let row: Option<(String, Option, Option)> = store.with_read_conn(|conn| { + conn.query_row( + "SELECT id, cover_art_id, album_id FROM track + WHERE server_id = ?1 AND id = ?2 AND deleted = 0", + rusqlite::params![library_server_id, track_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + )) + }, + ) + .optional() + })?; + let Some((id, cover_art_id, Some(album_id))) = row else { + return Ok(None); + }; + let album_id = album_id.trim(); + if album_id.is_empty() { + return Ok(None); + } + let fetch = song_fetch_cover_art_id(cover_art_id.as_deref(), &id, album_id); + let distinct = album_has_distinct_disc_covers(store, library_server_id, album_id)?; + Ok(resolve_album_cover(album_id, Some(fetch.as_str()), distinct).map(Into::into)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::LibraryStore; + + fn seed_album(store: &LibraryStore, server_id: &str, album_id: &str, cover_art: Option<&str>) { + store + .with_conn_mut("seed_album", |conn| { + conn.execute( + "INSERT INTO album ( + server_id, id, name, cover_art_id, synced_at, raw_json + ) VALUES (?1, ?2, 'A', ?3, 1, '{}')", + rusqlite::params![server_id, album_id, cover_art], + )?; + Ok(()) + }) + .unwrap(); + } + + fn seed_track( + store: &LibraryStore, + server_id: &str, + track_id: &str, + album_id: &str, + disc: i64, + cover: Option<&str>, + ) { + store + .with_conn_mut("seed_track", |conn| { + conn.execute( + "INSERT INTO track ( + server_id, id, title, album, album_id, disc_number, + duration_sec, deleted, synced_at, raw_json, cover_art_id + ) VALUES (?1, ?2, 't', 'A', ?3, ?4, 200, 0, 1, '{}', ?5)", + rusqlite::params![server_id, track_id, album_id, disc, cover], + )?; + Ok(()) + }) + .unwrap(); + } + + #[test] + fn resolve_album_uses_bare_id_and_stored_cover_art() { + let store = LibraryStore::open_in_memory(); + seed_album( + &store, + "srv", + "ca78bec6", + Some("al-ca78bec6_60fc987f"), + ); + let e = resolve_album_cover_entry(&store, "srv", "ca78bec6") + .unwrap() + .unwrap(); + assert_eq!(e.cache_entity_id, "ca78bec6"); + assert_eq!(e.fetch_cover_art_id, "al-ca78bec6_60fc987f"); + } + + #[test] + fn resolve_track_defaults_to_album_bucket() { + let store = LibraryStore::open_in_memory(); + seed_album(&store, "srv", "al-1", None); + seed_track(&store, "srv", "tr1", "al-1", 1, Some("mf-a")); + let e = resolve_track_cover_entry(&store, "srv", "tr1").unwrap().unwrap(); + assert_eq!(e.cache_entity_id, "al-1"); + assert_eq!(e.fetch_cover_art_id, "mf-a"); + } + + #[test] + fn backfill_album_slots_include_each_disc_mf() { + let store = LibraryStore::open_in_memory(); + seed_album(&store, "srv", "al-box", None); + seed_track(&store, "srv", "tr1", "al-box", 1, Some("mf-a")); + seed_track(&store, "srv", "tr2", "al-box", 2, Some("mf-b")); + let items = cover_backfill_items_for_album(&store, "srv", "al-box").unwrap(); + let ids: Vec<_> = items.iter().map(|i| i.cache_entity_id.as_str()).collect(); + assert!(ids.contains(&"mf-a")); + assert!(ids.contains(&"mf-b")); + } + + #[test] + fn distinct_disc_covers_change_cache_entity() { + let store = LibraryStore::open_in_memory(); + seed_album(&store, "srv", "al-box", None); + seed_track(&store, "srv", "tr1", "al-box", 1, Some("mf-a")); + seed_track(&store, "srv", "tr2", "al-box", 2, Some("mf-b")); + assert!(album_has_distinct_disc_covers(&store, "srv", "al-box").unwrap()); + let e = resolve_track_cover_entry(&store, "srv", "tr2").unwrap().unwrap(); + assert_eq!(e.cache_entity_id, "mf-b"); + } +} diff --git a/src-tauri/crates/psysonic-library/src/lib.rs b/src-tauri/crates/psysonic-library/src/lib.rs index 7d05778a..d5a2c070 100644 --- a/src-tauri/crates/psysonic-library/src/lib.rs +++ b/src-tauri/crates/psysonic-library/src/lib.rs @@ -15,6 +15,7 @@ mod advanced_search_mood; pub mod analysis_backfill; pub mod artist_lossless_browse; pub mod cover_backfill; +pub mod cover_resolve; pub mod canonical; pub mod commands; pub mod cross_server; diff --git a/src-tauri/src/cover_cache/backfill_worker.rs b/src-tauri/src/cover_cache/backfill_worker.rs index 354c2693..218642fc 100644 --- a/src-tauri/src/cover_cache/backfill_worker.rs +++ b/src-tauri/src/cover_cache/backfill_worker.rs @@ -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, 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) { 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) { 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() {} diff --git a/src-tauri/src/cover_cache/disk.rs b/src-tauri/src/cover_cache/disk.rs index d414e168..ac31712d 100644 --- a/src-tauri/src/cover_cache/disk.rs +++ b/src-tauri/src/cover_cache/disk.rs @@ -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 { diff --git a/src-tauri/src/cover_cache/mod.rs b/src-tauri/src/cover_cache/mod.rs index def71a86..1c3074cc 100644 --- a/src-tauri/src/cover_cache/mod.rs +++ b/src-tauri/src/cover_cache/mod.rs @@ -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, + pub cover_cpu_ui_sem: Arc, + pub cover_cpu_backfill_sem: Arc, } 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 { + 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>, ) -> Result { 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 = 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 { - 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), + } + + 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 { None } -async fn load_cover_source( - dir: &Path, +async fn download_cover_payload( + _dir: &Path, client: &Client, http_sem: &Semaphore, args: &CoverCacheEnsureArgs, -) -> Result { - if let Some(img) = load_image_from_disk(dir) { - return Ok(img); - } +) -> Result, 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>, String> { @@ -445,7 +432,7 @@ fn state(app: &AppHandle) -> Result>, 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 { #[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 { - 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 { #[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); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0eb46021..ef024914 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -734,6 +734,7 @@ 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, + psysonic_library::commands::library_resolve_cover_entry, cover_cache::cover_cache_peek_batch, cover_cache::cover_cache_ensure, cover_cache::cover_cache_ensure_batch, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dcdaa3db..b36e9b5c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "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/**"] + "scope": ["$APPDATA/**", "$APPLOCALDATA/**", "$DATA/**"] } } }, diff --git a/src/api/coverCache.test.ts b/src/api/coverCache.test.ts index 9126bab6..e24aa4d8 100644 --- a/src/api/coverCache.test.ts +++ b/src/api/coverCache.test.ts @@ -67,7 +67,7 @@ describe('coverCacheEnsure', () => { const call = invokeMock.mock.calls.find(c => c[0] === 'cover_cache_ensure'); expect(call).toBeTruthy(); - const payload = call?.[1] as Record; + const payload = (call?.[1] as { args: Record }).args; expect(payload.restBaseUrl).toBe('http://playback.example:5533'); expect(payload.username).toBe('playback-user'); expect(payload.password).toBe('playback-pass'); diff --git a/src/api/coverCache.ts b/src/api/coverCache.ts index f0bed847..92090fcd 100644 --- a/src/api/coverCache.ts +++ b/src/api/coverCache.ts @@ -1,6 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; -import { coverIndexKeyFromRef } from '../cover/storageKeys'; +import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys'; import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { restBaseFromUrl } from './subsonicClient'; @@ -44,7 +44,9 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { if (scope.kind === 'server') { return { serverIndexKey: coverIndexKeyFromRef(ref), - coverArtId: ref.coverArtId, + cacheKind: ref.cacheKind, + cacheEntityId: ref.cacheEntityId, + coverArtId: ref.fetchCoverArtId, tier, restBaseUrl: coverCacheRestHost(scope.url), username: scope.username, @@ -67,7 +69,9 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { const baseUrl = server?.url || getBaseUrl(); return { serverIndexKey: coverIndexKeyFromRef(ref), - coverArtId: ref.coverArtId, + cacheKind: ref.cacheKind, + cacheEntityId: ref.cacheEntityId, + coverArtId: ref.fetchCoverArtId, tier, restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '', username: server?.username ?? '', @@ -77,22 +81,26 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) { export type CoverCachePeekItem = { serverIndexKey: string; - coverArtId: string; + cacheKind: 'album' | 'artist'; + cacheEntityId: string; tier: CoverArtTier; + storageKey: string; }; /** Disk-only β€” no HTTP. Returns map storageKey β†’ absolute .webp path. */ export async function coverCachePeekBatch( - items: CoverCachePeekItem[], + refs: CoverArtRef[], + tier: CoverArtTier, ): Promise> { - if (items.length === 0) return {}; - const raw = await invoke>('cover_cache_peek_batch', { items }); - const out: Record = {}; - for (const item of items) { - const key = `${item.serverIndexKey}:cover:${item.coverArtId}:${item.tier}`; - if (raw[key]) out[key] = raw[key]; - } - return out; + if (refs.length === 0) return {}; + const items: CoverCachePeekItem[] = refs.map(ref => ({ + serverIndexKey: coverIndexKeyFromRef(ref), + cacheKind: ref.cacheKind, + cacheEntityId: ref.cacheEntityId, + tier, + storageKey: coverStorageKeyFromRef(ref, tier), + })); + return invoke>('cover_cache_peek_batch', { items }); } export async function coverCacheEnsure( @@ -100,7 +108,9 @@ export async function coverCacheEnsure( tier: CoverArtTier, _priority?: string, ): Promise { - return invoke('cover_cache_ensure', ensureArgsFromRef(ref, tier)); + return invoke('cover_cache_ensure', { + args: ensureArgsFromRef(ref, tier), + }); } export async function coverCacheEnsureBatch( diff --git a/src/api/subsonic.contract.test.ts b/src/api/subsonic.contract.test.ts index 8901d184..f3e9cb2e 100644 --- a/src/api/subsonic.contract.test.ts +++ b/src/api/subsonic.contract.test.ts @@ -130,12 +130,12 @@ describe('getClient', () => { describe('coverArtCacheKey', () => { it('uses host index key + entity id + tier as a stable cache key', () => { setUpServer(); - expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:cover-1:256'); - expect(coverArtCacheKey('cover-1', 200)).toBe('music.example.com:cover:cover-1:200'); + expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:album:cover-1:256'); + expect(coverArtCacheKey('cover-1', 200)).toBe('music.example.com:cover:album:cover-1:200'); }); it('falls back to "_" as the server-id segment when no server is active', () => { - expect(coverArtCacheKey('cover-99')).toBe('_:cover:cover-99:256'); + expect(coverArtCacheKey('cover-99')).toBe('_:cover:album:cover-99:256'); }); it('does not embed the ephemeral salt or token β€” keys stay cacheable across calls', () => { @@ -214,7 +214,7 @@ describe('buildCoverArtUrlForServer', () => { describe('coverArtCacheKeyForServer', () => { it('scopes cache keys by host index key when profile is known', () => { const profileId = setUpServer({ url: 'https://b.example' }); - expect(coverArtCacheKeyForServer(profileId, 'cover-1', 80)).toBe('b.example:cover:cover-1:80'); + expect(coverArtCacheKeyForServer(profileId, 'cover-1', 80)).toBe('b.example:cover:album:cover-1:80'); }); }); diff --git a/src/api/subsonicStreamUrl.ts b/src/api/subsonicStreamUrl.ts index 2168b9c9..1b1833bd 100644 --- a/src/api/subsonicStreamUrl.ts +++ b/src/api/subsonicStreamUrl.ts @@ -1,5 +1,6 @@ import md5 from 'md5'; -import { coverStorageKey } from '../cover/storageKeys'; +import { coverStorageKey, coverStorageKeyFromRef } from '../cover/storageKeys'; +import { coverEntryToRef, resolveAlbumCoverEntry } from '../cover/resolveEntry'; import type { CoverArtTier } from '../cover/types'; import { useAuthStore } from '../store/authStore'; import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; @@ -57,14 +58,18 @@ export function buildStreamUrl(id: string): string { /** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` β€” shim until migration. */ export function coverArtCacheKey(id: string, size = 256): string { - return coverStorageKey({ kind: 'active' }, id, size as CoverArtTier); + const entry = resolveAlbumCoverEntry(id, id); + const ref = coverEntryToRef(entry ?? { cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id }); + return coverStorageKeyFromRef(ref, size as CoverArtTier); } /** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` β€” shim until migration. */ export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, size = 256): string { const server = findServerByIdOrIndexKey(serverIdOrKey); - if (!server) return `${serverIdOrKey}:cover:${id}:${size}`; - return coverStorageKey( + if (!server) return `${serverIdOrKey}:cover:album:${id}:${size}`; + const entry = resolveAlbumCoverEntry(id, id); + const ref = coverEntryToRef( + entry ?? { cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id }, { kind: 'server', serverId: server.id, @@ -72,9 +77,8 @@ export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, siz username: server.username, password: server.password, }, - id, - size as CoverArtTier, ); + return coverStorageKeyFromRef(ref, size as CoverArtTier); } /** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` β€” shim until migration. */ diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 6d74e8d4..5ddc2a97 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -9,9 +9,10 @@ import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; import { CoverArtImage } from '../cover/CoverArtImage'; +import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; +import { coverStorageKeyFromRef } from '../cover/storageKeys'; import type { CoverPrefetchPriority } from '../cover/types'; import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes'; -import { coverStorageKey } from '../cover/storageKeys'; import { resolveCoverDisplayTier } from '../cover/tiers'; import { acquireUrl } from '../utils/imageCache/urlPool'; import { OpenArtistRefInline } from './OpenArtistRefInline'; @@ -38,6 +39,8 @@ interface AlbumCardProps { observeScrollRootId?: string; /** `high` for bounded grids (Random Albums, …) β€” skip defer-until-visible. */ ensurePriority?: CoverPrefetchPriority; + /** Artist/detail grids: API `coverArt` is enough β€” skip per-card library_resolve IPC. */ + libraryResolve?: boolean; } function AlbumCard({ @@ -53,6 +56,7 @@ function AlbumCard({ observeScrollRootId, ensurePriority, linkQuery, + libraryResolve = false, }: AlbumCardProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -65,11 +69,12 @@ function AlbumCard({ return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]); }); const psyDrag = useDragDrop(); + const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve }); const dragCoverKey = useMemo(() => { - if (!album.coverArt) return ''; + if (!coverRef) return ''; const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' }); - return coverStorageKey({ kind: 'active' }, album.coverArt, tier); - }, [album.coverArt, displayCssPx]); + return coverStorageKeyFromRef(coverRef, tier); + }, [coverRef, displayCssPx]); const isNewAlbum = isAlbumRecentlyAdded(album.created); const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]); @@ -112,9 +117,9 @@ function AlbumCard({ }} >
- {!disableArtwork && album.coverArt ? ( + {!disableArtwork && coverRef ? ( s.enableCoverArtBackground); - const coverRef = useMemo( - () => (coverArtId ? coverArtRef(coverArtId) : null), - [coverArtId], - ); + const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true }); const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: `${info.name} Cover`, }); @@ -167,7 +164,7 @@ export default function AlbumHeader({ {t('albumDetail.back')}
- {coverArtId ? ( + {coverRef ? ( - {song.coverArt && ( - - )} + {(() => { + const albumRow = song.albumId + ? albums.find(a => a.id === song.albumId) + : albums.find(a => a.name === song.album); + const coverId = resolveArtistPageSongCoverArtId(song, albums); + return coverId && song.albumId ? ( + + ) : null; + })()}
{song.title}
diff --git a/src/components/artistDetail/ArtistSuggestionTrackCover.tsx b/src/components/artistDetail/ArtistSuggestionTrackCover.tsx deleted file mode 100644 index 3fbd6db8..00000000 --- a/src/components/artistDetail/ArtistSuggestionTrackCover.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { CoverArtImage } from '../../cover/CoverArtImage'; -import { COVER_DENSE_ARTIST_LIST_CSS_PX } from '../../cover/layoutSizes'; - -export default function ArtistSuggestionTrackCover({ coverArt, album }: { coverArt: string; album: string }) { - return ( - { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} - /> - ); -} diff --git a/src/components/artistDetail/ArtistTopTrackCover.tsx b/src/components/artistDetail/ArtistTopTrackCover.tsx new file mode 100644 index 00000000..7d657f4b --- /dev/null +++ b/src/components/artistDetail/ArtistTopTrackCover.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage'; +import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '../../cover/layoutSizes'; + +export default function ArtistTopTrackCover({ + albumId, + coverArt, + album, +}: { + albumId: string; + coverArt: string; + album: string; +}) { + return ( + + ); +} diff --git a/src/components/artists/ArtistAvatars.tsx b/src/components/artists/ArtistAvatars.tsx index 8bbcb70d..3e8ea6e3 100644 --- a/src/components/artists/ArtistAvatars.tsx +++ b/src/components/artists/ArtistAvatars.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type { SubsonicArtist } from '../../api/subsonicTypes'; -import { CoverArtImage } from '../../cover/CoverArtImage'; -import { coverArtIdFromArtist } from '../../cover/ids'; +import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage'; import { COVER_DENSE_ARTIST_LIST_CSS_PX, COVER_DENSE_GRID_MIN_CELL_CSS_PX, @@ -21,12 +20,12 @@ interface AvatarProps { */ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) { const color = nameColor(artist.name); - const coverId = coverArtIdFromArtist(artist); if (showImages && (artist.coverArt || artist.id)) { return (
- - {s.coverArt ? (
{coverId ? ( - {s.coverArt ? ( ) : !coverRemoved && station?.coverArt ? ( {a.coverArt ? ( - void; onToggleStar: () => void; onToggleLfmLove: () => void; @@ -40,7 +42,7 @@ function renderStars(rating?: number) { ); } -const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverUrl, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) { +const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) { const { t } = useTranslation(); const rating = userRatingOverride ?? track.userRating; const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000; @@ -49,9 +51,18 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l return (
- {coverUrl - ? - :
} + {coverRef ? ( + + ) : ( +
+ )}
{track.title}
@@ -160,4 +171,4 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l ); }); -export default Hero; +export default Hero; \ No newline at end of file diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx index 31531c06..a1463cdf 100644 --- a/src/components/playerBar/PlayerTrackInfo.tsx +++ b/src/components/playerBar/PlayerTrackInfo.tsx @@ -6,6 +6,9 @@ import type { PlayerState, Track } from '../../store/playerStoreTypes'; import type { RadioMetadata } from '../../hooks/useRadioMetadata'; import type { PreviewingTrack } from '../../store/previewStore'; import { CoverArtImage } from '../../cover/CoverArtImage'; +import { albumCoverRef } from '../../cover/ref'; +import { useAlbumCoverRef } from '../../cover/useLibraryCoverRef'; +import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef'; import LastfmIcon from '../LastfmIcon'; import MarqueeText from '../MarqueeText'; import { OpenArtistRefInline } from '../OpenArtistRefInline'; @@ -24,7 +27,6 @@ interface Props { radioMeta: RadioMetadata; radioCoverArtId?: string; coverArtId?: string; - displayCoverArt: string | undefined; displayTitle: string; displayArtist: string; /** When set (OpenSubsonic `artists` on the playing track), render split links like album track rows. */ @@ -45,13 +47,20 @@ interface Props { export function PlayerTrackInfo({ currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId, - coverArtId, displayCoverArt, displayTitle, displayArtist, displayArtistRefs, + coverArtId, displayTitle, displayArtist, displayArtistRefs, showPreviewMeta, previewingTrack, isStarred, toggleStar, lastfmSessionKey, lastfmLoved, toggleLastfmLove, userRatingOverrides, toggleFullscreen, navigate, openContextMenu, t, }: Props) { const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering); + const playbackCoverRef = usePlaybackTrackCoverRef( + showPreviewMeta ? null : currentTrack ?? undefined, + ); + const previewCoverRef = useAlbumCoverRef( + showPreviewMeta ? coverArtId : null, + showPreviewMeta ? coverArtId : null, + ); const layoutItems = usePlayerBarLayoutStore(s => s.items); const isLayoutVisible = (id: PlayerBarLayoutItemId) => layoutItems.find(i => i.id === id)?.visible !== false; @@ -64,10 +73,10 @@ export function PlayerTrackInfo({ data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined} > {isRadio ? ( - radioCoverArtId ? ( + radioCoverArtId && currentRadio ? (
) - ) : coverArtId ? ( + ) : !isRadio && (showPreviewMeta ? coverArtId : playbackCoverRef) ? ( ) : ( diff --git a/src/components/playlist/PlaylistEditModal.tsx b/src/components/playlist/PlaylistEditModal.tsx index d95b7647..21c2f406 100644 --- a/src/components/playlist/PlaylistEditModal.tsx +++ b/src/components/playlist/PlaylistEditModal.tsx @@ -4,6 +4,7 @@ import { Camera, Loader2, X } from 'lucide-react'; import type { SubsonicPlaylist } from '../../api/subsonicTypes'; import type { CoverArtId } from '../../cover/types'; import { CoverArtImage } from '../../cover/CoverArtImage'; +import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage'; import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers'; import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages'; @@ -79,8 +80,9 @@ export default function PlaylistEditModal({ {coverPreview ? ( ) : !coverRemoved && customCoverId ? ( - setEditingMeta(true)} > {customCoverId ? ( - - +
{song.title} {song.artist} Β· {song.album} diff --git a/src/components/playlists/PlaylistCoverImages.tsx b/src/components/playlists/PlaylistCoverImages.tsx index 08bdf65e..7a7c59d8 100644 --- a/src/components/playlists/PlaylistCoverImages.tsx +++ b/src/components/playlists/PlaylistCoverImages.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { CoverArtImage } from '../../cover/CoverArtImage'; +import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage'; /** 2Γ—2 collage cell β€” half of clamp(120px, 15vw, 200px) playlist hero grid. */ const PLAYLIST_QUAD_CELL_CSS_PX = 100; @@ -8,8 +8,9 @@ const PLAYLIST_MAIN_COVER_CSS_PX = 200; export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) { return ( - ; orbitAttributionLabel: (trackId: string) => string | null; navigate: (to: string) => void | Promise; @@ -42,7 +43,7 @@ interface Props { } export function QueueCurrentTrack({ - currentTrack, currentCoverSrc, userRatingOverrides, orbitAttributionLabel, + currentTrack, userRatingOverrides, orbitAttributionLabel, navigate, playbackSource, normalizationEngine, normalizationEngineLive, normalizationNowDb, normalizationTargetLufs, authLoudnessTargetLufs, loudnessPreAnalysisAttenuationDb, expandReplayGain, setExpandReplayGain, @@ -50,6 +51,7 @@ export function QueueCurrentTrack({ lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t, }: Props) { const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering); + const coverRef = usePlaybackTrackCoverRef(currentTrack); const enrichment = useQueueTrackEnrichment(currentTrack.id); const bpmTech = formatQueueBpmTech(enrichment, t); const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t); @@ -201,8 +203,15 @@ export function QueueCurrentTrack({ })()}
- {currentTrack.coverArt && currentCoverSrc ? ( - + {coverRef ? ( + ) : (
)} diff --git a/src/components/search/ShareQueuePreviewModal.tsx b/src/components/search/ShareQueuePreviewModal.tsx index 95aae874..1115dc9e 100644 --- a/src/components/search/ShareQueuePreviewModal.tsx +++ b/src/components/search/ShareQueuePreviewModal.tsx @@ -11,7 +11,8 @@ import OverlayScrollArea from '../OverlayScrollArea'; import { usePlayerStore } from '../../store/playerStore'; import { CoverArtImage } from '../../cover/CoverArtImage'; import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes'; -import type { CoverServerScope } from '../../cover/types'; +import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../../cover/types'; +import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage'; type ShareQueuePreviewModalProps = { open: boolean; @@ -36,7 +37,7 @@ function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerS password: coverServer.password, }; } - return { kind: 'active' }; + return COVER_SCOPE_ACTIVE; } function QueuePreviewTrackRow({ @@ -51,11 +52,12 @@ function QueuePreviewTrackRow({ return (
  • {song.coverArt ? ( - diff --git a/src/components/search/ShareSearchResults.tsx b/src/components/search/ShareSearchResults.tsx index 507da35a..99251737 100644 --- a/src/components/search/ShareSearchResults.tsx +++ b/src/components/search/ShareSearchResults.tsx @@ -9,10 +9,10 @@ import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchP import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch'; import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview'; import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage'; -import { CoverArtImage } from '../../cover/CoverArtImage'; +import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage'; +import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage'; import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes'; -import { coverArtIdFromArtist } from '../../cover/ids'; -import type { CoverServerScope } from '../../cover/types'; +import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../../cover/types'; import { useShareQueuePreview } from '../../hooks/useShareQueuePreview'; import ShareQueuePreviewModal from './ShareQueuePreviewModal'; @@ -42,25 +42,28 @@ function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerS password: coverServer.password, }; } - return { kind: 'active' }; + return COVER_SCOPE_ACTIVE; } function ShareAlbumThumb({ + albumId, coverArt, displayCssPx, coverServer, }: { + albumId: string; coverArt: string; displayCssPx: number; coverServer?: ServerProfile | null; }) { const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb'; return ( - @@ -77,8 +80,7 @@ function ShareArtistThumb({ coverServer?: ServerProfile | null; }) { const [failed, setFailed] = useState(false); - const coverId = coverArtIdFromArtist(artist); - useEffect(() => { setFailed(false); }, [coverId]); + useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]); if (failed) { if (displayCssPx >= 64) { @@ -100,11 +102,12 @@ function ShareArtistThumb({ ? 'mobile-search-thumb mobile-search-thumb--artist-round' : 'search-result-thumb'; return ( - {shareAlbum.coverArt ? ( - + ) : ( )} @@ -370,7 +373,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) { aria-selected={desktop ? activeIndex === 0 : undefined} > {shareTrackSong.coverArt ? ( - + ) : ( )} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index e5b78e10..a9bf6455 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -134,6 +134,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Lossless: local index browse, Advanced Search and All Albums filters, artist/album drill-down mode, conserved sidebar page (PR #871)', 'Albums: combined browse filters (genre/year/favorites/lossless/compilations), session restore from album detail, favorites reconcile via local index (PR #876)', 'Artist detail: sort albums by year (newest/oldest) in the Albums section (PR #877)', + 'Cover art: Windows thumbnails, tier fallback, PNG decode, Subsonic coverArt id resolution (PR #878)', ], }, { diff --git a/src/cover/AlbumCoverArtImage.tsx b/src/cover/AlbumCoverArtImage.tsx new file mode 100644 index 00000000..8422d48c --- /dev/null +++ b/src/cover/AlbumCoverArtImage.tsx @@ -0,0 +1,28 @@ +import { CoverArtImage, type CoverArtImageProps } from './CoverArtImage'; +import { useAlbumCoverRef } from './useLibraryCoverRef'; +import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types'; + +export type AlbumCoverArtImageProps = Omit & { + albumId: string; + coverArt?: string | null; + serverScope?: CoverServerScope; + /** Live search: use API `coverArt` ids only (avoids library IPC per row). */ + libraryResolve?: boolean; +}; + +export function AlbumCoverArtImage({ + albumId, + coverArt, + serverScope, + libraryResolve = false, + ...rest +}: AlbumCoverArtImageProps) { + const coverRef = useAlbumCoverRef( + albumId, + coverArt, + serverScope ?? COVER_SCOPE_ACTIVE, + { libraryResolve }, + ); + if (!coverRef) return null; + return ; +} diff --git a/src/cover/ArtistCoverArtImage.tsx b/src/cover/ArtistCoverArtImage.tsx new file mode 100644 index 00000000..3b9f0fe8 --- /dev/null +++ b/src/cover/ArtistCoverArtImage.tsx @@ -0,0 +1,27 @@ +import { CoverArtImage, type CoverArtImageProps } from './CoverArtImage'; +import { useArtistCoverRef } from './useLibraryCoverRef'; +import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types'; + +export type ArtistCoverArtImageProps = Omit & { + artistId: string; + coverArt?: string | null; + serverScope?: CoverServerScope; + libraryResolve?: boolean; +}; + +export function ArtistCoverArtImage({ + artistId, + coverArt, + serverScope, + libraryResolve = false, + ...rest +}: ArtistCoverArtImageProps) { + const coverRef = useArtistCoverRef( + artistId, + coverArt, + serverScope ?? COVER_SCOPE_ACTIVE, + { libraryResolve }, + ); + if (!coverRef) return null; + return ; +} diff --git a/src/cover/CoverArtImage.tsx b/src/cover/CoverArtImage.tsx index 9fa35da3..fba93d37 100644 --- a/src/cover/CoverArtImage.tsx +++ b/src/cover/CoverArtImage.tsx @@ -1,20 +1,19 @@ import type { ImgHTMLAttributes } from 'react'; +import type React from 'react'; import { useEffect, useRef, useState } from 'react'; import { DEFAULT_CACHED_IMAGE_PREPARE_MARGIN } from '../components/CachedImage'; import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot'; import { coverEnsureBump } from './ensureQueue'; import { coverPrefetchBumpPriority } from './prefetchRegistry'; -import { coverArtRef } from './ref'; -import { coverStorageKey } from './storageKeys'; +import { coverStorageKeyFromRef } from './storageKeys'; import { resolveCoverDisplayTier } from './tiers'; import { coverImgSrc } from './imgSrc'; import { useCoverArt } from './useCoverArt'; -import type { CoverArtId, CoverPrefetchPriority, CoverServerScope, CoverSurfaceKind } from './types'; +import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types'; export type CoverArtImageProps = { - coverArtId: CoverArtId | null | undefined; + coverRef: CoverArtRef; displayCssPx: number; - serverScope?: CoverServerScope; surface?: CoverSurfaceKind; fullRes?: boolean; className?: string; @@ -22,14 +21,12 @@ export type CoverArtImageProps = { fetchQueueBias?: number; observeRootMargin?: string; observeScrollRootId?: string; - /** Initial ensure tier β€” use `high` for hero / above-the-fold cells. */ ensurePriority?: CoverPrefetchPriority; } & Omit, 'src'>; export function CoverArtImage({ - coverArtId, + coverRef, displayCssPx, - serverScope, surface, fullRes, className, @@ -41,34 +38,37 @@ export function CoverArtImage({ onError: restOnError, ...rest }: CoverArtImageProps) { - const scope = serverScope ?? { kind: 'active' }; const [ensurePriority, setEnsurePriority] = useState( ensurePriorityProp ?? 'middle', ); const imgRef = useRef(null); + const [imgLoadFailed, setImgLoadFailed] = useState(false); useEffect(() => { if (ensurePriorityProp) setEnsurePriority(ensurePriorityProp); }, [ensurePriorityProp]); + useEffect(() => { + setImgLoadFailed(false); + }, [coverRef.cacheEntityId, coverRef.cacheKind, coverRef.fetchCoverArtId, displayCssPx, surface, fullRes]); + useEffect(() => { const el = imgRef.current; - if (!el || !coverArtId) return; + if (!el) return; const root = (observeScrollRootId ? (document.getElementById(observeScrollRootId) as Element | null) : null) ?? resolveIntersectionScrollRoot(el); - const ref = coverArtRef(coverArtId, scope); const tier = resolveCoverDisplayTier(displayCssPx, { surface, fullRes }); - const storageKey = coverStorageKey(scope, coverArtId, tier); + const storageKey = coverStorageKeyFromRef(coverRef, tier); const observer = new IntersectionObserver( entries => { for (const entry of entries) { if (entry.isIntersecting) { setEnsurePriority('high'); - coverPrefetchBumpPriority(ref, 'high'); + coverPrefetchBumpPriority(coverRef, 'high'); coverEnsureBump(storageKey, 'high'); } } @@ -81,10 +81,9 @@ export function CoverArtImage({ ); observer.observe(el); return () => observer.disconnect(); - }, [coverArtId, scope, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]); + }, [coverRef, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]); - const { src, provisional, onImgError } = useCoverArt(coverArtId, displayCssPx, { - serverScope: scope, + const { src, provisional, onImgError } = useCoverArt(coverRef, displayCssPx, { surface, fullRes, ensurePriority, @@ -93,6 +92,21 @@ export function CoverArtImage({ const imgSrc = coverImgSrc(src); + if (!imgSrc || imgLoadFailed) { + return ( +
    } + className={className} + data-cover-provisional="true" + data-observe-root-margin={observeRootMargin} + data-observe-scroll-root={observeScrollRootId} + role="img" + aria-label={alt ?? ''} + {...(rest as React.HTMLAttributes)} + /> + ); + } + return ( { + setImgLoadFailed(true); onImgError?.(); restOnError?.(e); }} diff --git a/src/cover/TrackCoverArtImage.tsx b/src/cover/TrackCoverArtImage.tsx new file mode 100644 index 00000000..52e327de --- /dev/null +++ b/src/cover/TrackCoverArtImage.tsx @@ -0,0 +1,22 @@ +import type { SubsonicSong } from '../api/subsonicTypes'; +import { CoverArtImage, type CoverArtImageProps } from './CoverArtImage'; +import { useTrackCoverRef } from './useLibraryCoverRef'; +import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types'; + +export type TrackCoverArtImageProps = Omit & { + song: Pick; + serverScope?: CoverServerScope; + /** Default false for browse rails; true for queue/player rows needing per-disc art. */ + libraryResolve?: boolean; +}; + +export function TrackCoverArtImage({ + song, + serverScope, + libraryResolve = false, + ...rest +}: TrackCoverArtImageProps) { + const coverRef = useTrackCoverRef(song, serverScope ?? COVER_SCOPE_ACTIVE, { libraryResolve }); + if (!coverRef) return null; + return ; +} diff --git a/src/cover/artistHero.tsx b/src/cover/artistHero.tsx index a8d623da..65292bf7 100644 --- a/src/cover/artistHero.tsx +++ b/src/cover/artistHero.tsx @@ -71,11 +71,10 @@ export function ArtistHeroCover({ ); } - if (coverFallback?.coverArtId) { + if (coverFallback?.fetchCoverArtId) { return ( ({ + libraryCoverBackfillConfigure: vi.fn(async () => {}), + libraryCoverBackfillSetUiPriority: vi.fn(async () => {}), +})); + +import { libraryCoverBackfillSetUiPriority } from '../api/coverCache'; +import { + __test_resetCoverTraffic, + coverTrafficBackgroundPaused, + coverTrafficBeginNavigation, + coverTrafficEndNavigation, +} from './coverTraffic'; + +describe('coverTraffic navigation hold', () => { + beforeEach(() => { + __test_resetCoverTraffic(); + vi.mocked(libraryCoverBackfillSetUiPriority).mockClear(); + }); + + it('route effect cleanup ends navigation hold (does not leak begin)', () => { + coverTrafficBeginNavigation(); + coverTrafficEndNavigation(); + expect(coverTrafficBackgroundPaused()).toBe(false); + + coverTrafficBeginNavigation(); + coverTrafficEndNavigation(); + expect(coverTrafficBackgroundPaused()).toBe(false); + }); + + it('simulates useCoverNavigationPriority cleanup on pathname change', () => { + coverTrafficBeginNavigation(); + coverTrafficEndNavigation(); + expect(coverTrafficBackgroundPaused()).toBe(false); + + coverTrafficBeginNavigation(); + coverTrafficEndNavigation(); + coverTrafficEndNavigation(); + expect(coverTrafficBackgroundPaused()).toBe(false); + }); +}); diff --git a/src/cover/coverTraffic.ts b/src/cover/coverTraffic.ts index c970f7a8..9626eb2f 100644 --- a/src/cover/coverTraffic.ts +++ b/src/cover/coverTraffic.ts @@ -85,3 +85,13 @@ function scheduleNavigationResume(): void { syncBackfillUiPriority(); }, NAVIGATION_QUIET_MS); } + +/** Test-only β€” reset module hold state between cases. */ +export function __test_resetCoverTraffic(): void { + navigationHoldDepth = 0; + serverSwitchHold = false; + if (resumeTimer) clearTimeout(resumeTimer); + if (serverSwitchEndTimer) clearTimeout(serverSwitchEndTimer); + resumeTimer = null; + serverSwitchEndTimer = null; +} diff --git a/src/cover/diskSrcCache.test.ts b/src/cover/diskSrcCache.test.ts new file mode 100644 index 00000000..2d122c39 --- /dev/null +++ b/src/cover/diskSrcCache.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: vi.fn(() => true), + convertFileSrc: vi.fn(), +})); + +import { convertFileSrc } from '@tauri-apps/api/core'; +import { clearAllDiskSrcCache, coverDiskUrl, getDiskSrc, rememberDiskSrc } from './diskSrcCache'; + +describe('coverDiskUrl', () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockReset(); + }); + + it('rejects raw Windows path when convertFileSrc returns passthrough', () => { + const fsPath = + 'C:\\Users\\me\\AppData\\Roaming\\dev.psysonic.player\\cover-cache\\srv\\al-1\\128.webp'; + vi.mocked(convertFileSrc).mockReturnValue(fsPath); + expect(coverDiskUrl(fsPath)).toBe(''); + }); + + it('accepts asset.localhost URLs from convertFileSrc', () => { + const fsPath = 'C:\\cache\\cover-cache\\srv\\al-1\\128.webp'; + vi.mocked(convertFileSrc).mockReturnValue('https://asset.localhost/C%3A%2Fcache%2F128.webp'); + expect(coverDiskUrl(fsPath)).toBe('https://asset.localhost/C%3A%2Fcache%2F128.webp'); + }); + + it('normalizes Windows backslashes before convertFileSrc', () => { + const fsPath = 'C:\\Users\\me\\cover-cache\\al-1\\128.webp'; + vi.mocked(convertFileSrc).mockImplementation((p: string) => + `https://asset.localhost/${encodeURIComponent(p)}`, + ); + const url = coverDiskUrl(fsPath); + expect(convertFileSrc).toHaveBeenCalledWith('C:/Users/me/cover-cache/al-1/128.webp'); + expect(url).toContain('asset.localhost'); + }); + + it('accepts asset: protocol URLs from convertFileSrc', () => { + const fsPath = '/home/u/.local/share/dev.psysonic.player/cover-cache/srv/al-1/128.webp'; + vi.mocked(convertFileSrc).mockReturnValue('asset://localhost/home/u/.../128.webp'); + expect(coverDiskUrl(fsPath)).toBe('asset://localhost/home/u/.../128.webp'); + }); +}); + +describe('rememberDiskSrc', () => { + beforeEach(() => { + vi.mocked(convertFileSrc).mockReset(); + clearAllDiskSrcCache(); + }); + + it('does not cache when coverDiskUrl rejects the path', () => { + const fsPath = 'C:\\bad\\128.webp'; + vi.mocked(convertFileSrc).mockReturnValue(fsPath); + expect(rememberDiskSrc('srv:cover:al-1:128', fsPath)).toBe(''); + expect(getDiskSrc('srv:cover:al-1:128')).toBe(''); + }); +}); diff --git a/src/cover/diskSrcCache.ts b/src/cover/diskSrcCache.ts index 77985d3d..1795f96a 100644 --- a/src/cover/diskSrcCache.ts +++ b/src/cover/diskSrcCache.ts @@ -1,4 +1,6 @@ import { convertFileSrc, isTauri } from '@tauri-apps/api/core'; +import { coverIndexKeyFromScope } from './storageKeys'; +import type { CoverServerScope } from './types'; /** Stable asset URLs for disk `.webp` tiers β€” survives route unmount. */ const diskSrcByStorageKey = new Map(); @@ -27,23 +29,60 @@ export function getDiskSrcCacheGeneration(): number { return cacheGeneration; } +function isAssetProtocolUrl(url: string): boolean { + return url.startsWith('asset:') || /^https?:\/\/asset\.localhost/i.test(url); +} + +/** Windows: forward slashes before `convertFileSrc` (tauri#7970). */ +function normalizePathForConvert(fsPath: string): string { + if (/^[a-zA-Z]:[\\/]/.test(fsPath)) { + return fsPath.replace(/\\/g, '/'); + } + return fsPath; +} + /** True when `convertFileSrc` failed and returned the filesystem path unchanged. */ function isRawFsPath(url: string, fsPath: string): boolean { - return url === fsPath || (url.startsWith('/') && fsPath.startsWith('/')); + if (url === fsPath) return true; + if (url.startsWith('/') && fsPath.startsWith('/')) return true; + if (/^[a-zA-Z]:[\\/]/.test(fsPath)) { + const norm = fsPath.replace(/\\/g, '/'); + const urlNorm = url.replace(/\\/g, '/'); + // `endsWith(norm)`: convertFileSrc passthrough; `norm.endsWith(urlNorm)`: partial URL match. + if (urlNorm === norm || urlNorm.endsWith(norm) || norm.endsWith(urlNorm)) { + return !isAssetProtocolUrl(url); + } + } + return false; } /** * Turn a Rust disk path into a webview-loadable URL. * Returns empty when not in Tauri or path is outside asset scope (never put raw paths in ``). */ +function tryCoverDiskUrl(fsPath: string): string { + const paths = fsPath.includes('\\') + ? [normalizePathForConvert(fsPath), fsPath] + : [fsPath, normalizePathForConvert(fsPath)]; + const seen = new Set(); + for (const p of paths) { + if (!p || seen.has(p)) continue; + seen.add(p); + const src = convertFileSrc(p); + if (!src || isRawFsPath(src, p) || isRawFsPath(src, fsPath)) continue; + return src; + } + return ''; +} + export function coverDiskUrl(fsPath: string): string { if (!fsPath || !isTauri()) return ''; - const src = convertFileSrc(fsPath); - if (isRawFsPath(src, fsPath)) { - if (import.meta.env.DEV) { - console.warn('[cover] convertFileSrc out of asset scope β€” check tauri.conf assetProtocol', fsPath); - } - return ''; + const src = tryCoverDiskUrl(fsPath); + if (!src && import.meta.env.DEV) { + console.warn('[cover] convertFileSrc out of asset scope β€” check tauri.conf assetProtocol', { + fsPath, + src: convertFileSrc(normalizePathForConvert(fsPath)), + }); } return src; } @@ -67,8 +106,13 @@ export function forgetDiskSrc(storageKey: string): void { if (diskSrcByStorageKey.delete(storageKey)) bumpDiskSrcCache(); } -export function forgetDiskSrcPrefix(serverIndexKey: string, coverArtId: string): void { - const prefix = `${serverIndexKey}:cover:${coverArtId}:`; +export function forgetDiskSrcPrefix(ref: { + serverScope: CoverServerScope; + cacheKind: string; + cacheEntityId: string; +}): void { + const serverIndexKey = coverIndexKeyFromScope(ref.serverScope); + const prefix = `${serverIndexKey}:cover:${ref.cacheKind}:${ref.cacheEntityId}:`; let changed = false; for (const key of diskSrcByStorageKey.keys()) { if (key.startsWith(prefix)) { diff --git a/src/cover/diskSrcLookup.test.ts b/src/cover/diskSrcLookup.test.ts index 1b677a95..c41ced57 100644 --- a/src/cover/diskSrcLookup.test.ts +++ b/src/cover/diskSrcLookup.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { albumCoverRef } from './ref'; vi.mock('./diskSrcCache', () => ({ rememberDiskSrc: vi.fn(() => 'asset://cover.webp'), @@ -12,7 +13,7 @@ vi.mock('./diskHandoff', () => ({ import { rememberDiskSrc } from './diskSrcCache'; import { notifyCoverDiskReady } from './diskHandoff'; -import { gridDiskSrcLookupOrder, rememberGridDiskSrc } from './diskSrcLookup'; +import { gridDiskSrcLookupOrder, rememberDiskSrcLadder, rememberGridDiskSrc } from './diskSrcLookup'; describe('gridDiskSrcLookupOrder', () => { it('prefers 800 right after 512 when 512 is wanted', () => { @@ -32,9 +33,25 @@ describe('rememberGridDiskSrc', () => { }); it('seeds 512 and 800 keys from one on-disk path (800.webp fallback)', () => { - const hit = rememberGridDiskSrc({ kind: 'active' }, 'al-1', 512, '/data/800.webp'); + const ref = albumCoverRef('al-1', 'al-1'); + const hit = rememberGridDiskSrc(ref, 512, '/data/800.webp'); expect(hit).toBe(true); expect(vi.mocked(rememberDiskSrc).mock.calls.length).toBeGreaterThanOrEqual(2); expect(vi.mocked(notifyCoverDiskReady)).toHaveBeenCalledTimes(1); }); }); + +describe('rememberDiskSrcLadder', () => { + beforeEach(() => { + vi.mocked(rememberDiskSrc).mockClear(); + vi.mocked(rememberDiskSrc).mockReturnValue('asset://x'); + }); + + it('seeds 128 when only 800.webp path arrives', () => { + const hit = rememberDiskSrcLadder('srv', { cacheKind: 'album', cacheEntityId: 'al-1' }, 128, '/data/800.webp'); + expect(hit).toBe(true); + const keys = vi.mocked(rememberDiskSrc).mock.calls.map(c => c[0]); + expect(keys).toContain('srv:cover:album:al-1:128'); + expect(keys).toContain('srv:cover:album:al-1:800'); + }); +}); diff --git a/src/cover/diskSrcLookup.ts b/src/cover/diskSrcLookup.ts index 1dd73d0c..7c259f4f 100644 --- a/src/cover/diskSrcLookup.ts +++ b/src/cover/diskSrcLookup.ts @@ -1,7 +1,7 @@ import { getDiskSrc, rememberDiskSrc } from './diskSrcCache'; import { hasCoverDiskReadyListeners, notifyCoverDiskReady } from './diskHandoff'; -import { coverStorageKey } from './storageKeys'; -import type { CoverArtId, CoverArtTier, CoverServerScope } from './types'; +import { coverStorageKeyFromRef } from './storageKeys'; +import type { CoverArtRef, CoverArtTier } from './types'; /** Dense grids: prefer a larger on-disk tier (800) before tiny thumbs when the ideal tier is missing. */ export function gridDiskSrcLookupOrder(want: CoverArtTier): CoverArtTier[] { @@ -17,29 +17,20 @@ export function gridDiskSrcLookupOrder(want: CoverArtTier): CoverArtTier[] { } /** Synchronous hit from `diskSrcCache` β€” any tier already warmed/peeked for this cover. */ -export function getDiskSrcForGrid( - scope: CoverServerScope, - coverArtId: CoverArtId, - wantTier: CoverArtTier, -): string { +export function getDiskSrcForGrid(ref: CoverArtRef, wantTier: CoverArtTier): string { for (const tier of gridDiskSrcLookupOrder(wantTier)) { - const src = getDiskSrc(coverStorageKey(scope, coverArtId, tier)); + const src = getDiskSrc(coverStorageKeyFromRef(ref, tier)); if (src) return src; } return ''; } /** Seed lookup-order tier keys (512 + 800 fallback path, etc.) β€” no subscriber wakeups. */ -export function seedGridDiskSrcCache( - scope: CoverServerScope, - coverArtId: CoverArtId, - wantTier: CoverArtTier, - fsPath: string, -): boolean { +export function seedGridDiskSrcCache(ref: CoverArtRef, wantTier: CoverArtTier, fsPath: string): boolean { if (!fsPath) return false; let hit = false; for (const tier of gridDiskSrcLookupOrder(wantTier)) { - if (rememberDiskSrc(coverStorageKey(scope, coverArtId, tier), fsPath)) hit = true; + if (rememberDiskSrc(coverStorageKeyFromRef(ref, tier), fsPath)) hit = true; } return hit; } @@ -47,17 +38,28 @@ export function seedGridDiskSrcCache( /** * After peek/ensure: seed cache and wake mounted cells once (avoids 4Γ— notify / re-render storms). */ -export function rememberGridDiskSrc( - scope: CoverServerScope, - coverArtId: CoverArtId, - wantTier: CoverArtTier, - fsPath: string, -): boolean { - const hit = seedGridDiskSrcCache(scope, coverArtId, wantTier, fsPath); +export function rememberGridDiskSrc(ref: CoverArtRef, wantTier: CoverArtTier, fsPath: string): boolean { + const hit = seedGridDiskSrcCache(ref, wantTier, fsPath); if (!hit) return false; - const wantKey = coverStorageKey(scope, coverArtId, wantTier); + const wantKey = coverStorageKeyFromRef(ref, wantTier); if (hasCoverDiskReadyListeners(wantKey)) { notifyCoverDiskReady(wantKey, fsPath); } return true; } + +/** Rust `cover:tier-ready` β€” seed ladder keys so sparse cells see 800.webp when they want 128. */ +export function rememberDiskSrcLadder( + serverIndexKey: string, + ref: Pick, + wantTier: CoverArtTier, + fsPath: string, +): boolean { + if (!serverIndexKey || !ref.cacheEntityId || !fsPath) return false; + let hit = false; + for (const tier of gridDiskSrcLookupOrder(wantTier)) { + const key = `${serverIndexKey}:cover:${ref.cacheKind}:${ref.cacheEntityId}:${tier}`; + if (rememberDiskSrc(key, fsPath)) hit = true; + } + return hit; +} diff --git a/src/cover/ensureQueue.test.ts b/src/cover/ensureQueue.test.ts index a6a67a02..759d214f 100644 --- a/src/cover/ensureQueue.test.ts +++ b/src/cover/ensureQueue.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const { ensureImpl } = vi.hoisted(() => ({ ensureImpl: vi.fn( - async (_ref: { coverArtId: string }, _tier: number, _priority: string) => { + async (ref: { fetchCoverArtId: string }, _tier: number, _priority: string) => { await new Promise(r => setTimeout(r, 2)); - return { hit: true, path: `/tmp/${_ref.coverArtId}.webp`, tier: 128 }; + return { hit: true, path: `/tmp/${ref.fetchCoverArtId}.webp`, tier: 128 }; }, ), })); diff --git a/src/cover/ensureQueue.ts b/src/cover/ensureQueue.ts index 8060bbfd..e7e3f0c3 100644 --- a/src/cover/ensureQueue.ts +++ b/src/cover/ensureQueue.ts @@ -61,7 +61,7 @@ function trimQueue(): void { } function coverInflightKey(ref: CoverArtRef): string { - return `${coverIndexKeyFromRef(ref)}:${ref.coverArtId}`; + return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`; } /** Serialize ensures per cover ID so we do not re-download for every tier. */ @@ -164,12 +164,12 @@ export function __test_resetCoverEnsureQueue(): void { /** @internal Vitest-only β€” queued cover art IDs front-to-back. */ export function __test_queuedCoverIds(): string[] { - return queue.map(j => j.ref.coverArtId); + return queue.map(j => j.ref.cacheEntityId); } function ensureMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean { if (getDiskSrc(storageKey)) return true; - return Boolean(getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier)); + return Boolean(getDiskSrcForGrid(ref, tier)); } /** Rust disk ensure β€” parallel slots; one download chain per cover art ID. */ diff --git a/src/cover/fetchUrl.ts b/src/cover/fetchUrl.ts index dc549cbf..550a8ae9 100644 --- a/src/cover/fetchUrl.ts +++ b/src/cover/fetchUrl.ts @@ -8,13 +8,13 @@ import type { CoverArtRef, CoverArtTier } from './types'; /** Builds ephemeral getCoverArt URL β€” NOT a cache key */ export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): string { - const { coverArtId, serverScope } = ref; + const { fetchCoverArtId, serverScope } = ref; if (serverScope.kind === 'server') { return buildCoverArtUrlForServer( serverScope.url, serverScope.username, serverScope.password, - coverArtId, + fetchCoverArtId, tier, ); } @@ -28,11 +28,11 @@ export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): str server.url, server.username, server.password, - coverArtId, + fetchCoverArtId, tier, ); } } } - return buildCoverArtUrl(coverArtId, tier); + return buildCoverArtUrl(fetchCoverArtId, tier); } diff --git a/src/cover/index.ts b/src/cover/index.ts index adc4362f..c643c16c 100644 --- a/src/cover/index.ts +++ b/src/cover/index.ts @@ -7,9 +7,21 @@ export * from './ids'; export * from './storageKeys'; export * from './reachability'; export * from './layoutSizes'; +export * from './resolveEntry'; +export * from './resolveEntryLibrary'; export * from './ref'; export { useCoverArt } from './useCoverArt'; +export { + useAlbumCoverRef, + useArtistCoverRef, + usePlaybackTrackCoverRef, + useTrackCoverRef, +} from './useLibraryCoverRef'; export { CoverArtImage } from './CoverArtImage'; +export { AlbumCoverArtImage } from './AlbumCoverArtImage'; +export { ArtistCoverArtImage } from './ArtistCoverArtImage'; +export { TrackCoverArtImage } from './TrackCoverArtImage'; +export { useLibraryCoverPrefetch } from './useLibraryCoverPrefetch'; export { clearAllDiskSrcCache, forgetDiskSrc, @@ -18,6 +30,24 @@ export { rememberDiskSrc, } from './diskSrcCache'; export { usePlaybackCoverArt } from './usePlaybackCoverArt'; +export { + resolveAlbumCoverEntry, + resolveArtistCoverEntry, + resolveArtistPageSongFetchCoverArtId, + resolveTrackCoverEntry, + resolveSongFetchCoverArtId, + coverEntryToRef, +} from './resolveEntry'; +export { + resolveAlbumCoverRefsFromLibrary, + resolveArtistCoverRefsFromLibrary, + resolveTrackCoverRefsFromLibrary, +} from './resolveEntryLibrary'; +export { + resolveArtistPageSongCoverArtId, + resolvePlaybackTrackCoverArtId, + resolveSubsonicSongCoverArtId, +} from './resolveCoverArtId'; export { ensureCoverTierJs } from './resolveJs'; export { ensureCoverTierDiskSrc, ensureCoverTierDiskBlob } from './resolveDisk'; export { buildCoverArtFetchUrl } from './fetchUrl'; diff --git a/src/cover/layoutSizes.ts b/src/cover/layoutSizes.ts index 9e12214b..ffe2b9a0 100644 --- a/src/cover/layoutSizes.ts +++ b/src/cover/layoutSizes.ts @@ -1,6 +1,8 @@ import { computeCardGridColumnCount, computeCellWidthPx } from '../utils/cardGridLayout'; export const COVER_DENSE_SEARCH_CSS_PX = 40; +/** Artist detail top-track thumb (32Γ—32 CSS px). */ +export const COVER_ARTIST_TOP_TRACK_CSS_PX = 32; export const COVER_DENSE_ARTIST_LIST_CSS_PX = 64; export const COVER_DENSE_RAIL_CELL_CSS_PX = 180; export const COVER_DENSE_GRID_MIN_CELL_CSS_PX = 140; diff --git a/src/cover/lightbox.tsx b/src/cover/lightbox.tsx index 0791b71d..af9ab71d 100644 --- a/src/cover/lightbox.tsx +++ b/src/cover/lightbox.tsx @@ -30,7 +30,7 @@ export function useCoverLightboxSrc( return () => { cancelled = true; }; - }, [open, ref?.coverArtId, ref?.serverScope]); + }, [open, ref?.cacheEntityId, ref?.cacheKind, ref?.fetchCoverArtId, ref?.serverScope]); useEffect(() => { if (open) return; diff --git a/src/cover/peekQueue.ts b/src/cover/peekQueue.ts index 0468f673..fb2509e0 100644 --- a/src/cover/peekQueue.ts +++ b/src/cover/peekQueue.ts @@ -3,12 +3,12 @@ import { getDiskSrc } from './diskSrcCache'; import { getDiskSrcForGrid } from './diskSrcLookup'; import { coverTrafficServerSwitchPaused } from './coverTraffic'; import { rememberGridDiskSrc } from './diskSrcLookup'; -import { coverIndexKeyFromRef } from './storageKeys'; +import { coverStorageKeyFromRef } from './storageKeys'; import type { CoverArtRef, CoverArtTier } from './types'; function peekMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean { if (getDiskSrc(storageKey)) return true; - return Boolean(getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier)); + return Boolean(getDiskSrcForGrid(ref, tier)); } type PeekJob = { @@ -51,22 +51,24 @@ async function flush(): Promise { } if (needDisk.length === 0) return; - const hits = await coverCachePeekBatch( - needDisk.map(job => ({ - serverIndexKey: coverIndexKeyFromRef(job.ref), - coverArtId: job.ref.coverArtId, - tier: job.tier, - })), - ); - + const byTier = new Map(); for (const job of needDisk) { - const path = hits[job.storageKey]; - const hit = Boolean( - path - && rememberGridDiskSrc(job.ref.serverScope, job.ref.coverArtId, job.tier, path), + const list = byTier.get(job.tier) ?? []; + list.push(job); + byTier.set(job.tier, list); + } + + for (const [tier, jobs] of byTier) { + const hits = await coverCachePeekBatch( + jobs.map(j => j.ref), + tier, ); - job.resolve(hit); - inflight.delete(job.storageKey); + for (const job of jobs) { + const path = hits[job.storageKey] ?? ''; + const hit = Boolean(path && rememberGridDiskSrc(job.ref, job.tier, path)); + job.resolve(hit); + inflight.delete(job.storageKey); + } } } diff --git a/src/cover/prefetchRegistry.ts b/src/cover/prefetchRegistry.ts index 4dee3caa..277a06aa 100644 --- a/src/cover/prefetchRegistry.ts +++ b/src/cover/prefetchRegistry.ts @@ -7,7 +7,7 @@ const MAX_REGISTRY = 120; const registry = new Map(); function registryKey(ref: CoverArtRef): string { - return `${coverIndexKeyFromRef(ref)}:${ref.coverArtId}`; + return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`; } export function coverPrefetchRegister( @@ -23,7 +23,7 @@ export function coverPrefetchRegister( const keys: string[] = []; for (const ref of refs) { - if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) continue; + if (!ref.cacheEntityId || !coverServerReachable(ref.serverScope)) continue; const key = registryKey(ref); if (registry.size >= MAX_REGISTRY && !registry.has(key)) { const drop = [...registry.entries()].find(([, v]) => v.priority === 'low'); @@ -62,7 +62,7 @@ export function coverPrefetchBumpPriority( ref: CoverArtRef, priority: CoverPrefetchPriority, ): void { - if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) return; + if (!ref.cacheEntityId || !coverServerReachable(ref.serverScope)) return; const key = registryKey(ref); const existing = registry.get(key); if (!existing) { diff --git a/src/cover/ref.test.ts b/src/cover/ref.test.ts new file mode 100644 index 00000000..ce75d486 --- /dev/null +++ b/src/cover/ref.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { + albumCoverRef, + albumCoverRefForPlayback, + albumCoverRefForSong, + albumHasDistinctDiscCovers, + rememberAlbumDistinctDiscCovers, + resolveAlbumCoverCacheEntityId, + resolveDistinctDiscCoversForAlbum, +} from './ref'; + +describe('resolveAlbumCoverCacheEntityId', () => { + it('uses album id when fetch matches or is empty', () => { + expect(resolveAlbumCoverCacheEntityId('al-1', 'al-1')).toBe('al-1'); + expect(resolveAlbumCoverCacheEntityId('al-1', null)).toBe('al-1'); + expect(resolveAlbumCoverCacheEntityId('al-1', '')).toBe('al-1'); + }); + + it('ignores mf-* fetch unless distinctDiscCovers', () => { + expect(resolveAlbumCoverCacheEntityId('al-box', 'mf-disc2_abc')).toBe('al-box'); + expect(resolveAlbumCoverCacheEntityId('al-box', 'mf-disc2_abc', true)).toBe('mf-disc2_abc'); + }); +}); + +describe('albumHasDistinctDiscCovers', () => { + it('false for single disc', () => { + expect( + albumHasDistinctDiscCovers([ + { id: 't1', albumId: 'al-1', coverArt: 'mf-a', discNumber: 1 }, + ]), + ).toBe(false); + }); + + it('false when two discs share the same art id', () => { + expect( + albumHasDistinctDiscCovers([ + { id: 't1', albumId: 'al-1', coverArt: 'mf-same', discNumber: 1 }, + { id: 't2', albumId: 'al-1', coverArt: 'mf-same', discNumber: 2 }, + ]), + ).toBe(false); + }); + + it('true when two discs have different art ids', () => { + expect( + albumHasDistinctDiscCovers([ + { id: 't1', albumId: 'al-1', coverArt: 'mf-a', discNumber: 1 }, + { id: 't2', albumId: 'al-1', coverArt: 'mf-b', discNumber: 2 }, + ]), + ).toBe(true); + }); +}); + +describe('albumCoverRef', () => { + it('keys by album id for mf fetch by default', () => { + const ref = albumCoverRef('al-box', 'mf-disc1_xyz'); + expect(ref.cacheEntityId).toBe('al-box'); + expect(ref.fetchCoverArtId).toBe('mf-disc1_xyz'); + }); + + it('keys by fetch id when distinctDiscCovers', () => { + const ref = albumCoverRef('al-box', 'mf-disc1_xyz', { distinctDiscCovers: true }); + expect(ref.cacheEntityId).toBe('mf-disc1_xyz'); + }); +}); + +describe('resolveDistinctDiscCoversForAlbum', () => { + it('detects mf-* fetch id before album page visit', () => { + expect(resolveDistinctDiscCoversForAlbum('al-box', 'mf-d2')).toBe(true); + expect(resolveDistinctDiscCoversForAlbum('al-box', 'al-box')).toBe(false); + }); + + it('respects remembered false for same art on all discs', () => { + rememberAlbumDistinctDiscCovers('al-same', [ + { id: 't1', albumId: 'al-same', coverArt: 'mf-x', discNumber: 1 }, + { id: 't2', albumId: 'al-same', coverArt: 'mf-x', discNumber: 2 }, + ]); + expect(resolveDistinctDiscCoversForAlbum('al-same', 'mf-x')).toBe(false); + }); +}); + +describe('albumCoverRefForSong', () => { + it('keys per-disc without library resolve', () => { + const ref = albumCoverRefForSong({ + id: 't2', + albumId: 'al-box', + coverArt: 'mf-d2', + discNumber: 2, + }); + expect(ref?.cacheEntityId).toBe('mf-d2'); + }); +}); + +describe('albumCoverRefForPlayback', () => { + it('keys per-disc from mf coverArt before album page visit', () => { + const ref = albumCoverRefForPlayback( + { albumId: 'al-box', coverArt: 'mf-disc2', id: 't2', discNumber: 2 }, + { kind: 'active' }, + ); + expect(ref?.cacheEntityId).toBe('mf-disc2'); + expect(ref?.fetchCoverArtId).toBe('mf-disc2'); + }); + + it('uses remembered album flag', () => { + rememberAlbumDistinctDiscCovers('al-1', [ + { id: 't1', albumId: 'al-1', coverArt: 'mf-a', discNumber: 1 }, + { id: 't2', albumId: 'al-1', coverArt: 'mf-b', discNumber: 2 }, + ]); + const ref = albumCoverRefForPlayback( + { albumId: 'al-1', coverArt: 'mf-b', id: 't2', discNumber: 2 }, + { kind: 'active' }, + ); + expect(ref?.cacheEntityId).toBe('mf-b'); + }); +}); diff --git a/src/cover/ref.ts b/src/cover/ref.ts index db16283e..c1c060ec 100644 --- a/src/cover/ref.ts +++ b/src/cover/ref.ts @@ -1,19 +1,195 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { useAuthStore } from '../store/authStore'; -import type { CoverArtId, CoverArtRef, CoverServerScope } from './types'; +import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types'; +import { + albumHasDistinctDiscCovers, + coverEntryToRef, + resolveAlbumCoverEntry, + resolveArtistCoverEntry, + resolveTrackCoverEntry, +} from './resolveEntry'; +export type { CoverEntry } from './resolveEntry'; +export { albumHasDistinctDiscCovers } from './resolveEntry'; + +export type AlbumCoverRefOptions = { + serverScope?: CoverServerScope; + distinctDiscCovers?: boolean; +}; + +const albumDistinctDiscCoversByAlbumId = new Map(); + +export function rememberAlbumDistinctDiscCovers( + albumId: string, + songs: ReadonlyArray>, +): void { + const id = albumId.trim(); + if (!id) return; + albumDistinctDiscCoversByAlbumId.set(id, albumHasDistinctDiscCovers(songs)); +} + +export function forgetAlbumDistinctDiscCovers(albumId: string): void { + albumDistinctDiscCoversByAlbumId.delete(albumId.trim()); +} + +export type DistinctDiscCoversHint = Pick< + SubsonicSong, + 'discNumber' | 'coverArt' | 'id' | 'albumId' +>; + +/** + * Whether per-disc `mf-*` cache slots apply β€” from album tracklist memory or song hints + * when the album page has not been opened yet. + */ +export function resolveDistinctDiscCoversForAlbum( + albumId: string, + fetchCoverArtId?: string | null, + songHint?: DistinctDiscCoversHint, +): boolean { + const album = albumId.trim(); + if (!album) return false; + + const known = albumDistinctDiscCoversByAlbumId.get(album); + if (known === true) return true; + if (known === false) return false; + + if (songHint) { + const cover = songHint.coverArt?.trim(); + if ((songHint.discNumber ?? 1) > 1 && Boolean(cover && cover !== album)) return true; + } + + const fetch = fetchCoverArtId?.trim(); + if (fetch && fetch !== album && fetch.startsWith('mf-')) return true; + + return false; +} + +function resolveAlbumCoverRefOptions( + third?: CoverServerScope | AlbumCoverRefOptions, +): { serverScope: CoverServerScope; distinctDiscCovers: boolean } { + if (!third || 'kind' in third) { + return { serverScope: third ?? { kind: 'active' }, distinctDiscCovers: false }; + } + return { + serverScope: third.serverScope ?? { kind: 'active' }, + distinctDiscCovers: third.distinctDiscCovers ?? false, + }; +} + +/** @deprecated Use {@link resolveAlbumCoverEntry}. */ +export function resolveAlbumCoverCacheEntityId( + albumId: string, + fetchCoverArtId?: string | null, + distinctDiscCovers = false, +): string { + return resolveAlbumCoverEntry(albumId, fetchCoverArtId, distinctDiscCovers)?.cacheEntityId ?? ''; +} + +/** + * Sync fallback for cover identity β€” UI should prefer {@link useAlbumCoverRef} / + * {@link AlbumCoverArtImage}; async paths should use {@link resolveAlbumCoverRefFromLibrary}. + */ +export function albumCoverRef( + albumId: string, + fetchCoverArtId?: string | null, + scopeOrOpts: CoverServerScope | AlbumCoverRefOptions = { kind: 'active' }, +): CoverArtRef { + const { serverScope, distinctDiscCovers } = resolveAlbumCoverRefOptions(scopeOrOpts); + const entry = resolveAlbumCoverEntry(albumId, fetchCoverArtId, distinctDiscCovers); + if (!entry) { + const id = (fetchCoverArtId ?? albumId).trim(); + return coverEntryToRef( + { cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id }, + serverScope, + ); + } + return coverEntryToRef(entry, serverScope); +} + +export function albumCoverRefForSong( + song: Pick, + distinctDiscCovers?: boolean, + serverScope: CoverServerScope = { kind: 'active' }, +): CoverArtRef | undefined { + const albumId = song.albumId?.trim(); + const distinct = + distinctDiscCovers + ?? (albumId ? resolveDistinctDiscCoversForAlbum(albumId, song.coverArt, song) : false); + const entry = resolveTrackCoverEntry(song, distinct); + return entry ? coverEntryToRef(entry, serverScope) : undefined; +} + +export function albumCoverRefForPlayback( + track: Pick & { albumId?: string | null }, + serverScope: CoverServerScope = resolvePlaybackCoverScope(), +): CoverArtRef | undefined { + const albumId = track.albumId?.trim(); + if (!albumId) return undefined; + const distinctDiscCovers = resolveDistinctDiscCoversForAlbum( + albumId, + track.coverArt, + { ...track, albumId } as DistinctDiscCoversHint, + ); + return albumCoverRefForSong( + { ...track, albumId } as Pick, + distinctDiscCovers, + serverScope, + ); +} + +export function artistCoverRef( + artistId: string, + fetchCoverArtId?: string | null, + serverScope: CoverServerScope = { kind: 'active' }, +): CoverArtRef { + const entry = resolveArtistCoverEntry(artistId, fetchCoverArtId); + if (!entry) { + const id = (fetchCoverArtId ?? artistId).trim(); + return coverEntryToRef( + { cacheKind: 'artist', cacheEntityId: id, fetchCoverArtId: id }, + serverScope, + ); + } + return coverEntryToRef(entry, serverScope); +} + +export function coverRefFromEntity( + cacheKind: CoverCacheKind, + cacheEntityId: string, + fetchCoverArtId?: string | null, + serverScope: CoverServerScope = { kind: 'active' }, +): CoverArtRef { + const entry = + cacheKind === 'artist' + ? resolveArtistCoverEntry(cacheEntityId, fetchCoverArtId) + : resolveAlbumCoverEntry(cacheEntityId, fetchCoverArtId); + if (!entry) { + const id = (fetchCoverArtId ?? cacheEntityId).trim(); + return coverEntryToRef( + { cacheKind, cacheEntityId: id, fetchCoverArtId: id }, + serverScope, + ); + } + return coverEntryToRef(entry, serverScope); +} + +/** @deprecated Prefer entity helpers in {@link resolveEntry}. */ export function coverArtRef( coverArtId: CoverArtId, serverScope: CoverServerScope = { kind: 'active' }, ): CoverArtRef { - return { coverArtId, serverScope }; + const id = coverArtId.trim(); + if (id.startsWith('ar-')) return artistCoverRef(id, id, serverScope); + return albumCoverRef(id, id, serverScope); } export function resolvePlaybackCoverScope(): CoverServerScope { const playbackSid = getPlaybackServerId(); const activeSid = useAuthStore.getState().activeServerId; if (playbackSid && activeSid && playbackSid !== activeSid) { - const server = useAuthStore.getState().servers.find(s => s.id === playbackSid); + const server = findServerByIdOrIndexKey(playbackSid); if (server) { return { kind: 'server', diff --git a/src/cover/resolveCoverArtId.test.ts b/src/cover/resolveCoverArtId.test.ts new file mode 100644 index 00000000..aa3f6276 --- /dev/null +++ b/src/cover/resolveCoverArtId.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveArtistPageSongCoverArtId, + resolvePlaybackTrackCoverArtId, + resolveSubsonicSongCoverArtId, +} from './resolveCoverArtId'; + +describe('resolveSubsonicSongCoverArtId', () => { + it('prefers albumId when coverArt is the track id', () => { + expect( + resolveSubsonicSongCoverArtId({ + id: 'tr-1', + coverArt: 'tr-1', + albumId: 'al-42', + }), + ).toBe('al-42'); + }); + + it('keeps coverArt when it differs from song id and albumId is set', () => { + expect( + resolveSubsonicSongCoverArtId({ + id: 'tr-1', + coverArt: 'cov-track', + albumId: 'al-42', + }), + ).toBe('cov-track'); + }); + + it('keeps mf-* coverArt for per-disc art', () => { + expect( + resolveSubsonicSongCoverArtId({ + id: 'tr-1', + coverArt: 'mf-Gg7kLxzr2dNSB7BZ9eV2Xz_69d63a8a', + albumId: 'al-07lZYKfVt0F4MOgbhsmeyo_69d63b4d', + }), + ).toBe('mf-Gg7kLxzr2dNSB7BZ9eV2Xz_69d63a8a'); + }); +}); + +describe('resolvePlaybackTrackCoverArtId', () => { + it('returns undefined for null track', () => { + expect(resolvePlaybackTrackCoverArtId(null)).toBeUndefined(); + }); + + it('resolves albumId when coverArt echoes track id', () => { + expect( + resolvePlaybackTrackCoverArtId({ + id: 'tr-1', + coverArt: 'tr-1', + albumId: 'al-42', + }), + ).toBe('al-42'); + }); +}); + +describe('resolveArtistPageSongCoverArtId', () => { + it('prefers album coverArt over song coverArt', () => { + expect( + resolveArtistPageSongCoverArtId( + { id: 'tr-1', coverArt: 'tr-1', albumId: 'al-octa', album: 'Octastorium' }, + [{ id: 'al-octa', name: 'Octastorium', coverArt: 'cov-octa' }], + ), + ).toBe('cov-octa'); + }); + + it('ignores album coverArt when it echoes track id', () => { + expect( + resolveArtistPageSongCoverArtId( + { id: 'tr-1', coverArt: 'tr-1', albumId: 'al-octa', album: 'Octastorium' }, + [{ id: 'al-octa', name: 'Octastorium', coverArt: 'tr-1' }], + ), + ).toBe('al-octa'); + }); + + it('uses album row coverArt when present', () => { + expect( + resolveArtistPageSongCoverArtId( + { + id: 'tr-1', + coverArt: 'mf-x_1', + albumId: 'al-octa_2', + album: 'Octastorium', + }, + [{ id: 'al-octa_2', name: 'Octastorium', coverArt: 'mf-x_1' }], + ), + ).toBe('mf-x_1'); + }); + + it('uses per-disc coverArt when it differs from the album row', () => { + expect( + resolveArtistPageSongCoverArtId( + { + id: 'tr-2', + coverArt: 'mf-disc2', + albumId: 'al-box', + album: 'Box Set', + discNumber: 2, + }, + [{ id: 'al-box', name: 'Box Set', coverArt: 'mf-disc1' }], + ), + ).toBe('mf-disc2'); + }); +}); diff --git a/src/cover/resolveCoverArtId.ts b/src/cover/resolveCoverArtId.ts new file mode 100644 index 00000000..76aed7d1 --- /dev/null +++ b/src/cover/resolveCoverArtId.ts @@ -0,0 +1,21 @@ +/** @deprecated Import from `./resolveEntry` β€” kept for gradual migration. */ +export type { CoverArtResolvableSong } from './resolveEntry'; +export { + resolveArtistPageSongFetchCoverArtId as resolveArtistPageSongCoverArtId, + resolveSongFetchCoverArtId as resolveSubsonicSongCoverArtId, +} from './resolveEntry'; + +import type { CoverArtResolvableSong } from './resolveEntry'; +import { resolveSongFetchCoverArtId } from './resolveEntry'; + +/** @deprecated Use {@link resolveSongFetchCoverArtId}. */ +export function resolvePlaybackTrackCoverArtId( + track: CoverArtResolvableSong | null | undefined, +): string | undefined { + if (!track) return undefined; + return resolveSongFetchCoverArtId({ + id: track.id, + coverArt: track.coverArt, + albumId: track.albumId ?? '', + }); +} diff --git a/src/cover/resolveDisk.ts b/src/cover/resolveDisk.ts index af7b615b..224a033c 100644 --- a/src/cover/resolveDisk.ts +++ b/src/cover/resolveDisk.ts @@ -1,8 +1,9 @@ import { isTauri } from '@tauri-apps/api/core'; import { coverCacheEnsure } from '../api/coverCache'; import { invalidateCacheKey } from '../utils/imageCache'; +import { getDiskSrcForGrid } from './diskSrcLookup'; import { getDiskSrc, rememberDiskSrc } from './diskSrcCache'; -import { coverStorageKey } from './storageKeys'; +import { coverStorageKeyFromRef } from './storageKeys'; import type { CoverArtRef, CoverArtTier } from './types'; /** @@ -12,10 +13,10 @@ export async function ensureCoverTierDiskSrc( ref: CoverArtRef, tier: CoverArtTier, ): Promise { - if (!ref.coverArtId || !isTauri()) return ''; + if (!ref.fetchCoverArtId || !isTauri()) return ''; - const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier); - const cached = getDiskSrc(storageKey); + const storageKey = coverStorageKeyFromRef(ref, tier); + const cached = getDiskSrcForGrid(ref, tier) || getDiskSrc(storageKey); if (cached) return cached; const result = await coverCacheEnsure(ref, tier, 'high'); @@ -34,7 +35,7 @@ export async function ensureCoverTierDiskBlob( tier: CoverArtTier, signal?: AbortSignal, ): Promise { - const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier); + const storageKey = coverStorageKeyFromRef(ref, tier); const existing = getDiskSrc(storageKey); if (existing) { try { diff --git a/src/cover/resolveEntry.test.ts b/src/cover/resolveEntry.test.ts new file mode 100644 index 00000000..d96424cc --- /dev/null +++ b/src/cover/resolveEntry.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + albumHasDistinctDiscCovers, + resolveAlbumCoverEntry, + resolveArtistCoverEntry, + resolveSongFetchCoverArtId, + resolveTrackCoverEntry, +} from './resolveEntry'; + +describe('resolveAlbumCoverEntry', () => { + it('uses bare Navidrome album id on disk', () => { + const e = resolveAlbumCoverEntry('0DurV2S7arIOBQVEknOPWX', 'al-0Dur_abc'); + expect(e?.cacheEntityId).toBe('0DurV2S7arIOBQVEknOPWX'); + expect(e?.fetchCoverArtId).toBe('al-0Dur_abc'); + }); + + it('ignores mf fetch unless distinctDiscCovers', () => { + expect(resolveAlbumCoverEntry('al-box', 'mf-d2')?.cacheEntityId).toBe('al-box'); + expect(resolveAlbumCoverEntry('al-box', 'mf-d2', true)?.cacheEntityId).toBe('mf-d2'); + }); +}); + +describe('resolveArtistCoverEntry', () => { + it('keys by artist id', () => { + const e = resolveArtistCoverEntry('03b645ef2100dfc4', 'ar-03b645ef'); + expect(e?.cacheKind).toBe('artist'); + expect(e?.cacheEntityId).toBe('03b645ef2100dfc4'); + expect(e?.fetchCoverArtId).toBe('ar-03b645ef'); + }); +}); + +describe('resolveTrackCoverEntry', () => { + it('defaults to album bucket', () => { + const e = resolveTrackCoverEntry({ + id: 't1', + albumId: 'al-1', + coverArt: 'mf-a', + }); + expect(e?.cacheEntityId).toBe('al-1'); + expect(e?.fetchCoverArtId).toBe('mf-a'); + }); +}); + +describe('resolveSongFetchCoverArtId', () => { + it('falls back to albumId when coverArt echoes track id', () => { + expect( + resolveSongFetchCoverArtId({ id: 'tr-1', coverArt: 'tr-1', albumId: 'al-42' }), + ).toBe('al-42'); + }); +}); + +describe('albumHasDistinctDiscCovers', () => { + it('true when discs differ', () => { + expect( + albumHasDistinctDiscCovers([ + { id: 't1', albumId: 'al-1', coverArt: 'mf-a', discNumber: 1 }, + { id: 't2', albumId: 'al-1', coverArt: 'mf-b', discNumber: 2 }, + ]), + ).toBe(true); + }); +}); diff --git a/src/cover/resolveEntry.ts b/src/cover/resolveEntry.ts new file mode 100644 index 00000000..9e4ecaeb --- /dev/null +++ b/src/cover/resolveEntry.ts @@ -0,0 +1,128 @@ +/** + * Single source of truth for cover cache keys and HTTP fetch ids. + * + * Entities: **artist**, **album**, **track-on-album** (track art is always album-scoped + * unless the album has distinct per-CD covers). + * + * Disk path shape is Rust-only (`psysonic_core::cover_cache_layout`); this module must + * stay in sync with `resolve_album_cover` / `resolve_artist_cover` there. + */ + +import type { SubsonicAlbum, SubsonicSong } from '../api/subsonicTypes'; +import type { CoverArtRef, CoverCacheKind, CoverServerScope } from './types'; + +/** Resolved cover identity β€” maps 1:1 to Rust `CoverEntry`. */ +export type CoverEntry = { + cacheKind: CoverCacheKind; + cacheEntityId: string; + fetchCoverArtId: string; +}; + +export type CoverArtResolvableSong = Pick & { + albumId?: string | null; +}; + +/** Navidrome `getCoverArt` id for a song row (ignores echo of track id with no art). */ +export function resolveSongFetchCoverArtId(song: CoverArtResolvableSong): string | undefined { + const albumId = song.albumId?.trim(); + const cover = song.coverArt?.trim(); + const songId = song.id?.trim(); + if (cover && (!songId || cover !== songId)) return cover; + if (albumId) return albumId; + if (cover) return cover; + return undefined; +} + +/** True when 2+ discs use different cover art ids. */ +export function albumHasDistinctDiscCovers( + songs: ReadonlyArray>, +): boolean { + const artByDisc = new Map(); + for (const song of songs) { + const disc = song.discNumber ?? 1; + const artId = resolveSongFetchCoverArtId(song); + if (!artId) continue; + const prev = artByDisc.get(disc); + if (prev !== undefined && prev !== artId) return true; + artByDisc.set(disc, artId); + } + if (artByDisc.size <= 1) return false; + return new Set(artByDisc.values()).size > 1; +} + +/** Album entity β€” one cache slot per album unless `distinctDiscCovers`. */ +export function resolveAlbumCoverEntry( + albumId: string, + coverArtId?: string | null, + distinctDiscCovers = false, +): CoverEntry | undefined { + const album = albumId.trim(); + if (!album) return undefined; + const fetch = (coverArtId?.trim() || album); + const cacheEntityId = + distinctDiscCovers && fetch !== album ? fetch : album; + return { cacheKind: 'album', cacheEntityId, fetchCoverArtId: fetch }; +} + +/** Artist entity β€” one cache slot per artist. */ +export function resolveArtistCoverEntry( + artistId: string, + coverArtId?: string | null, +): CoverEntry | undefined { + const artist = artistId.trim(); + if (!artist) return undefined; + const fetch = coverArtId?.trim() || artist; + return { cacheKind: 'artist', cacheEntityId: artist, fetchCoverArtId: fetch }; +} + +/** Track on an album β€” album cache by default; per-disc fetch id when `distinctDiscCovers`. */ +export function resolveTrackCoverEntry( + song: Pick, + distinctDiscCovers = false, +): CoverEntry | undefined { + const albumId = song.albumId?.trim(); + if (!albumId) return undefined; + const fetch = resolveSongFetchCoverArtId(song) ?? albumId; + return resolveAlbumCoverEntry(albumId, fetch, distinctDiscCovers); +} + +export function coverEntryToRef( + entry: CoverEntry, + serverScope: CoverServerScope = { kind: 'active' }, +): CoverArtRef { + return { + cacheKind: entry.cacheKind, + cacheEntityId: entry.cacheEntityId, + fetchCoverArtId: entry.fetchCoverArtId, + serverScope, + }; +} + +/** Artist top tracks: prefer album row `coverArt` when the grid already has it. */ +/** @deprecated Alias for {@link resolveSongFetchCoverArtId}. */ +export const resolveSubsonicSongCoverArtId = resolveSongFetchCoverArtId; + +export function resolveArtistPageSongFetchCoverArtId( + song: Pick, + albums: ReadonlyArray>, +): string | undefined { + const songArt = resolveSongFetchCoverArtId(song); + const album = song.albumId + ? albums.find(a => a.id === song.albumId) + : albums.find(a => a.name === song.album); + const albumCover = album?.coverArt?.trim(); + const songId = song.id?.trim(); + + const songRowArt = song.coverArt?.trim(); + const perDiscArt = + Boolean(songArt && albumCover && songArt !== albumCover) + && Boolean( + (songRowArt && songRowArt !== songId) + || (songArt?.startsWith('mf-') ?? false), + ); + + if (perDiscArt && songArt) return songArt; + + if (albumCover && (!songId || albumCover !== songId)) return albumCover; + return songArt; +} diff --git a/src/cover/resolveEntryLibrary.ts b/src/cover/resolveEntryLibrary.ts new file mode 100644 index 00000000..bea9e7f9 --- /dev/null +++ b/src/cover/resolveEntryLibrary.ts @@ -0,0 +1,212 @@ +/** + * Cover resolution backed by the local library index β€” preferred over live API fields + * when the album/artist/track row exists in SQLite. + */ + +import { invoke } from '@tauri-apps/api/core'; +import { librarySqlServerId } from '../api/coverCache'; +import { useAuthStore } from '../store/authStore'; +import { COVER_SCOPE_ACTIVE, type CoverArtRef, CoverCacheKind, CoverServerScope } from './types'; +import { + coverEntryToRef, + resolveAlbumCoverEntry, + resolveArtistCoverEntry, + resolveTrackCoverEntry, + resolveSongFetchCoverArtId, + type CoverEntry, +} from './resolveEntry'; +import { resolveDistinctDiscCoversForAlbum } from './ref'; +import { coverIndexKeyFromScope } from './storageKeys'; + +export type LibraryCoverEntryDto = { + cacheKind: CoverCacheKind; + cacheEntityId: string; + fetchCoverArtId: string; +}; + +export type CoverLibraryEntity = 'album' | 'artist' | 'track'; + +function dtoToEntry(dto: LibraryCoverEntryDto): CoverEntry { + return { + cacheKind: dto.cacheKind, + cacheEntityId: dto.cacheEntityId, + fetchCoverArtId: dto.fetchCoverArtId, + }; +} + +export function libraryServerIdFromScope(scope: CoverServerScope): string { + if (scope.kind === 'server') { + return librarySqlServerId(scope.serverId); + } + const key = coverIndexKeyFromScope(scope); + if (key && key !== '_') return librarySqlServerId(key); + const active = useAuthStore.getState().activeServerId; + return active ? librarySqlServerId(active) : '_'; +} + +function libraryResolveCacheKey( + serverId: string, + entity: CoverLibraryEntity, + entityId: string, +): string { + return `${librarySqlServerId(serverId)}\u0000${entity}\u0000${entityId.trim()}`; +} + +const resolvedEntryCache = new Map(); +const inflightResolves = new Map>(); + +const LIBRARY_RESOLVE_MAX_INFLIGHT = 4; +let libraryResolveActive = 0; +const libraryResolveWaiters: Array<() => void> = []; + +function runLibraryResolveLimited(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + const start = () => { + libraryResolveActive += 1; + fn() + .then(resolve, reject) + .finally(() => { + libraryResolveActive -= 1; + const next = libraryResolveWaiters.shift(); + if (next) next(); + }); + }; + if (libraryResolveActive < LIBRARY_RESOLVE_MAX_INFLIGHT) start(); + else libraryResolveWaiters.push(start); + }); +} + +export async function libraryResolveCoverEntry( + serverId: string, + entity: CoverLibraryEntity, + entityId: string, +): Promise { + const id = entityId.trim(); + if (!id || !serverId.trim()) return null; + + const key = libraryResolveCacheKey(serverId, entity, id); + if (resolvedEntryCache.has(key)) return resolvedEntryCache.get(key) ?? null; + + const inflight = inflightResolves.get(key); + if (inflight) return inflight; + + const promise = runLibraryResolveLimited(async () => { + try { + const dto = await invoke('library_resolve_cover_entry', { + serverId: librarySqlServerId(serverId), + entity, + entityId: id, + }); + const entry = dto ? dtoToEntry(dto) : null; + resolvedEntryCache.set(key, entry); + return entry; + } catch { + resolvedEntryCache.set(key, null); + return null; + } finally { + inflightResolves.delete(key); + } + }); + + inflightResolves.set(key, promise); + return promise; +} + +export async function resolveAlbumCoverRefFromLibrary( + albumId: string, + fallbackCoverArt: string | null | undefined, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, +): Promise { + const entry = + (await libraryResolveCoverEntry(libraryServerIdFromScope(serverScope), 'album', albumId)) + ?? resolveAlbumCoverEntry(albumId, fallbackCoverArt); + return coverEntryToRef(entry!, serverScope); +} + +export async function resolveArtistCoverRefFromLibrary( + artistId: string, + fallbackCoverArt: string | null | undefined, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, +): Promise { + const entry = + (await libraryResolveCoverEntry(libraryServerIdFromScope(serverScope), 'artist', artistId)) + ?? resolveArtistCoverEntry(artistId, fallbackCoverArt); + return coverEntryToRef(entry!, serverScope); +} + +function pickTrackCoverEntry( + song: Parameters[0], + fromLibrary: CoverEntry | null, + distinctDiscCovers: boolean, +): CoverEntry | undefined { + const albumId = song.albumId?.trim(); + const fromClient = resolveTrackCoverEntry(song, distinctDiscCovers); + if (!fromLibrary) return fromClient; + if (!fromClient) return fromLibrary; + + const songArt = resolveSongFetchCoverArtId(song); + const libraryIsAlbumBucket = + Boolean(albumId) + && fromLibrary.cacheEntityId === albumId + && fromClient.cacheEntityId !== albumId; + + if ( + distinctDiscCovers + && libraryIsAlbumBucket + && songArt + && fromClient.fetchCoverArtId === songArt + ) { + return fromClient; + } + + if (fromClient.cacheEntityId !== fromLibrary.cacheEntityId && distinctDiscCovers) { + return fromClient; + } + + return fromLibrary; +} + +export async function resolveTrackCoverRefFromLibrary( + song: Parameters[0], + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, + distinctDiscCovers?: boolean, +): Promise { + const albumId = song.albumId?.trim(); + const distinct = + distinctDiscCovers + ?? (albumId ? resolveDistinctDiscCoversForAlbum(albumId, song.coverArt, song) : false); + const trackId = song.id?.trim(); + const fromLibrary = trackId + ? await libraryResolveCoverEntry(libraryServerIdFromScope(serverScope), 'track', trackId) + : null; + const entry = pickTrackCoverEntry(song, fromLibrary, distinct); + return entry ? coverEntryToRef(entry, serverScope) : undefined; +} + +export async function resolveAlbumCoverRefsFromLibrary( + albums: ReadonlyArray<{ id: string; coverArt?: string | null }>, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, +): Promise { + return Promise.all( + albums.map(a => resolveAlbumCoverRefFromLibrary(a.id, a.coverArt, serverScope)), + ); +} + +export async function resolveArtistCoverRefsFromLibrary( + artists: ReadonlyArray<{ id: string; coverArt?: string | null }>, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, +): Promise { + return Promise.all( + artists.map(a => resolveArtistCoverRefFromLibrary(a.id, a.coverArt, serverScope)), + ); +} + +export async function resolveTrackCoverRefsFromLibrary( + songs: ReadonlyArray[0]>, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, +): Promise { + const refs = await Promise.all( + songs.map(s => resolveTrackCoverRefFromLibrary(s, serverScope)), + ); + return refs.filter((r): r is CoverArtRef => !!r); +} diff --git a/src/cover/resolveJs.test.ts b/src/cover/resolveJs.test.ts index a9c56cd3..041633f6 100644 --- a/src/cover/resolveJs.test.ts +++ b/src/cover/resolveJs.test.ts @@ -8,9 +8,9 @@ vi.mock('../utils/imageCache', () => ({ vi.mock('../utils/imageCache/coverSiblings', () => ({ parseCoverCacheKey: (key: string) => { - const m = key.match(/^(.+):cover:(.+):(\d+)$/); + const m = key.match(/^(.+):cover:(.+):(.+):(\d+)$/); if (!m) return null; - return { stem: `${m[1]}:cover:${m[2]}`, size: Number(m[3]) }; + return { stem: `${m[1]}:cover:${m[2]}:${m[3]}`, size: Number(m[4]) }; }, probeSiblingCoverBlobInMemory: () => null, probeSiblingCoverBlobFromIDB: async () => null, @@ -38,7 +38,9 @@ vi.mock('./fetchUrl', () => ({ })); const ref: CoverArtRef = { - coverArtId: 'al-1', + cacheKind: 'album', + cacheEntityId: 'al-1', + fetchCoverArtId: 'al-1', serverScope: { kind: 'active' }, }; @@ -48,7 +50,7 @@ describe('ensureCoverTierJs', () => { }); it('returns null when cover id missing', async () => { - expect(await ensureCoverTierJs({ ...ref, coverArtId: '' }, 128)).toBeNull(); + expect(await ensureCoverTierJs({ ...ref, fetchCoverArtId: '' }, 128)).toBeNull(); }); it('fetches via getCachedBlob on cold path', async () => { diff --git a/src/cover/resolveJs.ts b/src/cover/resolveJs.ts index 25880e41..382cde9f 100644 --- a/src/cover/resolveJs.ts +++ b/src/cover/resolveJs.ts @@ -13,7 +13,7 @@ import { rememberBlob } from '../utils/imageCache/blobCache'; import { putBlob } from '../utils/imageCache/idbStore'; import { buildCoverArtFetchUrl } from './fetchUrl'; import { coverServerReachable } from './reachability'; -import { coverStorageKey } from './storageKeys'; +import { coverStorageKeyFromRef } from './storageKeys'; import type { CoverArtRef, CoverArtTier } from './types'; const CANONICAL_TIER = 800 as CoverArtTier; @@ -40,8 +40,8 @@ async function scheduleColdCoverRace( signal: AbortSignal | undefined, getPriority?: () => number, ): Promise { - const targetKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier); - const canonicalKey = coverStorageKey(ref.serverScope, ref.coverArtId, CANONICAL_TIER); + const targetKey = coverStorageKeyFromRef(ref, tier); + const canonicalKey = coverStorageKeyFromRef(ref, CANONICAL_TIER); const chainCtl = new AbortController(); const directCtl = new AbortController(); let winner = false; @@ -96,9 +96,9 @@ export async function ensureCoverTierJs( signal?: AbortSignal, getPriority?: () => number, ): Promise { - if (!ref.coverArtId || signal?.aborted) return null; + if (!ref.fetchCoverArtId || signal?.aborted) return null; - const cacheKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier); + const cacheKey = coverStorageKeyFromRef(ref, tier); const mem = blobCache.get(cacheKey); if (mem) return mem; @@ -128,7 +128,7 @@ export async function ensureCoverTierJs( return getCachedBlob(buildCoverArtFetchUrl(ref, 2000), cacheKey, signal, getPriority); } - const canonicalKey = coverStorageKey(ref.serverScope, ref.coverArtId, CANONICAL_TIER); + const canonicalKey = coverStorageKeyFromRef(ref, CANONICAL_TIER); const hasChain = blobCache.has(canonicalKey) || (parsed && (await probeSiblingCoverBlobFromIDB(parsed.stem, parsed.size)) !== null); diff --git a/src/cover/storageKeys.test.ts b/src/cover/storageKeys.test.ts index 23b24303..5c075818 100644 --- a/src/cover/storageKeys.test.ts +++ b/src/cover/storageKeys.test.ts @@ -36,9 +36,9 @@ describe('coverStorageKey', () => { }); it('uses host index key for active scope (not profile uuid)', () => { - expect(coverStorageKey({ kind: 'active' }, 'al-42', 128)).toBe( - 'music.local:4533:cover:al-42:128', - ); + expect( + coverStorageKey({ kind: 'active' }, { cacheKind: 'album', cacheEntityId: 'al-42' }, 128), + ).toBe('music.local:4533:cover:album:al-42:128'); }); it('uses host index key from explicit server url', () => { @@ -51,10 +51,10 @@ describe('coverStorageKey', () => { username: 'u', password: 'p', }, - 'ar-1', + { cacheKind: 'artist', cacheEntityId: 'ar-1' }, 512, ), - ).toBe('nav.example.com/navidrome:cover:ar-1:512'); + ).toBe('nav.example.com/navidrome:cover:artist:ar-1:512'); }); it('coverIndexKeyFromScope matches library-style keys', () => { diff --git a/src/cover/storageKeys.ts b/src/cover/storageKeys.ts index 938458d4..b3e1ee3a 100644 --- a/src/cover/storageKeys.ts +++ b/src/cover/storageKeys.ts @@ -1,10 +1,12 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer'; import { useAuthStore } from '../store/authStore'; +import { findServerByIdOrIndexKey } from '../utils/server/serverLookup'; import { + resolveIndexKey, serverIndexKeyForProfile, serverIndexKeyFromUrl, } from '../utils/server/serverIndexKey'; -import type { CoverArtId, CoverArtRef, CoverArtTier, CoverServerScope } from './types'; +import type { CoverArtRef, CoverArtTier, CoverServerScope } from './types'; /** * Stable server bucket for cover disk + IDB β€” same host index key as library SQLite (`server_id` column). @@ -18,10 +20,9 @@ export function coverIndexKeyFromScope(scope: CoverServerScope): string { const playbackSid = getPlaybackServerId(); const activeSid = useAuthStore.getState().activeServerId; const sid = playbackSid || activeSid; - const server = sid - ? useAuthStore.getState().servers.find(s => s.id === sid) - : undefined; + const server = sid ? findServerByIdOrIndexKey(sid) : undefined; if (server) return serverIndexKeyForProfile(server); + if (sid) return resolveIndexKey(sid) || sid; return '_'; } const server = useAuthStore.getState().getActiveServer(); @@ -38,8 +39,12 @@ export const serverIdFromScope = coverIndexKeyFromScope; export function coverStorageKey( serverScope: CoverServerScope, - coverArtId: CoverArtId, + ref: Pick, tier: CoverArtTier, ): string { - return `${coverIndexKeyFromScope(serverScope)}:cover:${coverArtId}:${tier}`; + return `${coverIndexKeyFromScope(serverScope)}:cover:${ref.cacheKind}:${ref.cacheEntityId}:${tier}`; +} + +export function coverStorageKeyFromRef(ref: CoverArtRef, tier: CoverArtTier): string { + return coverStorageKey(ref.serverScope, ref, tier); } diff --git a/src/cover/types.ts b/src/cover/types.ts index 7e1119bf..8eb0edf3 100644 --- a/src/cover/types.ts +++ b/src/cover/types.ts @@ -1,6 +1,8 @@ -/** Subsonic / Navidrome cover art ID passed to getCoverArt.view */ +/** Subsonic / Navidrome id passed to `getCoverArt.view` (`al-*`, `ar-*`, …). */ export type CoverArtId = string; +export type CoverCacheKind = 'album' | 'artist'; + /** Fixed storage / server-request tiers */ export const COVER_ART_TIERS = [64, 128, 256, 512, 800, 2000] as const; @@ -14,12 +16,26 @@ export type CoverServerScope = | { kind: 'playback' } | { kind: 'server'; serverId: string; url: string; username: string; password: string }; +/** Stable singleton β€” never inline `{ kind: 'active' }` in hook deps or default params. */ +export const COVER_SCOPE_ACTIVE: CoverServerScope = { kind: 'active' }; + +export function coverScopeKey(scope: CoverServerScope): string { + if (scope.kind === 'active') return 'active'; + if (scope.kind === 'playback') return 'playback'; + return `server:${scope.serverId}`; +} + export type CoverSurfaceKind = 'dense' | 'sparse'; export type CoverPrefetchPriority = 'high' | 'middle' | 'low'; +/** Disk cache is keyed by `cacheKind` + `cacheEntityId`; HTTP uses `fetchCoverArtId`. */ export type CoverArtRef = { - coverArtId: CoverArtId; + cacheKind: CoverCacheKind; + /** Disk segment β€” usually `al-*` / `ar-*`; per-CD `mf-*` only when album has distinct disc art. */ + cacheEntityId: string; + /** Navidrome `getCoverArt` id β€” usually matches `cacheEntityId`; may differ only transiently. */ + fetchCoverArtId: CoverArtId; serverScope: CoverServerScope; }; diff --git a/src/cover/useCoverArt.ts b/src/cover/useCoverArt.ts index 715e1f25..b4226625 100644 --- a/src/cover/useCoverArt.ts +++ b/src/cover/useCoverArt.ts @@ -3,22 +3,18 @@ import { coverEnsureQueued, coverEnsureRelease } from './ensureQueue'; import { coverPeekQueued } from './peekQueue'; import { getDiskSrcForGrid, seedGridDiskSrcCache } from './diskSrcLookup'; import { - forgetDiskSrc, - getDiskSrc, + forgetDiskSrcPrefix, getDiskSrcCacheGeneration, - rememberDiskSrc, subscribeDiskSrcCache, } from './diskSrcCache'; import { subscribeCoverDiskReady } from './diskHandoff'; -import { coverArtRef } from './ref'; import { coverServerReachable } from './reachability'; -import { coverStorageKey } from './storageKeys'; +import { coverStorageKeyFromRef } from './storageKeys'; import { resolveCoverDisplayTier } from './tiers'; import type { CoverArtHandle, - CoverArtId, + CoverArtRef, CoverPrefetchPriority, - CoverServerScope, CoverSurfaceKind, } from './types'; @@ -26,73 +22,58 @@ import type { * Disk cache in Rust (WebP tiers) β€” no webview `getCoverArt` fetch when server is reachable. */ export function useCoverArt( - coverArtId: CoverArtId | null | undefined, + coverRef: CoverArtRef | null | undefined, displayCssPx: number, opts?: { - serverScope?: CoverServerScope; surface?: CoverSurfaceKind; fullRes?: boolean; fetchQueueBias?: number; observeRootMargin?: string; alt?: string; - /** Download / ensure ordering β€” visible cells should pass `high`. */ ensurePriority?: CoverPrefetchPriority; }, ): CoverArtHandle { - const serverScope = opts?.serverScope ?? { kind: 'active' }; + const ref = coverRef ?? null; const surface = opts?.surface ?? 'sparse'; - const reachable = coverServerReachable(serverScope); + const reachable = ref ? coverServerReachable(ref.serverScope) : false; const tier = useMemo( () => - coverArtId + ref ? resolveCoverDisplayTier(displayCssPx, { surface, fullRes: opts?.fullRes, }) : 128, - [coverArtId, displayCssPx, surface, opts?.fullRes], - ); - - const ref = useMemo( - () => (coverArtId ? coverArtRef(coverArtId, serverScope) : null), - [coverArtId, serverScope], + [ref, displayCssPx, surface, opts?.fullRes], ); const storageKey = useMemo( - () => (ref ? coverStorageKey(ref.serverScope, ref.coverArtId, tier) : ''), + () => (ref ? coverStorageKeyFromRef(ref, tier) : ''), [ref, tier], ); const ensurePriority: CoverPrefetchPriority = opts?.ensurePriority ?? 'middle'; - /** Dense grids: peek on mount; HTTP ensure only when IO marks the cell `high`. */ const deferEnsureUntilVisible = surface === 'dense' && ensurePriority !== 'high'; const readCachedSrc = useCallback(() => { if (!ref) return ''; - if (surface === 'dense') { - return getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier); - } - return getDiskSrc(storageKey); - }, [ref, storageKey, surface, tier]); + return getDiskSrcForGrid(ref, tier); + }, [ref, tier]); useSyncExternalStore(subscribeDiskSrcCache, getDiskSrcCacheGeneration); const cachedSrc = readCachedSrc(); const applyDiskPath = useCallback((path: string) => { - if (!ref || !storageKey) return; + if (!ref) return; if (!path) { - forgetDiskSrc(storageKey); + forgetDiskSrcPrefix(ref); return; } - if (surface === 'dense') { - seedGridDiskSrcCache(ref.serverScope, ref.coverArtId, tier, path); - } else { - rememberDiskSrc(storageKey, path); - } - }, [ref, storageKey, tier, surface, readCachedSrc]); + seedGridDiskSrcCache(ref, tier, path); + }, [ref, tier]); useEffect(() => { if (!ref || !storageKey) return; @@ -139,8 +120,9 @@ export function useCoverArt( const provisional = Boolean(ref && storageKey && !src); const onImgError = useCallback(() => { - forgetDiskSrc(storageKey); - if (ref && reachable) { + if (!ref) return; + forgetDiskSrcPrefix(ref); + if (reachable) { void coverEnsureQueued(storageKey, ref, tier, 'high').then(result => { if (result.hit && result.path) applyDiskPath(result.path); }); diff --git a/src/cover/useCoverArtPrefetch.ts b/src/cover/useCoverArtPrefetch.ts index a13da839..38f57458 100644 --- a/src/cover/useCoverArtPrefetch.ts +++ b/src/cover/useCoverArtPrefetch.ts @@ -6,7 +6,7 @@ import { useAuthStore } from '../store/authStore'; import { coverPrefetchDrainBatch } from './prefetchRegistry'; import { coverTrafficBackgroundPaused } from './coverTraffic'; import { coverEnsureQueued } from './ensureQueue'; -import { coverStorageKey } from './storageKeys'; +import { coverStorageKeyFromRef } from './storageKeys'; import { resolveCoverDisplayTier } from './tiers'; import type { CoverArtTier } from './types'; @@ -43,7 +43,7 @@ export function useCoverArtPrefetch(enabled = true): void { if (batch.length > 0) { await Promise.all( batch.map(ref => { - const key = coverStorageKey(ref.serverScope, ref.coverArtId, DENSE_PREFETCH_TIER); + const key = coverStorageKeyFromRef(ref, DENSE_PREFETCH_TIER); return coverEnsureQueued(key, ref, DENSE_PREFETCH_TIER, 'low'); }), ); diff --git a/src/cover/useLibraryCoverPrefetch.ts b/src/cover/useLibraryCoverPrefetch.ts new file mode 100644 index 00000000..378612ca --- /dev/null +++ b/src/cover/useLibraryCoverPrefetch.ts @@ -0,0 +1,61 @@ +import { useEffect } from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { coverPrefetchRegister } from './prefetchRegistry'; +import { + resolveAlbumCoverRefsFromLibrary, + resolveArtistCoverRefsFromLibrary, + resolveTrackCoverRefsFromLibrary, +} from './resolveEntryLibrary'; +import { COVER_SCOPE_ACTIVE, type CoverPrefetchPriority, type CoverServerScope, type CoverSurfaceKind } from './types'; + +export type LibraryCoverPrefetchBucket = { + albums?: ReadonlyArray<{ id: string; coverArt?: string | null }>; + artists?: ReadonlyArray<{ id: string; coverArt?: string | null }>; + songs?: ReadonlyArray>; + limit?: number; + priority: CoverPrefetchPriority; + surface?: CoverSurfaceKind; + serverScope?: CoverServerScope; +}; + +/** Register cover prefetch after resolving refs from the local library index. */ +export function useLibraryCoverPrefetch( + buckets: LibraryCoverPrefetchBucket[], + deps: readonly unknown[], +): void { + useEffect(() => { + let cancelled = false; + const unregisters: Array<() => void> = []; + + void (async () => { + for (const bucket of buckets) { + const scope = bucket.serverScope ?? COVER_SCOPE_ACTIVE; + const refs = [ + ...(bucket.albums?.length + ? await resolveAlbumCoverRefsFromLibrary(bucket.albums, scope) + : []), + ...(bucket.artists?.length + ? await resolveArtistCoverRefsFromLibrary(bucket.artists, scope) + : []), + ...(bucket.songs?.length + ? await resolveTrackCoverRefsFromLibrary(bucket.songs, scope) + : []), + ]; + const capped = bucket.limit != null ? refs.slice(0, bucket.limit) : refs; + if (cancelled || capped.length === 0) continue; + unregisters.push( + coverPrefetchRegister(capped, { + surface: bucket.surface ?? 'dense', + priority: bucket.priority, + }), + ); + } + })(); + + return () => { + cancelled = true; + for (const unreg of unregisters) unreg(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- caller supplies `deps` for bucket inputs + }, deps); +} diff --git a/src/cover/useLibraryCoverRef.ts b/src/cover/useLibraryCoverRef.ts new file mode 100644 index 00000000..9aff1517 --- /dev/null +++ b/src/cover/useLibraryCoverRef.ts @@ -0,0 +1,270 @@ +import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; +import { + albumCoverRef, + albumCoverRefForPlayback, + albumCoverRefForSong, + artistCoverRef, + resolveDistinctDiscCoversForAlbum, + resolvePlaybackCoverScope, +} from './ref'; +import { + resolveAlbumCoverRefFromLibrary, + resolveArtistCoverRefFromLibrary, + resolveTrackCoverRefFromLibrary, +} from './resolveEntryLibrary'; +import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types'; + +function coverRefsEqual(a: CoverArtRef, b: CoverArtRef): boolean { + return ( + a.cacheKind === b.cacheKind + && a.cacheEntityId === b.cacheEntityId + && a.fetchCoverArtId === b.fetchCoverArtId + ); +} + +function applySyncRef( + setRef: Dispatch>, + syncRef: T, +): void { + setRef(prev => { + if (!syncRef) return syncRef; + if (prev && coverRefsEqual(prev, syncRef)) return prev; + return syncRef; + }); +} + +export type LibraryCoverRefOptions = { + /** + * When false, use API/index `coverArt` only β€” no per-mount `library_resolve_cover_entry`. + * Default for browse/search grids is false at the component layer; enable on album/artist + * detail headers and queue rows that need per-disc slots from SQLite. + */ + libraryResolve?: boolean; +}; + +/** Album grid / card β€” sync fallback, then local library index when indexed. */ +export function useAlbumCoverRef( + albumId: string | null | undefined, + fallbackCoverArt?: string | null, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, + options?: LibraryCoverRefOptions, +): CoverArtRef | null { + const libraryResolve = options?.libraryResolve !== false; + const scopeKey = coverScopeKey(serverScope); + const distinctDiscCovers = useMemo( + () => resolveDistinctDiscCoversForAlbum(albumId ?? '', fallbackCoverArt), + [albumId, fallbackCoverArt], + ); + const syncRef = useMemo(() => { + const id = albumId?.trim(); + if (!id) return null; + return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers }); + }, [albumId, fallbackCoverArt, scopeKey, serverScope, distinctDiscCovers]); + + const [ref, setRef] = useState(syncRef); + + useEffect(() => { + applySyncRef(setRef, syncRef); + if (!libraryResolve) return; + const id = albumId?.trim(); + if (!id) return; + let cancelled = false; + void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => { + if (!cancelled) { + setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next)); + } + }); + return () => { + cancelled = true; + }; + }, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]); + + return libraryResolve ? ref : syncRef; +} + +/** Artist grid β€” sync fallback, then library index. */ +export function useArtistCoverRef( + artistId: string | null | undefined, + fallbackCoverArt?: string | null, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, + options?: LibraryCoverRefOptions, +): CoverArtRef | null { + const libraryResolve = options?.libraryResolve !== false; + const scopeKey = coverScopeKey(serverScope); + const syncRef = useMemo(() => { + const id = artistId?.trim(); + if (!id) return null; + return artistCoverRef(id, fallbackCoverArt, serverScope); + }, [artistId, fallbackCoverArt, scopeKey, serverScope]); + + const [ref, setRef] = useState(syncRef); + + useEffect(() => { + applySyncRef(setRef, syncRef); + if (!libraryResolve) return; + const id = artistId?.trim(); + if (!id) return; + let cancelled = false; + void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => { + if (!cancelled) { + setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next)); + } + }); + return () => { + cancelled = true; + }; + }, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]); + + return libraryResolve ? ref : syncRef; +} + +/** Track row / song card β€” album-scoped; multi-CD from library when indexed. */ +export function useTrackCoverRef( + song: Pick | null | undefined, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, + options?: LibraryCoverRefOptions, +): CoverArtRef | undefined { + const libraryResolve = options?.libraryResolve !== false; + const scopeKey = coverScopeKey(serverScope); + const songId = song?.id; + const albumId = song?.albumId; + const coverArt = song?.coverArt; + const discNumber = song?.discNumber; + + const distinctDiscCovers = useMemo( + () => (albumId?.trim() + ? resolveDistinctDiscCoversForAlbum(albumId, coverArt, { + id: songId ?? '', + albumId, + coverArt, + discNumber, + }) + : false), + [albumId, coverArt, discNumber, songId], + ); + + const syncRef = useMemo(() => { + if (!songId?.trim() || !albumId?.trim()) return undefined; + return albumCoverRefForSong( + { id: songId, albumId, coverArt, discNumber }, + distinctDiscCovers, + ); + }, [songId, albumId, coverArt, discNumber, distinctDiscCovers]); + + const [ref, setRef] = useState(syncRef); + + useEffect(() => { + applySyncRef(setRef, syncRef); + if (!libraryResolve) return; + const trackId = songId?.trim(); + const al = albumId?.trim(); + if (!trackId || !al || !song) return; + let cancelled = false; + void resolveTrackCoverRefFromLibrary( + { ...song, id: trackId, albumId: al }, + serverScope, + distinctDiscCovers, + ).then(next => { + if (!cancelled) { + setRef(prev => { + if (!next) return undefined; + if ( + prev + && prev.cacheKind === 'album' + && next.cacheKind === 'album' + && al + && next.cacheEntityId === al + && prev.cacheEntityId !== al + && prev.fetchCoverArtId !== next.fetchCoverArtId + ) { + return prev; + } + if (prev && coverRefsEqual(prev, next)) return prev; + return next; + }); + } + }); + return () => { + cancelled = true; + }; + }, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers]); + + return libraryResolve ? ref : syncRef; +} + +/** Now playing / queue β€” playback server scope + library-backed multi-CD. */ +export function usePlaybackTrackCoverRef( + track: Parameters[0] | null | undefined, +): CoverArtRef | undefined { + const queueServerId = usePlayerStore(s => s.queueServerId); + const queueLength = usePlayerStore(s => s.queueItems.length); + const activeServerId = useAuthStore(s => s.activeServerId); + const serversFingerprint = useAuthStore(s => + s.servers + .map(srv => `${srv.id}\u0001${srv.url}\u0001${srv.username}\u0001${srv.password}`) + .join('\u0002'), + ); + + const scope = useMemo( + () => resolvePlaybackCoverScope(), + [queueServerId, queueLength, activeServerId, serversFingerprint], + ); + const scopeKey = coverScopeKey(scope); + + const trackId = track?.id; + const albumId = track?.albumId; + const coverArt = track?.coverArt; + const discNumber = track?.discNumber; + + const syncRef = useMemo(() => { + if (!albumId?.trim() || !track) return undefined; + return albumCoverRefForPlayback(track, scope); + }, [track, trackId, albumId, coverArt, discNumber, scopeKey]); + + const [ref, setRef] = useState(syncRef); + + useEffect(() => { + applySyncRef(setRef, syncRef); + const tid = trackId?.trim(); + const al = albumId?.trim(); + if (!tid || !al || !track) return; + let cancelled = false; + const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(al, track.coverArt, { + id: tid, + albumId: al, + coverArt: track.coverArt, + discNumber: track.discNumber, + }); + void resolveTrackCoverRefFromLibrary( + { ...track, id: tid, albumId: al } as Pick, + scope, + distinctDiscCovers, + ).then(next => { + if (!cancelled) { + setRef(prev => { + if (!next) return prev ?? next; + if ( + prev + && prev.cacheKind === 'album' + && next.cacheKind === 'album' + && next.cacheEntityId === al + && prev.cacheEntityId !== al + && prev.fetchCoverArtId !== next.fetchCoverArtId + ) { + return prev; + } + if (prev && coverRefsEqual(prev, next)) return prev; + return next; + }); + } + }); + return () => { + cancelled = true; + }; + }, [track, trackId, albumId, coverArt, discNumber, scopeKey, syncRef]); + + return ref; +} diff --git a/src/cover/usePlaybackCoverArt.test.ts b/src/cover/usePlaybackCoverArt.test.ts index 6688cf04..6453de53 100644 --- a/src/cover/usePlaybackCoverArt.test.ts +++ b/src/cover/usePlaybackCoverArt.test.ts @@ -1,6 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CoverArtHandle } from './types'; +import { albumCoverRef } from './ref'; import { usePlaybackCoverArt } from './usePlaybackCoverArt'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; @@ -10,7 +11,7 @@ import { toQueueItemRefs } from '../utils/library/queueItemRef'; const hoisted = vi.hoisted(() => ({ useCoverArtMock: vi.fn( - (_coverArtId?: unknown, _displayCssPx?: unknown, _opts?: unknown): CoverArtHandle => ({ + (_coverRef?: unknown, _displayCssPx?: unknown, _opts?: unknown): CoverArtHandle => ({ src: '', storageKey: '', cacheKey: '', @@ -24,7 +25,7 @@ vi.mock('./useCoverArt', () => ({ useCoverArt: hoisted.useCoverArtMock, })); -function seedPlaybackState(): { active: string; playback: string } { +function seedPlaybackState(): { active: string; playback: string; track: ReturnType } { const active = useAuthStore.getState().addServer({ name: 'Active', url: 'https://active.test', @@ -38,14 +39,14 @@ function seedPlaybackState(): { active: string; playback: string } { password: 'play-pass', }); useAuthStore.getState().setActiveServer(active); - const track = makeTrack({ id: 'song-1', coverArt: 'cover-1' }); + const track = makeTrack({ id: 'song-1', albumId: 'album-1', coverArt: 'cover-1' }); usePlayerStore.setState({ queueItems: toQueueItemRefs(playback, [track]), queueIndex: 0, queueServerId: playback, currentTrack: track, }); - return { active, playback }; + return { active, playback, track }; } describe('usePlaybackCoverArt', () => { @@ -55,13 +56,14 @@ describe('usePlaybackCoverArt', () => { }); it('recomputes server scope when playback server credentials change', async () => { - const { playback } = seedPlaybackState(); - const { rerender } = renderHook(() => usePlaybackCoverArt('cover-1', 300)); + const { playback, track } = seedPlaybackState(); + const coverRef = albumCoverRef(track.albumId!, track.coverArt!); + const { rerender } = renderHook(() => usePlaybackCoverArt(coverRef, 300)); const calls = hoisted.useCoverArtMock.mock.calls as Array< - [unknown, unknown, { serverScope?: Record }] + [{ serverScope?: Record } | null, unknown, unknown] >; - const firstScope = calls[0]?.[2]?.serverScope; + const firstScope = calls[0]?.[0]?.serverScope; expect(firstScope).toMatchObject({ kind: 'server', serverId: playback, @@ -79,7 +81,7 @@ describe('usePlaybackCoverArt', () => { rerender(); await waitFor(() => { - const latestScope = calls[calls.length - 1]?.[2]?.serverScope; + const latestScope = calls[calls.length - 1]?.[0]?.serverScope; expect(latestScope).toMatchObject({ kind: 'server', serverId: playback, diff --git a/src/cover/usePlaybackCoverArt.ts b/src/cover/usePlaybackCoverArt.ts index 70abe118..e20a5002 100644 --- a/src/cover/usePlaybackCoverArt.ts +++ b/src/cover/usePlaybackCoverArt.ts @@ -1,13 +1,13 @@ import { useMemo } from 'react'; import { resolvePlaybackCoverScope } from './ref'; -import type { CoverArtHandle, CoverArtId } from './types'; +import type { CoverArtHandle, CoverArtRef } from './types'; import { useCoverArt } from './useCoverArt'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; /** Cover art for playback queue β€” uses queue server when it differs from browsed server. */ export function usePlaybackCoverArt( - coverArtId: CoverArtId | undefined, + coverRef: CoverArtRef | undefined, displayCssPx: number, ): CoverArtHandle { const queueServerId = usePlayerStore(s => s.queueServerId); @@ -23,8 +23,11 @@ export function usePlaybackCoverArt( () => resolvePlaybackCoverScope(), [queueServerId, queueLength, activeServerId, serversFingerprint], ); - return useCoverArt(coverArtId, displayCssPx, { - serverScope: scope, + const refWithScope = useMemo( + () => (coverRef ? { ...coverRef, serverScope: scope } : null), + [coverRef, scope], + ); + return useCoverArt(refWithScope, displayCssPx, { surface: 'sparse', }); } diff --git a/src/cover/warmDiskPeek.ts b/src/cover/warmDiskPeek.ts index 812ead6f..8832f1c3 100644 --- a/src/cover/warmDiskPeek.ts +++ b/src/cover/warmDiskPeek.ts @@ -2,8 +2,9 @@ import { coverCachePeekBatch } from '../api/coverCache'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { coverEnsureQueued } from './ensureQueue'; import { getDiskSrcForGrid, rememberGridDiskSrc } from './diskSrcLookup'; -import { coverArtRef } from './ref'; -import { coverIndexKeyFromRef, coverStorageKey } from './storageKeys'; +import { albumCoverRef } from './ref'; +import { resolveAlbumCoverRefFromLibrary } from './resolveEntryLibrary'; +import { coverStorageKeyFromRef } from './storageKeys'; import { resolveCoverDisplayTier } from './tiers'; import type { CoverArtRef, CoverArtTier, CoverSurfaceKind } from './types'; @@ -13,30 +14,66 @@ export type CoverWarmItem = { storageKey: string; }; +/** @deprecated Sync fallback β€” prefer {@link coverWarmItemFromLibrary}. */ export function coverWarmItem( - coverArtId: string, + albumId: string, + fetchCoverArtId: string, displayCssPx: number, surface: CoverSurfaceKind = 'dense', ): CoverWarmItem { - const ref = coverArtRef(coverArtId); + const ref = albumCoverRef(albumId, fetchCoverArtId); const tier = resolveCoverDisplayTier(displayCssPx, { surface }); return { ref, tier, - storageKey: coverStorageKey(ref.serverScope, ref.coverArtId, tier), + storageKey: coverStorageKeyFromRef(ref, tier), + }; +} + +export async function coverWarmItemFromLibrary( + albumId: string, + fetchCoverArtId: string, + displayCssPx: number, + surface: CoverSurfaceKind = 'dense', +): Promise { + const ref = await resolveAlbumCoverRefFromLibrary(albumId, fetchCoverArtId); + const tier = resolveCoverDisplayTier(displayCssPx, { surface }); + return { + ref, + tier, + storageKey: coverStorageKeyFromRef(ref, tier), }; } export function collectAlbumCoverWarmItems( - albums: ReadonlyArray<{ coverArt?: string | null }>, + albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>, displayCssPx: number, surface: CoverSurfaceKind = 'dense', limit = 96, ): CoverWarmItem[] { const out: CoverWarmItem[] = []; for (const a of albums) { - if (!a.coverArt || out.length >= limit) break; - out.push(coverWarmItem(a.coverArt, displayCssPx, surface)); + if (out.length >= limit) break; + const entityId = a.id ?? a.coverArt; + if (!entityId) continue; + // Grid warm/peek uses API coverArt ids β€” avoids N sequential library_resolve IPC. + out.push(coverWarmItem(entityId, a.coverArt ?? entityId, displayCssPx, surface)); + } + return out; +} + +export async function collectSongCoverWarmItems( + songs: ReadonlyArray<{ albumId?: string; coverArt?: string | null }>, + displayCssPx: number, + surface: CoverSurfaceKind = 'dense', + limit = 96, +): Promise { + const out: CoverWarmItem[] = []; + for (const s of songs) { + if (!s.albumId || out.length >= limit) break; + out.push( + await coverWarmItemFromLibrary(s.albumId, s.coverArt ?? s.albumId, displayCssPx, surface), + ); } return out; } @@ -48,20 +85,14 @@ export async function warmCoverDiskSrcBatch(items: CoverWarmItem[]): Promise ({ - serverIndexKey: coverIndexKeyFromRef(item.ref), - coverArtId: item.ref.coverArtId, - tier: item.tier, - })), + items.map(item => item.ref), + items[0]!.tier, ); let warmed = 0; for (const item of items) { const path = hits[item.storageKey]; - if ( - path - && rememberGridDiskSrc(item.ref.serverScope, item.ref.coverArtId, item.tier, path) - ) { + if (path && rememberGridDiskSrc(item.ref, item.tier, path)) { warmed += 1; } } @@ -70,7 +101,7 @@ export async function warmCoverDiskSrcBatch(items: CoverWarmItem[]): Promise, + albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>, displayCssPx: number, opts?: { surface?: CoverSurfaceKind; limit?: number }, ): Promise { @@ -79,23 +110,26 @@ export async function ensureAlbumCoverMisses( const tier = resolveCoverDisplayTier(displayCssPx, { surface }); const slice = albums.slice(0, limit); - const needEnsure = slice.filter(album => { - if (!album.coverArt) return false; - return !getDiskSrcForGrid({ kind: 'active' }, album.coverArt, tier); - }); + const needEnsure: Array<{ entityId: string; coverArt: string; ref: CoverArtRef }> = []; + for (const album of slice) { + const entityId = album.id ?? album.coverArt; + if (!entityId || !album.coverArt) continue; + const ref = albumCoverRef(entityId, album.coverArt); + if (!getDiskSrcForGrid(ref, tier)) { + needEnsure.push({ entityId, coverArt: album.coverArt, ref }); + } + } if (needEnsure.length === 0) return; const PRIME_CHUNK = 8; for (let i = 0; i < needEnsure.length; i += PRIME_CHUNK) { const chunk = needEnsure.slice(i, i + PRIME_CHUNK); await Promise.all( - chunk.map(async album => { - const id = album.coverArt!; - const ref = coverArtRef(id); - const key = coverStorageKey(ref.serverScope, ref.coverArtId, tier); + chunk.map(async ({ ref }) => { + const key = coverStorageKeyFromRef(ref, tier); const result = await coverEnsureQueued(key, ref, tier, 'high'); if (result.hit && result.path) { - rememberGridDiskSrc(ref.serverScope, ref.coverArtId, tier, result.path); + rememberGridDiskSrc(ref, tier, result.path); } }), ); @@ -106,7 +140,7 @@ export async function ensureAlbumCoverMisses( * Peek + high-priority ensure so cards paint with `src` on first frame. */ export async function primeAlbumCoversForDisplay( - albums: ReadonlyArray<{ coverArt?: string | null }>, + albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>, displayCssPx: number, opts?: { surface?: CoverSurfaceKind; limit?: number; disabled?: boolean }, ): Promise { @@ -138,7 +172,7 @@ export async function warmHomeMainstageCovers(snapshot: { mostPlayed: SubsonicAlbum[]; recentlyPlayed: SubsonicAlbum[]; starred: SubsonicAlbum[]; - discoverSongs?: Array<{ coverArt?: string | null }>; + discoverSongs?: Array<{ albumId?: string; coverArt?: string | null }>; }): Promise { const items = dedupeWarmItems([ ...collectAlbumCoverWarmItems(snapshot.heroAlbums, 220, 'dense', 12), @@ -147,34 +181,34 @@ export async function warmHomeMainstageCovers(snapshot: { ...collectAlbumCoverWarmItems(snapshot.mostPlayed, 300, 'dense', 20), ...collectAlbumCoverWarmItems(snapshot.recentlyPlayed, 300, 'dense', 20), ...collectAlbumCoverWarmItems(snapshot.starred, 300, 'dense', 20), - ...collectAlbumCoverWarmItems(snapshot.discoverSongs ?? [], 200, 'dense', 20), + ...(await collectSongCoverWarmItems(snapshot.discoverSongs ?? [], 200, 'dense', 20)), ]); await warmCoverDiskSrcBatch(items); - // Prepare above-the-fold mainstage covers ahead of return navigation: - // if a refreshed snapshot introduces new albums not yet on disk, ensure them - // now in background so Hero / first rows don't wait on per-cell ensure. - // `discoverSongs` shares the same dense surface as the album rails β€” the - // pre-merge code only walked album collections here and the song row was - // left to lazy per-card ensure, which produced visible placeholder cards - // on cold caches. const discoverSongsForEnsure = snapshot.discoverSongs ?? []; await Promise.allSettled([ ensureAlbumCoverMisses(snapshot.heroAlbums, 220, { surface: 'dense', limit: 8 }), ensureAlbumCoverMisses(snapshot.recent, 300, { surface: 'dense', limit: 14 }), ensureAlbumCoverMisses(snapshot.random, 300, { surface: 'dense', limit: 10 }), - ensureAlbumCoverMisses(discoverSongsForEnsure, 200, { surface: 'dense', limit: 12 }), + ensureAlbumCoverMisses( + discoverSongsForEnsure.filter(s => s.albumId).map(s => ({ id: s.albumId!, coverArt: s.coverArt })), + 200, + { surface: 'dense', limit: 12 }, + ), ]); - // Fire-and-forget decode warmup to reduce first-paint "from cache" delay. void predecodeWarmAlbums(snapshot.heroAlbums, 220, 8); void predecodeWarmAlbums(snapshot.recent, 300, 10); void predecodeWarmAlbums(snapshot.random, 300, 8); - void predecodeWarmAlbums(discoverSongsForEnsure, 200, 8); + void predecodeWarmAlbums( + discoverSongsForEnsure.filter(s => s.albumId).map(s => ({ id: s.albumId!, coverArt: s.coverArt })), + 200, + 8, + ); } async function predecodeWarmAlbums( - albums: ReadonlyArray<{ coverArt?: string | null }>, + albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>, displayCssPx: number, limit: number, ): Promise { @@ -183,7 +217,10 @@ async function predecodeWarmAlbums( const urls: string[] = []; for (const album of albums) { if (!album.coverArt || urls.length >= limit) continue; - const src = getDiskSrcForGrid({ kind: 'active' }, album.coverArt, tier); + const entityId = album.id ?? album.coverArt; + if (!entityId) continue; + const ref = albumCoverRef(entityId, album.coverArt); + const src = getDiskSrcForGrid(ref, tier); if (!src) continue; urls.push(src); } diff --git a/src/hooks/tauriBridge/useCoverArtBridge.ts b/src/hooks/tauriBridge/useCoverArtBridge.ts index eb1cfda5..8bd47980 100644 --- a/src/hooks/tauriBridge/useCoverArtBridge.ts +++ b/src/hooks/tauriBridge/useCoverArtBridge.ts @@ -3,23 +3,25 @@ import { listen } from '@tauri-apps/api/event'; import { clearAllDiskSrcCache, forgetDiskSrcPrefix, - rememberDiskSrc, } from '../../cover/diskSrcCache'; +import { rememberDiskSrcLadder } from '../../cover/diskSrcLookup'; import { notifyCoverDiskReady } from '../../cover/diskHandoff'; import { invalidateCacheKey } from '../../utils/imageCache'; import { COVER_ART_TIERS } from '../../cover/tiers'; -import type { CoverArtTier } from '../../cover/types'; +import type { CoverArtTier, CoverCacheKind } from '../../cover/types'; type CoverTierReadyPayload = { serverIndexKey: string; - coverArtId: string; + cacheKind: CoverCacheKind; + cacheEntityId: string; tier: CoverArtTier; path: string; }; type CoverEvictedPayload = { serverIndexKey: string; - coverArtId: string; + cacheKind: CoverCacheKind; + cacheEntityId: string; }; /** Rust β†’ UI: disk `.webp` ready β€” do not invalidate IDB (that caused webview refetch storms). */ @@ -29,10 +31,10 @@ export function useCoverArtBridge(): void { void (async () => { unsubs.push( await listen('cover:tier-ready', ev => { - const { serverIndexKey, coverArtId, tier, path } = ev.payload; + const { serverIndexKey, cacheKind, cacheEntityId, tier, path } = ev.payload; if (!path) return; - const key = `${serverIndexKey}:cover:${coverArtId}:${tier}`; - rememberDiskSrc(key, path); + const key = `${serverIndexKey}:cover:${cacheKind}:${cacheEntityId}:${tier}`; + rememberDiskSrcLadder(serverIndexKey, { cacheKind, cacheEntityId }, tier, path); notifyCoverDiskReady(key, path); void invalidateCacheKey(key); }), @@ -44,10 +46,14 @@ export function useCoverArtBridge(): void { ); unsubs.push( await listen('cover:evicted', ev => { - const { serverIndexKey, coverArtId } = ev.payload; - forgetDiskSrcPrefix(serverIndexKey, coverArtId); + const { serverIndexKey, cacheKind, cacheEntityId } = ev.payload; + forgetDiskSrcPrefix({ + serverScope: { kind: 'active' }, + cacheKind, + cacheEntityId, + }); for (const tier of COVER_ART_TIERS) { - notifyCoverDiskReady(`${serverIndexKey}:cover:${coverArtId}:${tier}`, ''); + notifyCoverDiskReady(`${serverIndexKey}:cover:${cacheKind}:${cacheEntityId}:${tier}`, ''); } }), ); diff --git a/src/hooks/useCoverNavigationPriority.ts b/src/hooks/useCoverNavigationPriority.ts index 3a7c53a8..92c1a0bb 100644 --- a/src/hooks/useCoverNavigationPriority.ts +++ b/src/hooks/useCoverNavigationPriority.ts @@ -16,7 +16,7 @@ export function useCoverNavigationPriority(): void { coverTrafficBeginNavigation(); coverTrafficEndNavigation(); return () => { - coverTrafficBeginNavigation(); + coverTrafficEndNavigation(); }; }, [pathname]); } diff --git a/src/hooks/useLibraryAnalysisBackfill.ts b/src/hooks/useLibraryAnalysisBackfill.ts index d7a879e6..07af7f96 100644 --- a/src/hooks/useLibraryAnalysisBackfill.ts +++ b/src/hooks/useLibraryAnalysisBackfill.ts @@ -20,8 +20,10 @@ import { libraryIsReady } from '../utils/library/libraryReady'; const TOP_UP_POLL_MS = 500; const STEADY_POLL_MS = 2000; const READY_POLL_MS = 5000; -const EXHAUSTED_PAUSE_MS = 60_000; +const EXHAUSTED_PAUSE_MS = 15_000; const COMPLETED_RECHECK_MS = 5 * 60_000; +/** Consecutive exhausted scans with zero enqueue before treating the library as done. */ +const EXHAUSTED_DONE_STREAK = 2; const EMPTY_PIPELINE_STATS = { pipelineWorkers: 1, @@ -56,6 +58,7 @@ export function useLibraryAnalysisBackfill(enabled = true): void { ); const cursorRef = useRef(null); const completedTotalRef = useRef(null); + const exhaustedIdleStreakRef = useRef(0); useEffect(() => { if (!enabled) return; @@ -69,19 +72,9 @@ export function useLibraryAnalysisBackfill(enabled = true): void { let cancelled = false; const serverId = activeServerId; - let initialized = false; void (async () => { while (!cancelled) { - if (!initialized) { - initialized = true; - const progress = await libraryAnalysisProgress(serverId).catch(() => null); - if (progress && progress.pendingTracks <= 0) { - completedTotalRef.current = progress.totalTracks; - cursorRef.current = null; - } - } - if (completedTotalRef.current !== null) { const totalTracks = await libraryCountLiveTracks(serverId).catch(() => null); if (!Number.isFinite(totalTracks)) { @@ -94,6 +87,7 @@ export function useLibraryAnalysisBackfill(enabled = true): void { } completedTotalRef.current = null; cursorRef.current = null; + exhaustedIdleStreakRef.current = 0; } if (!(await libraryIsReady(serverId))) { @@ -145,19 +139,31 @@ export function useLibraryAnalysisBackfill(enabled = true): void { if (cancelled) return; + if (batch.trackIds.length > 0) { + exhaustedIdleStreakRef.current = 0; + } + if (batch.exhausted) { cursorRef.current = null; const progress = await libraryAnalysisProgress(serverId).catch(() => null); - if (progress && progress.pendingTracks <= 0) { - completedTotalRef.current = progress.totalTracks; - await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS)); + const pending = progress?.pendingTracks ?? -1; + if (pending <= 0 && batch.trackIds.length === 0) { + exhaustedIdleStreakRef.current += 1; + if (exhaustedIdleStreakRef.current >= EXHAUSTED_DONE_STREAK && progress) { + completedTotalRef.current = progress.totalTracks; + await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS)); + continue; + } + } else { + exhaustedIdleStreakRef.current = 0; + } + if (pending > 0) { continue; } await new Promise(r => setTimeout(r, EXHAUSTED_PAUSE_MS)); } else if (batch.trackIds.length === 0) { await new Promise(r => setTimeout(r, TOP_UP_POLL_MS)); } - // else: loop immediately if still below watermark } })(); @@ -165,6 +171,7 @@ export function useLibraryAnalysisBackfill(enabled = true): void { cancelled = true; cursorRef.current = null; completedTotalRef.current = null; + exhaustedIdleStreakRef.current = 0; }; }, [strategy, activeServerId, advancedParallelism, enabled]); } diff --git a/src/hooks/useNowPlayingPrewarm.test.ts b/src/hooks/useNowPlayingPrewarm.test.ts index a78c1478..73e01172 100644 --- a/src/hooks/useNowPlayingPrewarm.test.ts +++ b/src/hooks/useNowPlayingPrewarm.test.ts @@ -1,7 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache'; -import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey'; +import { coverIndexKeyFromRef } from '../cover/storageKeys'; import { useNowPlayingPrewarm } from './useNowPlayingPrewarm'; import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers'; import { useAuthStore } from '../store/authStore'; @@ -10,10 +10,14 @@ import { makeTrack } from '../test/helpers/factories'; import { resetAllStores } from '../test/helpers/storeReset'; import { toQueueItemRefs } from '../utils/library/queueItemRef'; -vi.mock('../api/coverCache', () => ({ - coverCachePeekBatch: vi.fn(async () => ({})), - coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })), -})); +vi.mock('../api/coverCache', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + coverCachePeekBatch: vi.fn(async () => ({})), + coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })), + }; +}); vi.mock('./useNowPlayingFetchers', () => ({ prewarmNowPlayingFetchers: vi.fn(async () => undefined), @@ -65,10 +69,10 @@ describe('useNowPlayingPrewarm', () => { expect(coverCachePeekBatch).toHaveBeenCalledTimes(1); }); - const peekArg = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0]; + const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0]; const playbackProfile = useAuthStore.getState().servers.find(s => s.id === playback); expect(playbackProfile).toBeDefined(); - expect(peekArg?.serverIndexKey).toBe(serverIndexKeyForProfile(playbackProfile!)); + expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('playback.test'); const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0]; expect(ensureRef?.serverScope.kind).toBe('server'); }); @@ -95,10 +99,10 @@ describe('useNowPlayingPrewarm', () => { expect(coverCachePeekBatch).toHaveBeenCalledTimes(1); }); - const peekArg = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0]; + const peekRef = vi.mocked(coverCachePeekBatch).mock.calls[0]?.[0]?.[0]; const activeProfile = useAuthStore.getState().servers.find(s => s.id === active); expect(activeProfile).toBeDefined(); - expect(peekArg?.serverIndexKey).toBe(serverIndexKeyForProfile(activeProfile!)); + expect(peekRef && coverIndexKeyFromRef(peekRef)).toBe('active.test'); const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0]; expect(ensureRef?.serverScope).toEqual({ kind: 'active' }); }); diff --git a/src/hooks/useNowPlayingPrewarm.ts b/src/hooks/useNowPlayingPrewarm.ts index 2e1df591..9b6203f4 100644 --- a/src/hooks/useNowPlayingPrewarm.ts +++ b/src/hooks/useNowPlayingPrewarm.ts @@ -1,11 +1,13 @@ import { useEffect } from 'react'; import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache'; -import { coverArtRef, resolvePlaybackCoverScope } from '../cover/ref'; +import { albumCoverRef } from '../cover/ref'; +import { resolvePlaybackCoverScope } from '../cover/ref'; +import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary'; import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache'; -import { coverIndexKeyFromRef, coverStorageKey } from '../cover/storageKeys'; +import { coverStorageKeyFromRef } from '../cover/storageKeys'; import { resolveCoverDisplayTier } from '../cover/tiers'; import { coverArtIdFromRadio } from '../cover/ids'; -import type { CoverServerScope } from '../cover/types'; +import type { CoverArtRef } from '../cover/types'; import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; @@ -13,23 +15,13 @@ import { usePlaybackServerId } from './usePlaybackServerId'; const NOW_PLAYING_COVER_CSS_PX = 800; -async function prewarmCoverForScope( - coverArtId: string, - scope: CoverServerScope, -): Promise { - if (!coverArtId) return; +async function prewarmCoverRef(ref: CoverArtRef): Promise { + if (!ref.fetchCoverArtId) return; const tier = resolveCoverDisplayTier(NOW_PLAYING_COVER_CSS_PX, { surface: 'sparse' }); - const ref = coverArtRef(coverArtId, scope); - const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier); + const storageKey = coverStorageKeyFromRef(ref, tier); if (getDiskSrc(storageKey)) return; - const hits = await coverCachePeekBatch([ - { - serverIndexKey: coverIndexKeyFromRef(ref), - coverArtId: ref.coverArtId, - tier, - }, - ]); + const hits = await coverCachePeekBatch([ref], tier); const hitPath = hits[storageKey]; if (hitPath) { rememberDiskSrc(storageKey, hitPath); @@ -73,8 +65,18 @@ export function useNowPlayingPrewarm(): void { fetchEnabled: true, }); - if (currentTrack.coverArt) { - void prewarmCoverForScope(currentTrack.coverArt, resolvePlaybackCoverScope()); + if (currentTrack.albumId && currentTrack.id) { + void resolveTrackCoverRefFromLibrary( + { + id: currentTrack.id, + albumId: currentTrack.albumId, + coverArt: currentTrack.coverArt, + discNumber: (currentTrack as { discNumber?: number }).discNumber, + }, + resolvePlaybackCoverScope(), + ).then(ref => { + if (ref) void prewarmCoverRef(ref); + }); } }, [ currentTrack?.id, @@ -91,6 +93,6 @@ export function useNowPlayingPrewarm(): void { useEffect(() => { if (!currentRadio?.coverArt || !activeServerId) return; const radioCoverArtId = coverArtIdFromRadio(currentRadio.id); - void prewarmCoverForScope(radioCoverArtId, { kind: 'active' }); + void prewarmCoverRef(albumCoverRef(radioCoverArtId, radioCoverArtId, { kind: 'active' })); }, [currentRadio?.id, currentRadio?.coverArt, activeServerId]); } diff --git a/src/hooks/usePlaylistCovers.ts b/src/hooks/usePlaylistCovers.ts index d0ebe86f..932fdfa6 100644 --- a/src/hooks/usePlaylistCovers.ts +++ b/src/hooks/usePlaylistCovers.ts @@ -1,8 +1,8 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { SubsonicSong } from '../api/subsonicTypes'; -import type { CoverArtId } from '../cover/types'; +import type { CoverArtId, CoverArtRef } from '../cover/types'; import { coverPrefetchRegister } from '../cover/prefetchRegistry'; -import { coverArtRef } from '../cover/ref'; +import { resolveAlbumCoverRefFromLibrary } from '../cover/resolveEntryLibrary'; import { useCoverArt } from '../cover/useCoverArt'; const PLAYLIST_HERO_BG_CSS_PX = 200; @@ -14,6 +14,17 @@ export interface PlaylistCovers { resolvedBgUrl: string; } +async function playlistCoverRefFromLibrary( + coverId: string, + songs: SubsonicSong[], +): Promise { + const song = songs.find(s => s.coverArt === coverId || s.albumId === coverId); + if (song?.albumId) { + return resolveAlbumCoverRefFromLibrary(song.albumId, coverId); + } + return resolveAlbumCoverRefFromLibrary(coverId, coverId); +} + export function usePlaylistCovers(songs: SubsonicSong[], customCoverId: string | null): PlaylistCovers { const coverQuad = useMemo(() => { const seen = new Set(); @@ -38,18 +49,46 @@ export function usePlaylistCovers(songs: SubsonicSong[], customCoverId: string | ); const bgCoverId = customCoverId ?? coverQuad[0] ?? null; - const { src: resolvedBgUrl } = useCoverArt(bgCoverId, PLAYLIST_HERO_BG_CSS_PX, { + const [bgCoverRef, setBgCoverRef] = useState(null); + + useEffect(() => { + if (!bgCoverId) { + setBgCoverRef(null); + return; + } + let cancelled = false; + void playlistCoverRefFromLibrary(bgCoverId, songs).then(ref => { + if (!cancelled) setBgCoverRef(ref); + }); + return () => { + cancelled = true; + }; + }, [bgCoverId, songs]); + + const { src: resolvedBgUrl } = useCoverArt(bgCoverRef, PLAYLIST_HERO_BG_CSS_PX, { surface: 'dense', ensurePriority: 'high', }); useEffect(() => { - const refs = coverQuadIds - .filter((id): id is CoverArtId => !!id) - .map(id => coverArtRef(id)); - if (bgCoverId) refs.push(coverArtRef(bgCoverId)); - return coverPrefetchRegister(refs, { surface: 'dense', priority: 'middle' }); - }, [coverQuadIds, bgCoverId]); + const ids = [ + ...coverQuadIds.filter((id): id is CoverArtId => !!id), + ...(bgCoverId ? [bgCoverId] : []), + ]; + if (ids.length === 0) return; + let cancelled = false; + let unreg: (() => void) | undefined; + void (async () => { + const refs = await Promise.all(ids.map(id => playlistCoverRefFromLibrary(id, songs))); + if (!cancelled) { + unreg = coverPrefetchRegister(refs, { surface: 'dense', priority: 'middle' }); + } + })(); + return () => { + cancelled = true; + unreg?.(); + }; + }, [coverQuadIds, bgCoverId, songs]); return { coverQuadIds, bgCoverId, resolvedBgUrl }; } diff --git a/src/hooks/useWarmGridCovers.ts b/src/hooks/useWarmGridCovers.ts index a81dd0dd..de835064 100644 --- a/src/hooks/useWarmGridCovers.ts +++ b/src/hooks/useWarmGridCovers.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { GRID_COVER_PRIME_ALL_MAX } from '../cover/layoutSizes'; import { collectAlbumCoverWarmItems, @@ -10,7 +10,7 @@ import type { CoverSurfaceKind } from '../cover/types'; const DEFAULT_LIMIT = 120; /** - * Peek before paint; for small grids (≀48) queue ensures only for disk misses. + * Peek after mount (non-blocking); for small grids (≀48) queue ensures only for disk misses. */ export function useWarmGridCovers( items: ReadonlyArray<{ coverArt?: string | null }>, @@ -37,13 +37,13 @@ export function useWarmGridCovers( const primeAllMisses = items.length > 0 && items.length <= GRID_COVER_PRIME_ALL_MAX; - useLayoutEffect(() => { + useEffect(() => { if (!enabled || displayCssPx <= 0) return; - const batch = collectAlbumCoverWarmItems(items, displayCssPx, surface, limit); - if (batch.length === 0) return; let cancelled = false; void (async () => { + const batch = collectAlbumCoverWarmItems(items, displayCssPx, surface, limit); + if (cancelled || batch.length === 0) return; await warmCoverDiskSrcBatch(batch); if (cancelled) return; if (primeAllMisses) { diff --git a/src/pages/AdvancedSearch.tsx b/src/pages/AdvancedSearch.tsx index f6d99ee8..b74350a4 100644 --- a/src/pages/AdvancedSearch.tsx +++ b/src/pages/AdvancedSearch.tsx @@ -650,6 +650,8 @@ export default function AdvancedSearch() { title={`${t('search.albums')} (${filteredResults.albums.length})`} albums={filteredResults.albums} albumLinkQuery={activeSearch?.losslessOnly ? LOSSLESS_MODE_QUERY : undefined} + windowArtworkByViewport + initialArtworkBudget={12} /> )} diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 03e74b86..194763cf 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -23,6 +23,11 @@ import AlbumHeader from '../components/AlbumHeader'; import AlbumTrackList from '../components/AlbumTrackList'; import { AlbumDetailToolbar } from '../components/albumDetail/AlbumDetailToolbar'; import { useCoverArt } from '../cover/useCoverArt'; +import { + forgetAlbumDistinctDiscCovers, + rememberAlbumDistinctDiscCovers, +} from '../cover/ref'; +import { useAlbumCoverRef } from '../cover/useLibraryCoverRef'; import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/ui/toast'; import { useSelectionStore } from '../store/selectionStore'; @@ -90,6 +95,12 @@ export default function AlbumDetail() { return album.songs.filter(s => isLosslessSuffix(s.suffix)); }, [album?.songs, losslessOnly]); + useEffect(() => { + if (!albumId || !effectiveSongs?.length) return; + rememberAlbumDistinctDiscCovers(albumId, effectiveSongs); + return () => forgetAlbumDistinctDiscCovers(albumId); + }, [albumId, effectiveSongs]); + const handlePlayAll = () => { if (!album || !effectiveSongs) return; const albumGenre = album.album.genre; @@ -270,7 +281,13 @@ const handleShuffleAll = () => { userRatingOverrides, }); - const albumCover = useCoverArt(album?.album.coverArt, 400, { surface: 'sparse' }); + const albumCoverRefResolved = useAlbumCoverRef( + album?.album.id, + album?.album.coverArt, + undefined, + { libraryResolve: true }, + ); + const albumCover = useCoverArt(albumCoverRefResolved, 400, { surface: 'sparse' }); const resolvedCoverUrl = albumCover.src || null; useEffect(() => { diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 5851efce..a1a02746 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -4,8 +4,7 @@ import { songToTrack } from '../utils/playback/songToTrack'; import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'; import AlbumCard from '../components/AlbumCard'; import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '../cover/layoutSizes'; -import { coverPrefetchRegister } from '../cover/prefetchRegistry'; -import { coverArtRef } from '../cover/ref'; +import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch'; import { useAuthStore } from '../store/authStore'; import { clampLibraryGridMaxColumns } from '../store/authStoreHelpers'; import { computeCardGridColumnCount } from '../utils/cardGridLayout'; @@ -211,13 +210,15 @@ export default function Albums() { return () => ro.disconnect(); }, [maxGridCols, visibleAlbums.length]); - useEffect(() => { - const viewportBudget = Math.max(albumGridCols * 3, albumGridCols); - const refs = visibleAlbums - .slice(0, viewportBudget) - .flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])); - return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' }); - }, [visibleAlbums, albumGridCols]); + useLibraryCoverPrefetch( + [ + { + albums: visibleAlbums.slice(0, Math.max(albumGridCols * 3, albumGridCols)), + priority: 'high', + }, + ], + [visibleAlbums, albumGridCols], + ); const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ sort, diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 37b09999..34238af0 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -1,5 +1,6 @@ import { uploadArtistImage } from '../api/subsonicPlaylists'; import { useCoverArt } from '../cover/useCoverArt'; +import { useArtistCoverRef } from '../cover/useLibraryCoverRef'; import { setRating, star, unstar } from '../api/subsonicStarRating'; import { getAlbum } from '../api/subsonicLibrary'; import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes'; @@ -44,6 +45,7 @@ import ArtistCard from '../components/nowPlaying/ArtistCard'; import LosslessModeBanner from '../components/LosslessModeBanner'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { albumGridWarmCovers } from '../cover/layoutSizes'; +import { rememberAlbumDistinctDiscCovers } from '../cover/ref'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; import { sortArtistAlbumsByYear } from '../utils/library/sortArtistAlbums'; @@ -167,7 +169,10 @@ export default function ArtistDetail() { // Cover URLs β€” must run every render (before early returns) or hook order breaks. const coverId = artist ? (artist.coverArt || artist.id) : ''; - const artistCoverFallback = useCoverArt(coverId || undefined, 80, { surface: 'sparse' }); + const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, { + libraryResolve: true, + }); + const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' }); const groupedAlbums = useMemo(() => { if (albums.length === 0) return []; @@ -210,6 +215,19 @@ export default function ArtistDetail() { setHeaderCoverFailed(false); }, [coverId, coverRevision, id]); + useEffect(() => { + const byAlbum = new Map(); + for (const song of topSongs) { + const albumId = song.albumId?.trim(); + if (!albumId) continue; + if (!byAlbum.has(albumId)) byAlbum.set(albumId, []); + byAlbum.get(albumId)!.push(song); + } + for (const [albumId, songs] of byAlbum) { + rememberAlbumDistinctDiscCovers(albumId, songs); + } + }, [topSongs]); + if (loading) { return (
    @@ -284,6 +302,7 @@ export default function ArtistDetail() { openedLink={openedLink} openLink={openLink} coverId={coverId} + coverRef={artistCoverRefResolved} coverRevision={coverRevision} headerCoverFailed={headerCoverFailed} setHeaderCoverFailed={setHeaderCoverFailed} @@ -313,6 +332,7 @@ export default function ArtistDetail() { ( - + )} /> ) : groupedAlbums.map(([label, group]) => ( @@ -395,7 +418,10 @@ export default function ArtistDetail() { wrapClassName="album-grid-wrap album-grid-wrap--artist" warmGridCovers={albumGridWarmCovers()} renderItem={a => ( - + )} />
    diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 02fa2c0c..1b28d21c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -18,9 +18,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { bumpPerfCounter } from '../utils/perf/perfTelemetry'; import { dedupeById } from '../utils/dedupeById'; import { shuffleArray } from '../utils/playback/shuffleArray'; -import { coverArtIdFromArtist } from '../cover/ids'; -import { coverPrefetchRegister } from '../cover/prefetchRegistry'; -import { coverArtRef } from '../cover/ref'; +import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch'; import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek'; import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache'; import { @@ -103,36 +101,20 @@ export default function Home() { bumpPerfCounter('homeCommits'); }); - useEffect(() => { - const heroRefs = heroAlbums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])); - const recentRefs = recent.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])); - const restAlbumRefs = [...random, ...mostPlayed, ...recentlyPlayed, ...starred].flatMap(a => - a.coverArt ? [coverArtRef(a.coverArt)] : [], - ); - const artistRefs = randomArtists.map(a => coverArtRef(coverArtIdFromArtist(a))); - const songRefs = discoverSongs.flatMap(s => { - const id = s.coverArt ?? s.albumId; - return id ? [coverArtRef(id)] : []; - }); - const unregHero = coverPrefetchRegister(heroRefs, { surface: 'dense', priority: 'high' }); - const unregRecent = coverPrefetchRegister(recentRefs, { surface: 'dense', priority: 'high' }); - // The album-and-artist `cappedRest` bucket is sized for the visible album - // rails (random + mostPlayed + recentlyPlayed + starred = 48 refs, plus - // 16 artist refs) and would otherwise crowd `songRefs` out entirely at - // the 24-entry slice. Register the Discover Songs rail on its own with - // its own modest cap so the song row gets a fair share of background - // bandwidth without inflating the 'low' bucket. - const cappedRest = [...restAlbumRefs, ...artistRefs].slice(0, 24); - const unregRest = coverPrefetchRegister(cappedRest, { surface: 'dense', priority: 'low' }); - const cappedSongs = songRefs.slice(0, 16); - const unregSongs = coverPrefetchRegister(cappedSongs, { surface: 'dense', priority: 'middle' }); - return () => { - unregHero(); - unregRecent(); - unregRest(); - unregSongs(); - }; - }, [heroAlbums, recent, random, mostPlayed, recentlyPlayed, starred, randomArtists, discoverSongs]); + useLibraryCoverPrefetch( + [ + { albums: heroAlbums, priority: 'high' }, + { albums: recent, priority: 'high' }, + { + albums: [...random, ...mostPlayed, ...recentlyPlayed, ...starred], + artists: randomArtists, + limit: 24, + priority: 'low', + }, + { songs: discoverSongs, limit: 16, priority: 'middle' }, + ], + [heroAlbums, recent, random, mostPlayed, recentlyPlayed, starred, randomArtists, discoverSongs], + ); useEffect(() => { if (!activeServerId) return; diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx index a74bf0b8..90da3e44 100644 --- a/src/pages/MostPlayed.tsx +++ b/src/pages/MostPlayed.tsx @@ -6,7 +6,8 @@ import { useNavigate } from 'react-router-dom'; import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound, Play, ListPlus } from 'lucide-react'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; -import { CoverArtImage } from '../cover/CoverArtImage'; +import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage'; +import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage'; import { playAlbum } from '../utils/playback/playAlbum'; import { useTranslation } from 'react-i18next'; @@ -55,17 +56,6 @@ function formatPlays(n: number, t: ReturnType - ); -} export default function MostPlayed() { const { t } = useTranslation(); @@ -169,7 +159,14 @@ export default function MostPlayed() { > {i + 1} {artist.coverArt ? ( - + ) : (
    )} @@ -206,7 +203,14 @@ export default function MostPlayed() { > {sortAsc ? withPlays.length - i : i + 1} {album.coverArt ? ( - + ) : (
    )} diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index 012fbcb3..e2e97314 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -1,5 +1,6 @@ -import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt'; import { useCoverArt } from '../cover/useCoverArt'; +import { albumCoverRef } from '../cover/ref'; +import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef'; import { coverArtIdFromRadio } from '../cover/ids'; import type { SubsonicArtistInfo, SubsonicSong } from '../api/subsonicTypes'; import React, { useState, useRef, useEffect, useCallback, useMemo, memo } from 'react'; @@ -95,11 +96,14 @@ export default function NowPlaying() { showLyrics(); }, [isQueueVisible, toggleQueue, showLyrics]); - const trackCover = usePlaybackCoverArt(currentTrack?.coverArt, 800); - const resolvedCover = trackCover.src; + const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined); const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined; - const radioCover = useCoverArt(radioCoverArtId, 800, { surface: 'sparse' }); + const radioCover = useCoverArt( + radioCoverArtId ? albumCoverRef(radioCoverArtId, radioCoverArtId) : null, + 800, + { surface: 'sparse' }, + ); const resolvedRadioCover = radioCover.src; const contributorRows = useMemo( @@ -242,7 +246,7 @@ export default function NowPlaying() { lfmLoved={lfmLoved} lfmLoveEnabled={lfmLoveEnabled} activeLyricsTab={activeTab === 'lyrics' && isQueueVisible} - coverUrl={resolvedCover} + coverRef={playbackCoverRef} onNavigate={stableNavigate} onToggleStar={toggleStar} onToggleLfmLove={toggleLfmLove} diff --git a/src/pages/OfflineLibrary.tsx b/src/pages/OfflineLibrary.tsx index 71ffbc45..f94eeb77 100644 --- a/src/pages/OfflineLibrary.tsx +++ b/src/pages/OfflineLibrary.tsx @@ -5,6 +5,7 @@ import { useOfflineStore, type OfflineAlbumMeta } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { CoverArtImage } from '../cover/CoverArtImage'; +import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { albumGridWarmCovers } from '../cover/layoutSizes'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; @@ -82,11 +83,12 @@ export default function OfflineLibrary() { return (
    - {coverScope && album.coverArt ? ( - 0 && ( - + )} {results.songs.length > 0 && ( diff --git a/src/pages/Tracks.tsx b/src/pages/Tracks.tsx index 3ac7fab8..5e372c2c 100644 --- a/src/pages/Tracks.tsx +++ b/src/pages/Tracks.tsx @@ -1,4 +1,5 @@ import { CoverArtImage } from '../cover/CoverArtImage'; +import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage'; import { getRandomSongs } from '../api/subsonicLibrary'; import type { SubsonicSong } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; @@ -109,9 +110,10 @@ export default function Tracks() { {!perfFlags.disableMainstageHero && hero && (
    - {hero.coverArt ? ( - void { if (cached !== undefined) { sendPresence(cached); } else { - const ref = coverArtRef(currentTrack.coverArt, resolvePlaybackCoverScope()); - coverArtUrlForDiscord(ref).then(url => { - discordServerCoverCache.set(cacheKey, url); - sendPresence(url); - }).catch(() => { - discordServerCoverCache.set(cacheKey, null); - sendPresence(null); + void resolveTrackCoverRefFromLibrary( + { + id: currentTrack.id, + albumId: currentTrack.albumId, + coverArt: currentTrack.coverArt, + discNumber: (currentTrack as { discNumber?: number }).discNumber, + }, + resolvePlaybackCoverScope(), + ).then(ref => { + if (!ref) { + sendPresence(null); + return; + } + return coverArtUrlForDiscord(ref) + .then(url => { + discordServerCoverCache.set(cacheKey, url); + sendPresence(url); + }) + .catch(() => { + discordServerCoverCache.set(cacheKey, null); + sendPresence(null); + }); }); } } else { diff --git a/src/store/audioListenerSetup/mprisSync.ts b/src/store/audioListenerSetup/mprisSync.ts index 17f9e2b7..5c913e61 100644 --- a/src/store/audioListenerSetup/mprisSync.ts +++ b/src/store/audioListenerSetup/mprisSync.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; -import { coverArtRef, resolvePlaybackCoverScope } from '../../cover/ref'; +import { resolvePlaybackCoverScope } from '../../cover/ref'; +import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary'; import { coverArtUrlForMpris } from '../../cover/integrations/mpris'; import { usePlayerStore } from '../playerStore'; import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../playbackProgress'; @@ -26,17 +27,27 @@ export function setupMprisSync(): () => void { const artist = currentTrack.artist; const album = currentTrack.album; const durationSecs = currentTrack.duration; - if (currentTrack.coverArt) { - const ref = coverArtRef(currentTrack.coverArt, resolvePlaybackCoverScope()); - coverArtUrlForMpris(ref) - .then(coverUrl => invoke('mpris_set_metadata', { - title, - artist, - album, - coverUrl: coverUrl || undefined, - durationSecs, - })) - .catch(() => {}); + if (currentTrack.coverArt && currentTrack.albumId) { + void resolveTrackCoverRefFromLibrary( + { + id: currentTrack.id, + albumId: currentTrack.albumId, + coverArt: currentTrack.coverArt, + discNumber: (currentTrack as { discNumber?: number }).discNumber, + }, + resolvePlaybackCoverScope(), + ).then(ref => { + if (!ref) return; + coverArtUrlForMpris(ref) + .then(coverUrl => invoke('mpris_set_metadata', { + title, + artist, + album, + coverUrl: coverUrl || undefined, + durationSecs, + })) + .catch(() => {}); + }); } else { invoke('mpris_set_metadata', { title, diff --git a/src/store/playerStoreTypes.ts b/src/store/playerStoreTypes.ts index 85a0f648..d7d6962a 100644 --- a/src/store/playerStoreTypes.ts +++ b/src/store/playerStoreTypes.ts @@ -12,6 +12,7 @@ export interface Track { artists?: SubsonicOpenArtistRef[]; duration: number; coverArt?: string; + discNumber?: number; track?: number; year?: number; bitRate?: number; diff --git a/src/utils/library/advancedSearchLocal.test.ts b/src/utils/library/advancedSearchLocal.test.ts index 3f3b2a99..3db0b4be 100644 --- a/src/utils/library/advancedSearchLocal.test.ts +++ b/src/utils/library/advancedSearchLocal.test.ts @@ -115,6 +115,15 @@ describe('runLocalAdvancedSearch', () => { expect(resolveTrackCoverArtId({ coverArtId: 'cv1', albumId: 'al-42' })).toBe('cv1'); }); + it('resolveTrackCoverArtId prefers raw_json mf art over stale index column', () => { + expect( + resolveTrackCoverArtId( + { coverArtId: 'mf-disc1', albumId: 'al-box' }, + { coverArt: 'mf-disc2', albumId: 'al-box', discNumber: 2 }, + ), + ).toBe('mf-disc2'); + }); + it('trackToSong sets coverArt from albumId when the index row has no cover_art_id', () => { const song = trackToSong({ serverId: 's1', diff --git a/src/utils/library/advancedSearchLocal.ts b/src/utils/library/advancedSearchLocal.ts index d9daa367..469732ba 100644 --- a/src/utils/library/advancedSearchLocal.ts +++ b/src/utils/library/advancedSearchLocal.ts @@ -147,6 +147,12 @@ export function resolveTrackCoverArtId( hot: Pick, song: Partial = {}, ): string | undefined { + const songArt = typeof song.coverArt === 'string' ? song.coverArt.trim() : ''; + const hotArt = typeof hot.coverArtId === 'string' ? hot.coverArtId.trim() : ''; + // `raw_json` per-disc `coverArt` wins over a stale index `cover_art_id` (often disc 1). + if (songArt && hotArt && songArt !== hotArt && songArt.startsWith('mf-')) { + return songArt; + } for (const c of [hot.coverArtId, song.coverArt, hot.albumId, song.albumId]) { const id = typeof c === 'string' ? c.trim() : ''; if (id) return id; diff --git a/src/utils/library/libraryAnalysisBackfillPolicy.test.ts b/src/utils/library/libraryAnalysisBackfillPolicy.test.ts index 3f274516..8d60b7cd 100644 --- a/src/utils/library/libraryAnalysisBackfillPolicy.test.ts +++ b/src/utils/library/libraryAnalysisBackfillPolicy.test.ts @@ -14,20 +14,34 @@ describe('libraryAnalysisBackfillPolicy', () => { expect(computeLibraryBackfillTargetDepth(100)).toBe(240); }); - it('measures backlog as queued plus active downloads', () => { + const zeroCpu = { cpuQueued: 0, cpuDecodeActive: 0 }; + + it('measures backlog as HTTP plus CPU seed pipeline load', () => { expect( - libraryBackfillPipelineBacklog({ httpQueued: 5, httpDownloadActive: 3 }), + libraryBackfillPipelineBacklog({ + httpQueued: 5, + httpDownloadActive: 3, + ...zeroCpu, + }), ).toBe(8); + expect( + libraryBackfillPipelineBacklog({ + httpQueued: 0, + httpDownloadActive: 0, + cpuQueued: 4, + cpuDecodeActive: 2, + }), + ).toBe(6); }); it('requests top-up while backlog stays below target', () => { - const stats = { httpQueued: 2, httpDownloadActive: 1 }; + const stats = { httpQueued: 2, httpDownloadActive: 1, ...zeroCpu }; expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(true); expect(libraryBackfillTopUpLimit(stats, 8)).toBe(20); }); it('stops top-up when backlog meets target', () => { - const stats = { httpQueued: 20, httpDownloadActive: 4 }; + const stats = { httpQueued: 20, httpDownloadActive: 4, ...zeroCpu }; expect(libraryBackfillNeedsTopUp(stats, 8)).toBe(false); expect(libraryBackfillTopUpLimit(stats, 8)).toBe(0); }); diff --git a/src/utils/library/libraryAnalysisBackfillPolicy.ts b/src/utils/library/libraryAnalysisBackfillPolicy.ts index 8ac9cb72..966c7f14 100644 --- a/src/utils/library/libraryAnalysisBackfillPolicy.ts +++ b/src/utils/library/libraryAnalysisBackfillPolicy.ts @@ -14,24 +14,26 @@ export function computeLibraryBackfillTargetDepth(workers: number): number { ); } -/** HTTP jobs waiting or actively downloading (keeps workers fed after decode decoupling). */ -export function libraryBackfillPipelineBacklog( - stats: Pick, -): number { - return stats.httpQueued + stats.httpDownloadActive; +type PipelineBacklogStats = Pick< + AnalysisPipelineQueueStatsDto, + 'httpQueued' | 'httpDownloadActive' | 'cpuQueued' | 'cpuDecodeActive' +>; + +/** HTTP + in-flight CPU seed jobs (decode backpressure must not look like an empty pipeline). */ +export function libraryBackfillPipelineBacklog(stats: PipelineBacklogStats): number { + return ( + stats.httpQueued + + stats.httpDownloadActive + + stats.cpuQueued + + stats.cpuDecodeActive + ); } -export function libraryBackfillNeedsTopUp( - stats: Pick, - workers: number, -): boolean { +export function libraryBackfillNeedsTopUp(stats: PipelineBacklogStats, workers: number): boolean { return libraryBackfillPipelineBacklog(stats) < computeLibraryBackfillTargetDepth(workers); } -export function libraryBackfillTopUpLimit( - stats: Pick, - workers: number, -): number { +export function libraryBackfillTopUpLimit(stats: PipelineBacklogStats, workers: number): number { const target = computeLibraryBackfillTargetDepth(workers); const deficit = target - libraryBackfillPipelineBacklog(stats); if (deficit <= 0) return 0; diff --git a/src/utils/offline/offlineLibraryHelpers.test.ts b/src/utils/offline/offlineLibraryHelpers.test.ts index 71025280..6bdb1571 100644 --- a/src/utils/offline/offlineLibraryHelpers.test.ts +++ b/src/utils/offline/offlineLibraryHelpers.test.ts @@ -65,7 +65,9 @@ describe('offlineLibraryHelpers', () => { const scope = offlineAlbumCoverScope(album); expect(scope).toMatchObject({ kind: 'server', serverId: 'a' }); const tier = resolveCoverDisplayTier(300, { surface: 'dense' }); - expect(coverStorageKey(scope!, 'ca1', tier)).toBe('a.test:cover:ca1:512'); + expect(coverStorageKey(scope!, { cacheKind: 'album', cacheEntityId: 'ca1' }, tier)).toBe( + 'a.test:cover:album:ca1:512', + ); }); it('ensureServerForOfflineAlbum skips switch when already active', async () => { diff --git a/src/utils/playback/playbackServer.test.ts b/src/utils/playback/playbackServer.test.ts index 1071b06a..8f3b6aa6 100644 --- a/src/utils/playback/playbackServer.test.ts +++ b/src/utils/playback/playbackServer.test.ts @@ -109,7 +109,7 @@ describe('playbackServer', () => { useAuthStore.setState({ activeServerId: 'b' }); const { src, cacheKey } = playbackCoverArtForId('cov1', 128); expect(src).toContain('a.test'); - expect(cacheKey).toBe('a.test:cover:cov1:128'); + expect(cacheKey).toBe('a.test:cover:album:cov1:128'); }); it('shouldBindQueueServerForPlay detects queue replacement', () => { diff --git a/src/utils/playback/playbackServer.ts b/src/utils/playback/playbackServer.ts index 1a94a5a8..986c8fe5 100644 --- a/src/utils/playback/playbackServer.ts +++ b/src/utils/playback/playbackServer.ts @@ -1,6 +1,7 @@ import { buildCoverArtFetchUrl } from '../../cover/fetchUrl'; -import { coverArtRef, resolvePlaybackCoverScope } from '../../cover/ref'; -import { coverStorageKey } from '../../cover/storageKeys'; +import { resolvePlaybackCoverScope } from '../../cover/ref'; +import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntry'; +import { coverStorageKeyFromRef } from '../../cover/storageKeys'; import { resolveCoverDisplayTier } from '../../cover/tiers'; import { useAuthStore } from '../../store/authStore'; import { usePlayerStore } from '../../store/playerStore'; @@ -101,15 +102,28 @@ export async function ensurePlaybackServerActive(): Promise { } /** Cover fetch URL + storage key for queue prefetch (displayCssPx = layout CSS px). */ -export function playbackCoverArtForId(coverId: string, displayCssPx: number): { src: string; cacheKey: string } { - const ref = coverArtRef(coverId, resolvePlaybackCoverScope()); +export function playbackCoverArtForAlbum( + albumId: string, + coverArt: string, + displayCssPx: number, +): { src: string; cacheKey: string } { + const entry = resolveAlbumCoverEntry(albumId, coverArt); + if (!entry) { + return playbackCoverArtForId(coverArt, displayCssPx); + } + const ref = coverEntryToRef(entry, resolvePlaybackCoverScope()); const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' }); return { src: buildCoverArtFetchUrl(ref, tier), - cacheKey: coverStorageKey(ref.serverScope, coverId, tier), + cacheKey: coverStorageKeyFromRef(ref, tier), }; } +/** @deprecated Use {@link playbackCoverArtForAlbum} with album id. */ +export function playbackCoverArtForId(coverId: string, displayCssPx: number): { src: string; cacheKey: string } { + return playbackCoverArtForAlbum(coverId, coverId, displayCssPx); +} + export function shouldBindQueueServerForPlay( prevQueue: QueueItemRef[], newQueue: Track[], diff --git a/src/utils/playback/songToTrack.ts b/src/utils/playback/songToTrack.ts index 7fdf4b4f..f11e0e26 100644 --- a/src/utils/playback/songToTrack.ts +++ b/src/utils/playback/songToTrack.ts @@ -11,6 +11,7 @@ export function songToTrack(song: SubsonicSong): Track { artists: song.artists && song.artists.length > 0 ? song.artists : undefined, duration: song.duration, coverArt: song.coverArt, + discNumber: song.discNumber, track: song.track, year: song.year, bitRate: song.bitRate,