mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(cover): Windows thumbnails, tier fallback, PNG decode, coverArt id (#878)
* fix(cover): tier fallback for sparse surfaces and Windows asset URLs Sparse UI (player bar, queue) now reads disk covers via the same tier ladder as dense grids, so a warm 800.webp satisfies a 128px request. Reject non-asset convertFileSrc results on Windows, widen Tauri asset scope, and seed ladder keys on cover:tier-ready. applyDiskPath uses seedGridDiskSrcCache only to avoid notify/subscriber infinite loops. * fix(artist): top-track thumb uses album coverArt already warm in grid Song coverArt ids often differ from album cover ids (e.g. Octastorium in the grid vs empty track thumb). Prefer the album row's coverArt on artist pages and ensure high priority for 32px dense cells. * fix(cover): albumId for playback/queue; no broken img until disk URL ready Prefer albumId over track-id coverArt (Navidrome). Wire queue to CoverArtImage with playback scope. CoverArtImage renders a placeholder div until asset src exists to avoid the browser broken-image icon. * fix(test): add song id to resolveArtistPageSongCoverArtId fixture Pick<SubsonicSong, …> requires id; fixes tsc in CI/build. * fix(cover): resolve albumId for Now Playing and artist top tracks Prefer albumId when album.coverArt echoes track id; use sparse surface on artist suggestion thumbs; apply resolveSubsonicSongCoverArtId across playback surfaces (Now Playing, fullscreen, mobile, mini). * fix(cover): decode PNG from Subsonic before WebP tier encode Enable `png` in the image crate — some servers return PNG cover art; failed decode left `.fetch-failed` and empty thumbs for those albums. * refactor(cover): consolidate cover id resolution and align tests Move resolveSubsonicSongCoverArtId helpers to src/cover/resolveCoverArtId.ts with resolvePlaybackTrackCoverArtId for player surfaces; co-locate tests; fix FullscreenPlayer expectations for albumId-first resolution. * docs: CHANGELOG and credits for PR #878 * fix(cover): keep per-track coverArt when distinct from song id Address PR #878 review (b): albumId only when coverArt is missing or echoes track id; pin case with unit test; comment isRawFsPath symmetry. * chore(cover): address PR #878 review nits (scope, tests, rename) Narrow asset scope to cover-cache dirs only; add diskSrcCache Windows-path tests; rename ArtistTopTrackCover; CHANGELOG symptom-first wording. * fix(cover): restore asset scope to app data dirs (Windows regression) $APPDATA/cover-cache/** did not match Tauri scope resolution — covers were blocked after load. Use $APPDATA/** and $APPLOCALDATA/** (no $DATA). * fix(cover): Windows asset URLs — restore DATA scope, path normalize Regression after review nits: dropped $DATA/** and strict isAssetProtocolUrl blocked valid http://asset.localhost URLs on Windows. Normalize C:/ paths before convertFileSrc; CoverArtImage/Hero hide broken img on load error. * fix(cover): disk peek fallbacks when cache folder id differs Small surfaces resolve albumId while cover-cache often stores WebP under track id or album.coverArt from the grid. Peek batch now tries legacy ids; playback scope resolves server index key by URL key, not UUID-only lookup. * fix(cover): Navidrome al-* vs mf-* disk id mismatch UI used mf-* coverArtId while library backfill only cached al-* folders. Prefer album id for display/peek when coverArt is mf-*; backfill now queues both distinct album_id and cover_art_id values. * fix(cover): mf→al disk peek when mf folder missing in cache Navidrome Subsonic often returns mf-* coverArtId while backfill only creates al-* folders. Peek mf first, then al-* from hints; load albumId from library when Subsonic omits it; ensure fallback uses al-* id. * feat(cover): CoverArtRef, segment disk layout, library-index backfill Normalize cover caching around stable entity ids from the local library and Navidrome fetch ids. Disk paths live in psysonic_core::cover_cache_layout (album/<entityId>/); UI uses CoverArtRef with cacheEntityId + fetchCoverArtId. - Remove SQLite/mf peek helpers (diskPeekIds, peekCoverOnDisk, mergeDiskIdHints) - Backfill reads album/artist rows from library SQLite (bare Navidrome ids ok) - Use stored cover_art_id for HTTP; per-disc dirs only when discs differ - Migrate call sites to albumCoverRef / albumCoverRefForPlayback * feat(cover): central CoverEntry resolver (artist, album, track) Add resolveEntry.ts and Rust CoverEntry helpers as the single source of truth for cache_entity_id vs fetch_cover_art_id. ref.ts delegates to them; resolveCoverArtId becomes a thin compatibility shim. * feat(cover): resolve cover entries from local library index Add library_resolve_cover_entry IPC and cover_resolve.rs so album, artist, and track covers use SQLite cover_art_id + disc detection. TypeScript helpers in resolveEntryLibrary.ts prefer the index over live API fields when rows exist. * feat(cover): library-first hooks for grids and playback UI Add useAlbumCoverRef, useArtistCoverRef, useTrackCoverRef, and usePlaybackTrackCoverRef — sync fallback then SQLite index upgrade. Wire album/artist cards, album header, song card, and all player surfaces to resolve covers from the local library when indexed. * feat(cover): complete library-first migration across all UI surfaces Add Album/Artist/TrackCoverArtImage, useLibraryCoverPrefetch, and batch resolve helpers. Migrate grids, search, home, playback sidecars, warm peek, playlists, and share flows to hooks that upgrade from SQLite. Backfill normalizes album rows through cover_resolve; document paths in COVER_PATHS.md. Radio remains a deliberate non-library exception. * fix(cover): stop render loop from unstable serverScope in library hooks Default param `{ kind: 'active' }` created a new object every render, so every grid cell re-ran library_resolve IPC and setState in a loop. Use COVER_SCOPE_ACTIVE singleton, coverScopeKey deps, and guarded sync updates. * chore(cover): remove COVER_PATHS.md from app tree (lives in workdocs) Audit doc is team spec — see workdocs 2026-05-cover-art-pipeline/cover-paths-audit.md. * fix(cover): unstick library backfill after route changes (PR #870 regression) useCoverNavigationPriority cleanup called beginNavigation instead of end, leaking navigationHoldDepth so ui_priority_hold never released and backfill never downloaded. Also skip disk check after cover_resolve normalization. * fix(cover): segment progress, cap backfill CPU, include artists in catalog Progress and disk size now scan album/ and artist/ segments (canonical 800.webp). Prune legacy flat server/al-* dirs on startup and backfill pass. Backfill: max 2 concurrent ensures; JPEG decode and WebP encode run on the blocking pool behind a shared 2-permit semaphore so Tokio workers stay cool. Artists were missing because the catalog only read the empty artist table; add distinct artist_id from track and album rows. Paginate with a composite (kind, id) cursor so album and artist rows are not skipped. * fix(cover): drop legacy prune; backfill per-disc and artist catalog Remove prune_legacy_* and cover_cache_catalog_entry — layout is only cover_dir (album|artist segments); stale flat dirs clear on LAYOUT_STAMP change. Backfill: artists from track/album artist_id; expand albums to per-CD mf-* slots when discs differ; fix resolve_album_cover_entry when album row is missing. * fix(cover): reduce library IPC storms and fix multi-disc player art Skip per-row library_resolve on live search and artist album grids; warm grids from API coverArt after mount instead of blocking layout. Dedupe and cap concurrent library_resolve calls. Restore per-disc cache keys in the player and queue when track mf-* art differs from the album bucket. * fix(cover): skip library resolve on advanced and full search rows Use API coverArt for album/artist rails and lazy viewport artwork so result pages do not fire hundreds of library_resolve IPC calls at once. * fix(cover): default libraryResolve off for browse grids and rails Skip per-card library_resolve on album/artist/song browse UI by default; keep it on album/artist headers, playback queue rows, and orbit approval. * fix(cover): split UI/backfill CPU pools and restore mainstage hero carousel Library backfill no longer shares the 2-permit JPEG/WebP semaphore with visible cover ensures. Hero initializes albums from props, re-binds scroll visibility after mount, updates backdrop on slide change, and uses library resolve for correct cover art on the banner. * fix(analysis): resume full-library scan after candidates phase Reset the SQL cursor when entering full-library mode so tracks with partial analysis are not skipped. Tighten TS backfill completion and CPU queue watermarking; align cover-cache key tests with album-scoped storage keys. * fix(library): remove useless map_err in cover_resolve (clippy) CI treats clippy::useless-conversion as error on rusqlite optional() chains. * fix(cover): satisfy clippy on cover_cache_ensure IPC args Pass CoverCacheEnsureArgs as a single Tauri parameter instead of nine positional fields; align frontend invoke payload with { args }.
This commit is contained in:
@@ -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
|
## [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.
|
> **🙏 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.
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ rusqlite = { version = "0.39", features = ["bundled"] }
|
|||||||
ebur128 = "0.1"
|
ebur128 = "0.1"
|
||||||
dasp_sample = "0.11.0"
|
dasp_sample = "0.11.0"
|
||||||
zip = "0.6.6"
|
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"
|
webp = "0.3"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
|||||||
@@ -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<CoverEntry> {
|
||||||
|
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<CoverEntry> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! macros) and the cross-crate port traits used to break dependency cycles
|
//! macros) and the cross-crate port traits used to break dependency cycles
|
||||||
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
|
||||||
|
|
||||||
|
pub mod cover_cache_layout;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod track_analysis;
|
pub mod track_analysis;
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ enum ScanMode {
|
|||||||
Full,
|
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<String>) {
|
||||||
|
(ScanMode::Full, None)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn collect_analysis_backfill_batch(
|
pub fn collect_analysis_backfill_batch(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
runtime: &LibraryRuntime,
|
runtime: &LibraryRuntime,
|
||||||
@@ -63,7 +70,7 @@ pub fn collect_analysis_backfill_batch(
|
|||||||
if page.is_empty() {
|
if page.is_empty() {
|
||||||
match mode {
|
match mode {
|
||||||
ScanMode::Candidates => {
|
ScanMode::Candidates => {
|
||||||
mode = ScanMode::Full;
|
(mode, after) = begin_full_library_scan();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ScanMode::Full => {
|
ScanMode::Full => {
|
||||||
@@ -98,7 +105,7 @@ pub fn collect_analysis_backfill_batch(
|
|||||||
if page_len < SCAN_CHUNK {
|
if page_len < SCAN_CHUNK {
|
||||||
match mode {
|
match mode {
|
||||||
ScanMode::Candidates => {
|
ScanMode::Candidates => {
|
||||||
mode = ScanMode::Full;
|
(mode, after) = begin_full_library_scan();
|
||||||
}
|
}
|
||||||
ScanMode::Full => {
|
ScanMode::Full => {
|
||||||
return Ok(LibraryAnalysisBackfillBatchDto {
|
return Ok(LibraryAnalysisBackfillBatchDto {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use psysonic_integration::subsonic::SubsonicClient;
|
|||||||
|
|
||||||
use crate::advanced_search;
|
use crate::advanced_search;
|
||||||
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
|
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
|
||||||
|
use crate::cover_resolve::CoverEntryDto;
|
||||||
use crate::cross_server;
|
use crate::cross_server;
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
||||||
@@ -51,6 +52,28 @@ pub struct LibraryServerKeyMigrationDto {
|
|||||||
pub index_key: String,
|
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<Option<CoverEntryDto>, 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]
|
#[tauri::command]
|
||||||
pub fn library_analysis_backfill_batch(
|
pub fn library_analysis_backfill_batch(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
//! Library cursor scan for background cover disk warm-up.
|
//! Library cursor scan for background cover disk warm-up.
|
||||||
//!
|
//!
|
||||||
//! Cover IDs for backfill come from **track** + **album** rows using
|
//! Catalog rows come from SQLite (`album` / `artist` tables) with explicit `kind`.
|
||||||
//! `COALESCE(cover_art_id, album_id)` (album table id as fallback).
|
//! On-disk paths — `psysonic_core::cover_cache_layout`.
|
||||||
//! Artist IDs are excluded — `getCoverArt` with `artist_id` often 404s and stalled the queue.
|
|
||||||
|
|
||||||
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;
|
use crate::store::LibraryStore;
|
||||||
|
|
||||||
const DEFAULT_BATCH: u32 = 32;
|
const DEFAULT_BATCH: u32 = 32;
|
||||||
@@ -13,9 +17,19 @@ const MAX_BATCH: u32 = 48;
|
|||||||
const SCAN_PAGE: i64 = 256;
|
const SCAN_PAGE: i64 = 256;
|
||||||
const MAX_SCAN_PAGES: usize = 16;
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct LibraryCoverBackfillBatchDto {
|
pub struct LibraryCoverBackfillBatchDto {
|
||||||
|
pub items: Vec<CoverBackfillItem>,
|
||||||
|
/// Entity ids only — compatibility shim for older callers.
|
||||||
pub cover_ids: Vec<String>,
|
pub cover_ids: Vec<String>,
|
||||||
pub next_cursor: Option<String>,
|
pub next_cursor: Option<String>,
|
||||||
pub exhausted: bool,
|
pub exhausted: bool,
|
||||||
@@ -29,26 +43,108 @@ pub struct LibraryCoverProgressDto {
|
|||||||
pub done: i64,
|
pub done: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
const COVER_ID_SUBQUERY: &str = "
|
/// `kind`, entity `id`, and HTTP `getCoverArt` id (Navidrome `cover_art_id` or fallback to entity id).
|
||||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(album_id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
///
|
||||||
FROM track
|
/// The `artist` table is often empty (IS-4 only stores a watermark). Artists are also taken from
|
||||||
WHERE server_id = ?1 AND deleted = 0
|
/// `track.artist_id` and `album.artist_id` so backfill matches library browse / prefetch.
|
||||||
AND (
|
const COVER_CATALOG_SUBQUERY: &str = "
|
||||||
NULLIF(TRIM(album_id), '') IS NOT NULL
|
SELECT 'album' AS kind,
|
||||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
TRIM(id) AS id,
|
||||||
)
|
COALESCE(NULLIF(TRIM(cover_art_id), ''), TRIM(id)) AS fetch_id
|
||||||
UNION
|
|
||||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
|
||||||
FROM album
|
FROM album
|
||||||
WHERE server_id = ?1
|
WHERE server_id = ?1 AND NULLIF(TRIM(id), '') IS NOT NULL
|
||||||
AND (
|
UNION ALL
|
||||||
NULLIF(TRIM(id), '') IS NOT NULL
|
SELECT 'album',
|
||||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
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";
|
pub const COVER_FETCH_FAIL_MARKER: &str = ".fetch-failed";
|
||||||
|
|
||||||
/// Recent HTTP failure — skip in backfill cursor so slots go to fetchable album art.
|
/// 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<Vec<CoverBackfillItem>, 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 {
|
pub fn cover_fetch_recently_failed(cover_dir: &Path) -> bool {
|
||||||
let marker = cover_dir.join(COVER_FETCH_FAIL_MARKER);
|
let marker = cover_dir.join(COVER_FETCH_FAIL_MARKER);
|
||||||
let Ok(meta) = std::fs::metadata(&marker) else {
|
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.
|
/// 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 {
|
pub fn clear_cover_fetch_failures(cover_root: &Path, server_index_key: &str) -> u32 {
|
||||||
let server_dir = cover_root.join(server_index_key);
|
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;
|
let mut cleared = 0u32;
|
||||||
for id_dir in entries.flatten() {
|
for kind in cover_cache_layout::SEGMENT_KINDS {
|
||||||
let marker = id_dir.path().join(COVER_FETCH_FAIL_MARKER);
|
let kind_dir = server_dir.join(kind);
|
||||||
if marker.is_file() && std::fs::remove_file(&marker).is_ok() {
|
let Ok(entries) = std::fs::read_dir(&kind_dir) else {
|
||||||
cleared += 1;
|
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
|
cleared
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_cover_id_page(
|
fn fetch_catalog_page(
|
||||||
store: &LibraryStore,
|
store: &LibraryStore,
|
||||||
library_server_id: &str,
|
library_server_id: &str,
|
||||||
after: &str,
|
after: &str,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<CoverBackfillItem>, String> {
|
||||||
store.with_read_conn(|conn| {
|
store.with_read_conn(|conn| {
|
||||||
|
let (after_kind, after_id) = parse_catalog_cursor(after);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id FROM ({COVER_ID_SUBQUERY})
|
"SELECT kind, id, fetch_id FROM (
|
||||||
WHERE id > ?2
|
SELECT kind, id, MAX(fetch_id) AS fetch_id
|
||||||
ORDER BY id ASC
|
FROM ({COVER_CATALOG_SUBQUERY})
|
||||||
LIMIT ?3"
|
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 mut stmt = conn.prepare(&sql)?;
|
||||||
let ids = stmt
|
let rows = stmt
|
||||||
.query_map(rusqlite::params![library_server_id, after, limit], |row| {
|
.query_map(
|
||||||
row.get::<_, String>(0)
|
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::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
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<i64, String> {
|
pub fn count_distinct_cover_ids(store: &LibraryStore, library_server_id: &str) -> Result<i64, String> {
|
||||||
store.with_read_conn(|conn| {
|
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))
|
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))
|
.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(
|
pub fn cover_canonical_cached_on_disk(
|
||||||
cover_root: &Path,
|
cover_root: &Path,
|
||||||
server_index_key: &str,
|
server_index_key: &str,
|
||||||
cover_art_id: &str,
|
cache_kind: &str,
|
||||||
|
cache_entity_id: &str,
|
||||||
) -> bool {
|
) -> 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)
|
tier_file_ready(&dir, LIBRARY_COVER_CANONICAL_TIER)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cover_ladder_cached_on_disk(
|
pub fn cover_ladder_cached_on_disk(
|
||||||
cover_root: &Path,
|
cover_root: &Path,
|
||||||
server_index_key: &str,
|
server_index_key: &str,
|
||||||
cover_art_id: &str,
|
cache_kind: &str,
|
||||||
|
cache_entity_id: &str,
|
||||||
) -> bool {
|
) -> 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)
|
cover_ladder_complete_on_disk(&dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,32 +294,51 @@ pub fn collect_cover_backfill_batch(
|
|||||||
if pending.len() >= want {
|
if pending.len() >= want {
|
||||||
break;
|
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() {
|
if page.is_empty() {
|
||||||
sql_exhausted = true;
|
sql_exhausted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
for id in &page {
|
for item in page {
|
||||||
after.clone_from(id);
|
after = format_catalog_cursor(&item.cache_kind, &item.cache_entity_id);
|
||||||
let dir = cover_root.join(server_index_key).join(id);
|
for normalized in expand_backfill_items(store, library_server_id, item)? {
|
||||||
if cover_canonical_cached_on_disk(cover_root, server_index_key, id)
|
if cover_canonical_cached_on_disk(
|
||||||
|| cover_fetch_recently_failed(&dir)
|
cover_root,
|
||||||
{
|
server_index_key,
|
||||||
continue;
|
&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 {
|
if pending.len() >= want {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (page.len() as i64) < SCAN_PAGE {
|
if (page_len as i64) < SCAN_PAGE {
|
||||||
sql_exhausted = true;
|
sql_exhausted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cover_ids = pending
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.cache_entity_id.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(LibraryCoverBackfillBatchDto {
|
Ok(LibraryCoverBackfillBatchDto {
|
||||||
cover_ids: pending,
|
items: pending,
|
||||||
|
cover_ids,
|
||||||
next_cursor: if sql_exhausted { None } else { Some(after) },
|
next_cursor: if sql_exhausted { None } else { Some(after) },
|
||||||
exhausted: sql_exhausted,
|
exhausted: sql_exhausted,
|
||||||
})
|
})
|
||||||
@@ -202,17 +354,25 @@ pub fn count_pending_canonical_covers(
|
|||||||
let mut after = String::new();
|
let mut after = String::new();
|
||||||
let mut pending = 0i64;
|
let mut pending = 0i64;
|
||||||
loop {
|
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() {
|
if page.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
for id in &page {
|
let page_len = page.len();
|
||||||
after.clone_from(id);
|
for item in page {
|
||||||
if !cover_canonical_cached_on_disk(cover_root, server_index_key, id) {
|
after = format_catalog_cursor(&item.cache_kind, &item.cache_entity_id);
|
||||||
pending += 1;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +439,24 @@ mod tests {
|
|||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
fn backfill_uses_track_album_id_when_cover_art_null() {
|
fn backfill_uses_track_album_id_when_cover_art_null() {
|
||||||
let store = LibraryStore::open_in_memory();
|
let store = LibraryStore::open_in_memory();
|
||||||
@@ -295,13 +473,39 @@ mod tests {
|
|||||||
assert_eq!(batch.cover_ids, vec!["al-99".to_string()]);
|
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]
|
#[test]
|
||||||
fn backfill_skips_when_canonical_800_exists() {
|
fn backfill_skips_when_canonical_800_exists() {
|
||||||
let store = LibraryStore::open_in_memory();
|
let store = LibraryStore::open_in_memory();
|
||||||
seed_track(&store, "srv", "tr1", "al-partial", None);
|
seed_track(&store, "srv", "tr1", "al-partial", None);
|
||||||
let root = std::env::temp_dir().join("psysonic-cover-backfill-test");
|
let root = std::env::temp_dir().join("psysonic-cover-backfill-test");
|
||||||
let host = "srv-host";
|
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::create_dir_all(&id_dir).unwrap();
|
||||||
std::fs::write(id_dir.join("128.webp"), b"x").unwrap();
|
std::fs::write(id_dir.join("128.webp"), b"x").unwrap();
|
||||||
|
|
||||||
@@ -332,11 +536,120 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn count_distinct_includes_artist_ids() {
|
fn backfill_includes_per_disc_mf_when_discs_differ() {
|
||||||
let store = LibraryStore::open_in_memory();
|
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
|
store
|
||||||
.with_conn_mut("test_artist", |conn| {
|
.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(
|
conn.execute(
|
||||||
"INSERT INTO track (
|
"INSERT INTO track (
|
||||||
server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json
|
server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json
|
||||||
@@ -347,6 +660,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let n = count_distinct_cover_ids(&store, "srv").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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<CoverEntry> 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<bool, String> {
|
||||||
|
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<i64>>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut art_by_disc: std::collections::HashMap<i64, String> = 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<Option<CoverEntryDto>, 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<String>>(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<Option<CoverEntryDto>, 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<Vec<CoverEntryDto>, 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<i64>>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
row.get::<_, Option<String>>(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<Option<CoverEntryDto>, 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<Option<CoverEntryDto>, String> {
|
||||||
|
let track_id = track_id.trim();
|
||||||
|
if track_id.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let row: Option<(String, Option<String>, Option<String>)> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ mod advanced_search_mood;
|
|||||||
pub mod analysis_backfill;
|
pub mod analysis_backfill;
|
||||||
pub mod artist_lossless_browse;
|
pub mod artist_lossless_browse;
|
||||||
pub mod cover_backfill;
|
pub mod cover_backfill;
|
||||||
|
pub mod cover_resolve;
|
||||||
pub mod canonical;
|
pub mod canonical;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod cross_server;
|
pub mod cross_server;
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use tokio::sync::{Mutex, Semaphore};
|
|||||||
|
|
||||||
use super::{count_cached_cover_ids, dir_usage_for_server};
|
use super::{count_cached_cover_ids, dir_usage_for_server};
|
||||||
|
|
||||||
/// Concurrent library downloads (encode runs on blocking pool; no webview tier events).
|
/// Concurrent library downloads + encodes (hard cap — avoids saturating all CPU cores).
|
||||||
const LIBRARY_BACKFILL_PARALLEL: usize = 4;
|
const LIBRARY_BACKFILL_PARALLEL: usize = 2;
|
||||||
const BATCH_SIZE: u32 = 24;
|
const BATCH_SIZE: u32 = 24;
|
||||||
const PENDING_RESTART_THRESHOLD: i64 = 32;
|
const PENDING_RESTART_THRESHOLD: i64 = 32;
|
||||||
const SYNC_WAIT_MS: u64 = 5000;
|
const SYNC_WAIT_MS: u64 = 5000;
|
||||||
@@ -160,14 +160,16 @@ async fn ensure_one(
|
|||||||
http_sem: Arc<Semaphore>,
|
http_sem: Arc<Semaphore>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
session: CoverBackfillSession,
|
session: CoverBackfillSession,
|
||||||
cover_art_id: String,
|
item: psysonic_library::cover_backfill::CoverBackfillItem,
|
||||||
) {
|
) {
|
||||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let args = CoverCacheEnsureArgs {
|
let args = CoverCacheEnsureArgs {
|
||||||
server_index_key: session.server_index_key,
|
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,
|
tier: LIBRARY_COVER_CANONICAL_TIER,
|
||||||
rest_base_url: session.rest_base_url,
|
rest_base_url: session.rest_base_url,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
@@ -258,10 +260,11 @@ async fn run_full_pass(app: AppHandle, worker: Arc<CoverBackfillWorker>) {
|
|||||||
if !session_still_focused(&worker, &session).await {
|
if !session_still_focused(&worker, &session).await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let ids = batch.cover_ids.clone();
|
let items = batch.items.clone();
|
||||||
let mut paused_for_ui_priority = false;
|
let mut paused_for_ui_priority = false;
|
||||||
|
let batch_slots = Arc::new(Semaphore::new(LIBRARY_BACKFILL_PARALLEL));
|
||||||
let mut set = tokio::task::JoinSet::new();
|
let mut set = tokio::task::JoinSet::new();
|
||||||
for id in ids {
|
for item in items {
|
||||||
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
if worker.ui_priority_hold.load(Ordering::Relaxed) {
|
||||||
paused_for_ui_priority = true;
|
paused_for_ui_priority = true;
|
||||||
break;
|
break;
|
||||||
@@ -271,8 +274,12 @@ async fn run_full_pass(app: AppHandle, worker: Arc<CoverBackfillWorker>) {
|
|||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let worker_arc = worker.clone();
|
let worker_arc = worker.clone();
|
||||||
|
let batch_slots = batch_slots.clone();
|
||||||
set.spawn(async move {
|
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() {}
|
while set.join_next().await.is_some() {}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub use psysonic_core::cover_cache_layout;
|
||||||
|
|
||||||
pub const DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800];
|
pub const DERIVE_TIERS: [u32; 4] = [128, 256, 512, 800];
|
||||||
|
|
||||||
/// `server_index_key` — host (+ optional path), same bucket as library `server_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, cover_art_id: &str) -> PathBuf {
|
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_art_id)
|
cover_cache_layout::cover_dir(root, server_index_key, cache_kind, cache_entity_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
|
pub fn tier_path(dir: &Path, tier: u32) -> PathBuf {
|
||||||
|
|||||||
+164
-172
@@ -7,8 +7,11 @@ mod fetch;
|
|||||||
|
|
||||||
use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS};
|
use disk::{cover_dir, tier_exists, tier_path, DERIVE_TIERS};
|
||||||
use encode::write_webp_tier;
|
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 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::{
|
use psysonic_library::cover_backfill::{
|
||||||
clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress,
|
clear_cover_fetch_failures, collect_cover_backfill_batch, collect_cover_progress,
|
||||||
count_distinct_cover_ids, cover_fetch_recently_failed, LibraryCoverBackfillBatchDto,
|
count_distinct_cover_ids, cover_fetch_recently_failed, LibraryCoverBackfillBatchDto,
|
||||||
@@ -47,6 +50,10 @@ pub struct CoverCacheStatsDto {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CoverCacheEnsureArgs {
|
pub struct CoverCacheEnsureArgs {
|
||||||
pub server_index_key: String,
|
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 cover_art_id: String,
|
||||||
pub tier: u32,
|
pub tier: u32,
|
||||||
pub rest_base_url: String,
|
pub rest_base_url: String,
|
||||||
@@ -57,8 +64,16 @@ pub struct CoverCacheEnsureArgs {
|
|||||||
pub library_bulk: bool,
|
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;
|
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 struct CoverCacheState {
|
||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
@@ -67,6 +82,8 @@ pub struct CoverCacheState {
|
|||||||
pub high_watermark_pct: u64,
|
pub high_watermark_pct: u64,
|
||||||
pub resume_watermark_pct: u64,
|
pub resume_watermark_pct: u64,
|
||||||
pub http_sem: Arc<Semaphore>,
|
pub http_sem: Arc<Semaphore>,
|
||||||
|
pub cover_cpu_ui_sem: Arc<Semaphore>,
|
||||||
|
pub cover_cpu_backfill_sem: Arc<Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoverCacheState {
|
impl CoverCacheState {
|
||||||
@@ -84,9 +101,19 @@ impl CoverCacheState {
|
|||||||
high_watermark_pct: 90,
|
high_watermark_pct: 90,
|
||||||
resume_watermark_pct: 85,
|
resume_watermark_pct: 85,
|
||||||
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
|
http_sem: Arc::new(Semaphore::new(COVER_HTTP_CONCURRENCY)),
|
||||||
|
cover_cpu_ui_sem: Arc::new(Semaphore::new(COVER_CPU_UI_CONCURRENCY)),
|
||||||
|
cover_cpu_backfill_sem: Arc::new(Semaphore::new(COVER_CPU_BACKFILL_CONCURRENCY)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cpu_sem_for(&self, library_bulk: bool) -> Arc<Semaphore> {
|
||||||
|
if library_bulk {
|
||||||
|
self.cover_cpu_backfill_sem.clone()
|
||||||
|
} else {
|
||||||
|
self.cover_cpu_ui_sem.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pressure_from_bytes(&self, _bytes: u64) -> (String, bool) {
|
fn pressure_from_bytes(&self, _bytes: u64) -> (String, bool) {
|
||||||
("ok".into(), true)
|
("ok".into(), true)
|
||||||
}
|
}
|
||||||
@@ -103,7 +130,7 @@ impl CoverCacheState {
|
|||||||
http_sem_override: Option<Arc<Semaphore>>,
|
http_sem_override: Option<Arc<Semaphore>>,
|
||||||
) -> Result<CoverCacheEnsureResult, String> {
|
) -> Result<CoverCacheEnsureResult, String> {
|
||||||
let this = state.lock().await;
|
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) {
|
if let Some(path) = peek_tier_path(&dir, args.tier) {
|
||||||
return Ok(CoverCacheEnsureResult {
|
return Ok(CoverCacheEnsureResult {
|
||||||
hit: true,
|
hit: true,
|
||||||
@@ -124,6 +151,7 @@ impl CoverCacheState {
|
|||||||
let client = this.client.clone();
|
let client = this.client.clone();
|
||||||
let root = this.root.clone();
|
let root = this.root.clone();
|
||||||
let http_sem = http_sem_override.unwrap_or_else(|| this.http_sem.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);
|
drop(this);
|
||||||
|
|
||||||
if cover_fetch_recently_failed(&dir) {
|
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 requested = args.tier;
|
||||||
let quiet = args.library_bulk;
|
let quiet = args.library_bulk;
|
||||||
let tiers_now: Vec<u32> = if args.library_bulk {
|
let tiers_now: Vec<u32> = if args.library_bulk {
|
||||||
@@ -166,32 +180,72 @@ impl CoverCacheState {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut wrote_requested = false;
|
enum CoverSource {
|
||||||
if quiet {
|
Image(DynamicImage),
|
||||||
let dir_bg = dir.clone();
|
Bytes(Vec<u8>),
|
||||||
let img_bg = img.clone();
|
}
|
||||||
let max_tier = requested;
|
|
||||||
let wrote = tauri::async_runtime::spawn_blocking(move || -> Result<bool, String> {
|
let source = if let Some(img) = load_image_from_disk(&dir) {
|
||||||
disk::write_derived_webp_tiers(&dir_bg, &img_bg, max_tier)?;
|
CoverSource::Image(img)
|
||||||
Ok(tier_exists(&dir_bg, max_tier).is_some())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())??;
|
|
||||||
wrote_requested = wrote;
|
|
||||||
} else {
|
} else {
|
||||||
for tier in tiers_now {
|
match download_cover_payload(&dir, &client, &http_sem, args).await {
|
||||||
if tier_exists(&dir, tier).is_some() {
|
Ok(bytes) => CoverSource::Bytes(bytes),
|
||||||
if tier == requested {
|
Err(_) => {
|
||||||
wrote_requested = true;
|
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);
|
Ok((wrote_requested, fresh))
|
||||||
write_webp_tier(&img, tier, &path)?;
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
|
||||||
|
if !quiet {
|
||||||
|
for (tier, path) in fresh_tiers {
|
||||||
emit_tier_ready(app, args, tier, &path);
|
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);
|
let out_path = tier_path(&dir, requested);
|
||||||
if wrote_requested || out_path.is_file() {
|
if wrote_requested || out_path.is_file() {
|
||||||
if !quiet {
|
if !quiet {
|
||||||
spawn_derive_remaining_tiers(
|
if let Some(img) = load_image_from_disk(&dir) {
|
||||||
app.clone(),
|
spawn_derive_remaining_tiers(
|
||||||
state.clone(),
|
app.clone(),
|
||||||
root,
|
state.clone(),
|
||||||
args.clone(),
|
root,
|
||||||
img,
|
args.clone(),
|
||||||
requested,
|
img,
|
||||||
);
|
requested,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(CoverCacheEnsureResult {
|
return Ok(CoverCacheEnsureResult {
|
||||||
hit: true,
|
hit: true,
|
||||||
@@ -237,7 +293,8 @@ fn emit_tier_ready(app: &AppHandle, args: &CoverCacheEnsureArgs, tier: u32, path
|
|||||||
"cover:tier-ready",
|
"cover:tier-ready",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"serverIndexKey": args.server_index_key,
|
"serverIndexKey": args.server_index_key,
|
||||||
"coverArtId": args.cover_art_id,
|
"cacheKind": args.cache_kind,
|
||||||
|
"cacheEntityId": args.cache_entity_id,
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
"path": path.to_string_lossy(),
|
"path": path.to_string_lossy(),
|
||||||
}),
|
}),
|
||||||
@@ -263,15 +320,12 @@ fn load_image_from_disk(dir: &Path) -> Option<DynamicImage> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_cover_source(
|
async fn download_cover_payload(
|
||||||
dir: &Path,
|
_dir: &Path,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
http_sem: &Semaphore,
|
http_sem: &Semaphore,
|
||||||
args: &CoverCacheEnsureArgs,
|
args: &CoverCacheEnsureArgs,
|
||||||
) -> Result<DynamicImage, String> {
|
) -> Result<Vec<u8>, String> {
|
||||||
if let Some(img) = load_image_from_disk(dir) {
|
|
||||||
return Ok(img);
|
|
||||||
}
|
|
||||||
let _permit = http_sem
|
let _permit = http_sem
|
||||||
.acquire()
|
.acquire()
|
||||||
.await
|
.await
|
||||||
@@ -288,8 +342,7 @@ async fn load_cover_source(
|
|||||||
&args.cover_art_id,
|
&args.cover_art_id,
|
||||||
fetch_size,
|
fetch_size,
|
||||||
);
|
);
|
||||||
let bytes = fetch_cover_bytes(client, &url).await?;
|
fetch::fetch_cover_bytes(client, &url).await
|
||||||
decode_image_bytes(&bytes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_derive_remaining_tiers(
|
fn spawn_derive_remaining_tiers(
|
||||||
@@ -313,130 +366,64 @@ fn spawn_derive_remaining_tiers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let dir = {
|
let (dir, cover_cpu_sem) = {
|
||||||
let guard = state.lock().await;
|
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 {
|
for tier in tiers_bg {
|
||||||
if tier_exists(&dir, tier).is_some() {
|
if tier_exists(&dir, tier).is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path = tier_path(&dir, tier);
|
let path = tier_path(&dir, tier);
|
||||||
if write_webp_tier(&img, tier, &path).is_ok() {
|
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 {
|
/// Entity dirs with canonical `800.webp` under `album/` and `artist/` (segment layout).
|
||||||
if tier_exists(dir, 800).is_some() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for tier in DERIVE_TIERS {
|
|
||||||
if tier != 800 && tier_exists(dir, tier).is_some() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tier_exists(dir, 2000).is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_cached_in_server_dir(server_dir: &Path) -> i64 {
|
|
||||||
let Ok(entries) = std::fs::read_dir(server_dir) else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
entries
|
|
||||||
.flatten()
|
|
||||||
.filter(|e| e.path().is_dir())
|
|
||||||
.filter(|e| dir_has_any_cached_tier(&e.path()))
|
|
||||||
.count() as i64
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count cover ID dirs with any cached tier (UI progress — matches visible disk cache).
|
|
||||||
pub(crate) fn count_cached_cover_ids(root: &Path, server_index_key: &str) -> i64 {
|
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 {
|
if keyed > 0 {
|
||||||
return keyed;
|
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 {
|
let Ok(entries) = std::fs::read_dir(root) else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
entries
|
entries
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
e.path().is_dir()
|
e.path().is_dir() && e.file_name().to_string_lossy() != ".storage-layout"
|
||||||
&& 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()
|
.max()
|
||||||
.unwrap_or(0)
|
.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) {
|
pub(crate) fn dir_usage_for_server(root: &Path, server_index_key: &str) -> (u64, u64) {
|
||||||
let mut bytes = 0u64;
|
server_cover_disk_usage(&root.join(server_index_key))
|
||||||
let mut count = 0u64;
|
|
||||||
let server_dir = root.join(server_index_key);
|
|
||||||
let Ok(ids) = std::fs::read_dir(&server_dir) else {
|
|
||||||
return (0, 0);
|
|
||||||
};
|
|
||||||
for id_dir in ids.flatten() {
|
|
||||||
if !id_dir.path().is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if dir_has_any_cached_tier(&id_dir.path()) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
let Ok(files) = std::fs::read_dir(id_dir.path()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for f in files.flatten() {
|
|
||||||
if let Ok(meta) = f.metadata() {
|
|
||||||
bytes += meta.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(bytes, count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn dir_usage_at_root(root: &Path) -> (u64, u64) {
|
pub(crate) fn dir_usage_at_root(root: &Path) -> (u64, u64) {
|
||||||
let mut bytes = 0u64;
|
cover_root_disk_usage(root)
|
||||||
let mut count = 0u64;
|
|
||||||
let Ok(entries) = std::fs::read_dir(root) else {
|
|
||||||
return (0, 0);
|
|
||||||
};
|
|
||||||
for server in entries.flatten() {
|
|
||||||
if server.file_name().to_string_lossy() == ".storage-layout" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !server.path().is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(ids) = std::fs::read_dir(server.path()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for id_dir in ids.flatten() {
|
|
||||||
if !id_dir.path().is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if dir_has_any_cached_tier(&id_dir.path()) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
let Ok(files) = std::fs::read_dir(id_dir.path()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for f in files.flatten() {
|
|
||||||
if let Ok(meta) = f.metadata() {
|
|
||||||
bytes += meta.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(bytes, count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state(app: &AppHandle) -> Result<Arc<Mutex<CoverCacheState>>, String> {
|
fn state(app: &AppHandle) -> Result<Arc<Mutex<CoverCacheState>>, String> {
|
||||||
@@ -445,7 +432,7 @@ fn state(app: &AppHandle) -> Result<Arc<Mutex<CoverCacheState>>, String> {
|
|||||||
.ok_or_else(|| "cover cache not initialized".into())
|
.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).
|
/// 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> {
|
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")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CoverCachePeekItem {
|
pub struct CoverCachePeekItem {
|
||||||
pub server_index_key: String,
|
pub server_index_key: String,
|
||||||
pub cover_art_id: String,
|
pub cache_kind: String,
|
||||||
|
pub cache_entity_id: String,
|
||||||
pub tier: u32,
|
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).
|
/// 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();
|
let mut out = HashMap::new();
|
||||||
for item in items {
|
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);
|
let path = peek_tier_path(&dir, item.tier);
|
||||||
if let Some(p) = path {
|
if let Some(p) = path {
|
||||||
let key = format!(
|
out.insert(item.storage_key, p.to_string_lossy().into_owned());
|
||||||
"{}:cover:{}:{}",
|
|
||||||
item.server_index_key, item.cover_art_id, item.tier
|
|
||||||
);
|
|
||||||
out.insert(key, p.to_string_lossy().into_owned());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -625,22 +616,8 @@ fn peek_tier_path(dir: &Path, want: u32) -> Option<PathBuf> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn cover_cache_ensure(
|
pub async fn cover_cache_ensure(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
server_index_key: String,
|
args: CoverCacheEnsureArgs,
|
||||||
cover_art_id: String,
|
|
||||||
tier: u32,
|
|
||||||
rest_base_url: String,
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
) -> Result<CoverCacheEnsureResult, String> {
|
) -> Result<CoverCacheEnsureResult, String> {
|
||||||
let args = CoverCacheEnsureArgs {
|
|
||||||
server_index_key,
|
|
||||||
cover_art_id,
|
|
||||||
tier,
|
|
||||||
rest_base_url,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
library_bulk: false,
|
|
||||||
};
|
|
||||||
let st = state(&app)?;
|
let st = state(&app)?;
|
||||||
CoverCacheState::ensure_inner(&st, &app, &args, None).await
|
CoverCacheState::ensure_inner(&st, &app, &args, None).await
|
||||||
}
|
}
|
||||||
@@ -873,13 +850,28 @@ pub fn cover_revalidate_batch() -> Result<serde_json::Value, String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use image::{ImageBuffer, ImageFormat, Rgba};
|
||||||
|
|
||||||
|
use super::decode_image_bytes;
|
||||||
use super::disk::{cover_dir, tier_path};
|
use super::disk::{cover_dir, tier_path};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disk_layout_paths() {
|
fn disk_layout_paths() {
|
||||||
let root = std::path::Path::new("/tmp/cover-test");
|
let root = std::path::Path::new("/tmp/cover-test");
|
||||||
let dir = cover_dir(root, "srv", "al-1");
|
let dir = cover_dir(root, "srv", "album", "al-1");
|
||||||
assert_eq!(dir, root.join("srv").join("al-1"));
|
assert_eq!(dir, root.join("srv").join("album").join("al-1"));
|
||||||
assert_eq!(tier_path(&dir, 512), dir.join("512.webp"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,6 +734,7 @@ pub fn run() {
|
|||||||
psysonic_library::commands::library_migrate_server_index_keys,
|
psysonic_library::commands::library_migrate_server_index_keys,
|
||||||
psysonic_library::commands::library_delete_server_data,
|
psysonic_library::commands::library_delete_server_data,
|
||||||
psysonic_library::commands::library_analysis_backfill_batch,
|
psysonic_library::commands::library_analysis_backfill_batch,
|
||||||
|
psysonic_library::commands::library_resolve_cover_entry,
|
||||||
cover_cache::cover_cache_peek_batch,
|
cover_cache::cover_cache_peek_batch,
|
||||||
cover_cache::cover_cache_ensure,
|
cover_cache::cover_cache_ensure,
|
||||||
cover_cache::cover_cache_ensure_batch,
|
cover_cache::cover_cache_ensure_batch,
|
||||||
|
|||||||
@@ -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:;",
|
"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": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": ["$APPDATA/**", "$APPDATA/cover-cache/**"]
|
"scope": ["$APPDATA/**", "$APPLOCALDATA/**", "$DATA/**"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe('coverCacheEnsure', () => {
|
|||||||
|
|
||||||
const call = invokeMock.mock.calls.find(c => c[0] === 'cover_cache_ensure');
|
const call = invokeMock.mock.calls.find(c => c[0] === 'cover_cache_ensure');
|
||||||
expect(call).toBeTruthy();
|
expect(call).toBeTruthy();
|
||||||
const payload = call?.[1] as Record<string, unknown>;
|
const payload = (call?.[1] as { args: Record<string, unknown> }).args;
|
||||||
expect(payload.restBaseUrl).toBe('http://playback.example:5533');
|
expect(payload.restBaseUrl).toBe('http://playback.example:5533');
|
||||||
expect(payload.username).toBe('playback-user');
|
expect(payload.username).toBe('playback-user');
|
||||||
expect(payload.password).toBe('playback-pass');
|
expect(payload.password).toBe('playback-pass');
|
||||||
|
|||||||
+24
-14
@@ -1,6 +1,6 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { coverIndexKeyFromRef } from '../cover/storageKeys';
|
import { coverIndexKeyFromRef, coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
||||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { restBaseFromUrl } from './subsonicClient';
|
import { restBaseFromUrl } from './subsonicClient';
|
||||||
@@ -44,7 +44,9 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
|||||||
if (scope.kind === 'server') {
|
if (scope.kind === 'server') {
|
||||||
return {
|
return {
|
||||||
serverIndexKey: coverIndexKeyFromRef(ref),
|
serverIndexKey: coverIndexKeyFromRef(ref),
|
||||||
coverArtId: ref.coverArtId,
|
cacheKind: ref.cacheKind,
|
||||||
|
cacheEntityId: ref.cacheEntityId,
|
||||||
|
coverArtId: ref.fetchCoverArtId,
|
||||||
tier,
|
tier,
|
||||||
restBaseUrl: coverCacheRestHost(scope.url),
|
restBaseUrl: coverCacheRestHost(scope.url),
|
||||||
username: scope.username,
|
username: scope.username,
|
||||||
@@ -67,7 +69,9 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
|||||||
const baseUrl = server?.url || getBaseUrl();
|
const baseUrl = server?.url || getBaseUrl();
|
||||||
return {
|
return {
|
||||||
serverIndexKey: coverIndexKeyFromRef(ref),
|
serverIndexKey: coverIndexKeyFromRef(ref),
|
||||||
coverArtId: ref.coverArtId,
|
cacheKind: ref.cacheKind,
|
||||||
|
cacheEntityId: ref.cacheEntityId,
|
||||||
|
coverArtId: ref.fetchCoverArtId,
|
||||||
tier,
|
tier,
|
||||||
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
|
restBaseUrl: baseUrl ? coverCacheRestHost(baseUrl) : '',
|
||||||
username: server?.username ?? '',
|
username: server?.username ?? '',
|
||||||
@@ -77,22 +81,26 @@ function ensureArgsFromRef(ref: CoverArtRef, tier: CoverArtTier) {
|
|||||||
|
|
||||||
export type CoverCachePeekItem = {
|
export type CoverCachePeekItem = {
|
||||||
serverIndexKey: string;
|
serverIndexKey: string;
|
||||||
coverArtId: string;
|
cacheKind: 'album' | 'artist';
|
||||||
|
cacheEntityId: string;
|
||||||
tier: CoverArtTier;
|
tier: CoverArtTier;
|
||||||
|
storageKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Disk-only — no HTTP. Returns map storageKey → absolute .webp path. */
|
/** Disk-only — no HTTP. Returns map storageKey → absolute .webp path. */
|
||||||
export async function coverCachePeekBatch(
|
export async function coverCachePeekBatch(
|
||||||
items: CoverCachePeekItem[],
|
refs: CoverArtRef[],
|
||||||
|
tier: CoverArtTier,
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
if (items.length === 0) return {};
|
if (refs.length === 0) return {};
|
||||||
const raw = await invoke<Record<string, string>>('cover_cache_peek_batch', { items });
|
const items: CoverCachePeekItem[] = refs.map(ref => ({
|
||||||
const out: Record<string, string> = {};
|
serverIndexKey: coverIndexKeyFromRef(ref),
|
||||||
for (const item of items) {
|
cacheKind: ref.cacheKind,
|
||||||
const key = `${item.serverIndexKey}:cover:${item.coverArtId}:${item.tier}`;
|
cacheEntityId: ref.cacheEntityId,
|
||||||
if (raw[key]) out[key] = raw[key];
|
tier,
|
||||||
}
|
storageKey: coverStorageKeyFromRef(ref, tier),
|
||||||
return out;
|
}));
|
||||||
|
return invoke<Record<string, string>>('cover_cache_peek_batch', { items });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function coverCacheEnsure(
|
export async function coverCacheEnsure(
|
||||||
@@ -100,7 +108,9 @@ export async function coverCacheEnsure(
|
|||||||
tier: CoverArtTier,
|
tier: CoverArtTier,
|
||||||
_priority?: string,
|
_priority?: string,
|
||||||
): Promise<CoverCacheEnsureResult> {
|
): Promise<CoverCacheEnsureResult> {
|
||||||
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', ensureArgsFromRef(ref, tier));
|
return invoke<CoverCacheEnsureResult>('cover_cache_ensure', {
|
||||||
|
args: ensureArgsFromRef(ref, tier),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function coverCacheEnsureBatch(
|
export async function coverCacheEnsureBatch(
|
||||||
|
|||||||
@@ -130,12 +130,12 @@ describe('getClient', () => {
|
|||||||
describe('coverArtCacheKey', () => {
|
describe('coverArtCacheKey', () => {
|
||||||
it('uses host index key + entity id + tier as a stable cache key', () => {
|
it('uses host index key + entity id + tier as a stable cache key', () => {
|
||||||
setUpServer();
|
setUpServer();
|
||||||
expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:cover-1:256');
|
expect(coverArtCacheKey('cover-1')).toBe('music.example.com:cover:album:cover-1:256');
|
||||||
expect(coverArtCacheKey('cover-1', 200)).toBe('music.example.com:cover:cover-1:200');
|
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', () => {
|
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', () => {
|
it('does not embed the ephemeral salt or token — keys stay cacheable across calls', () => {
|
||||||
@@ -214,7 +214,7 @@ describe('buildCoverArtUrlForServer', () => {
|
|||||||
describe('coverArtCacheKeyForServer', () => {
|
describe('coverArtCacheKeyForServer', () => {
|
||||||
it('scopes cache keys by host index key when profile is known', () => {
|
it('scopes cache keys by host index key when profile is known', () => {
|
||||||
const profileId = setUpServer({ url: 'https://b.example' });
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import md5 from 'md5';
|
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 type { CoverArtTier } from '../cover/types';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
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. */
|
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
|
||||||
export function coverArtCacheKey(id: string, size = 256): string {
|
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. */
|
/** @deprecated Use `coverStorageKey` from `src/cover/storageKeys` — shim until migration. */
|
||||||
export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, size = 256): string {
|
export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, size = 256): string {
|
||||||
const server = findServerByIdOrIndexKey(serverIdOrKey);
|
const server = findServerByIdOrIndexKey(serverIdOrKey);
|
||||||
if (!server) return `${serverIdOrKey}:cover:${id}:${size}`;
|
if (!server) return `${serverIdOrKey}:cover:album:${id}:${size}`;
|
||||||
return coverStorageKey(
|
const entry = resolveAlbumCoverEntry(id, id);
|
||||||
|
const ref = coverEntryToRef(
|
||||||
|
entry ?? { cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id },
|
||||||
{
|
{
|
||||||
kind: 'server',
|
kind: 'server',
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
@@ -72,9 +77,8 @@ export function coverArtCacheKeyForServer(serverIdOrKey: string, id: string, siz
|
|||||||
username: server.username,
|
username: server.username,
|
||||||
password: server.password,
|
password: server.password,
|
||||||
},
|
},
|
||||||
id,
|
|
||||||
size as CoverArtTier,
|
|
||||||
);
|
);
|
||||||
|
return coverStorageKeyFromRef(ref, size as CoverArtTier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
|
/** @deprecated Use `buildCoverArtFetchUrl` from `src/cover/fetchUrl` — shim until migration. */
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { usePlayerStore } from '../store/playerStore';
|
|||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
|
import { coverStorageKeyFromRef } from '../cover/storageKeys';
|
||||||
import type { CoverPrefetchPriority } from '../cover/types';
|
import type { CoverPrefetchPriority } from '../cover/types';
|
||||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||||
import { coverStorageKey } from '../cover/storageKeys';
|
|
||||||
import { resolveCoverDisplayTier } from '../cover/tiers';
|
import { resolveCoverDisplayTier } from '../cover/tiers';
|
||||||
import { acquireUrl } from '../utils/imageCache/urlPool';
|
import { acquireUrl } from '../utils/imageCache/urlPool';
|
||||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||||
@@ -38,6 +39,8 @@ interface AlbumCardProps {
|
|||||||
observeScrollRootId?: string;
|
observeScrollRootId?: string;
|
||||||
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
|
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
|
||||||
ensurePriority?: CoverPrefetchPriority;
|
ensurePriority?: CoverPrefetchPriority;
|
||||||
|
/** Artist/detail grids: API `coverArt` is enough — skip per-card library_resolve IPC. */
|
||||||
|
libraryResolve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({
|
function AlbumCard({
|
||||||
@@ -53,6 +56,7 @@ function AlbumCard({
|
|||||||
observeScrollRootId,
|
observeScrollRootId,
|
||||||
ensurePriority,
|
ensurePriority,
|
||||||
linkQuery,
|
linkQuery,
|
||||||
|
libraryResolve = false,
|
||||||
}: AlbumCardProps) {
|
}: AlbumCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -65,11 +69,12 @@ function AlbumCard({
|
|||||||
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
|
||||||
});
|
});
|
||||||
const psyDrag = useDragDrop();
|
const psyDrag = useDragDrop();
|
||||||
|
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
|
||||||
const dragCoverKey = useMemo(() => {
|
const dragCoverKey = useMemo(() => {
|
||||||
if (!album.coverArt) return '';
|
if (!coverRef) return '';
|
||||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
|
||||||
return coverStorageKey({ kind: 'active' }, album.coverArt, tier);
|
return coverStorageKeyFromRef(coverRef, tier);
|
||||||
}, [album.coverArt, displayCssPx]);
|
}, [coverRef, displayCssPx]);
|
||||||
const isNewAlbum = isAlbumRecentlyAdded(album.created);
|
const isNewAlbum = isAlbumRecentlyAdded(album.created);
|
||||||
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
|
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
|
||||||
|
|
||||||
@@ -112,9 +117,9 @@ function AlbumCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="album-card-cover">
|
<div className="album-card-cover">
|
||||||
{!disableArtwork && album.coverArt ? (
|
{!disableArtwork && coverRef ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={album.coverArt}
|
coverRef={coverRef}
|
||||||
displayCssPx={displayCssPx}
|
displayCssPx={displayCssPx}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={`${album.name} Cover`}
|
alt={`${album.name} Cover`}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { coverArtRef } from '../cover/ref';
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { useCoverLightboxSrc } from '../cover/lightbox';
|
import { useCoverLightboxSrc } from '../cover/lightbox';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
@@ -121,10 +121,7 @@ export default function AlbumHeader({
|
|||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||||
|
|
||||||
const coverRef = useMemo(
|
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
|
||||||
() => (coverArtId ? coverArtRef(coverArtId) : null),
|
|
||||||
[coverArtId],
|
|
||||||
);
|
|
||||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
|
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
|
||||||
alt: `${info.name} Cover`,
|
alt: `${info.name} Cover`,
|
||||||
});
|
});
|
||||||
@@ -167,7 +164,7 @@ export default function AlbumHeader({
|
|||||||
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
<ChevronLeft size={16} /> {t('albumDetail.back')}
|
||||||
</button>
|
</button>
|
||||||
<div className="album-detail-hero">
|
<div className="album-detail-hero">
|
||||||
{coverArtId ? (
|
{coverRef ? (
|
||||||
<button
|
<button
|
||||||
className="album-detail-cover-btn"
|
className="album-detail-cover-btn"
|
||||||
onClick={openLightbox}
|
onClick={openLightbox}
|
||||||
@@ -176,7 +173,7 @@ export default function AlbumHeader({
|
|||||||
>
|
>
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
className="album-detail-cover"
|
className="album-detail-cover"
|
||||||
coverArtId={coverArtId}
|
coverRef={coverRef}
|
||||||
displayCssPx={400}
|
displayCssPx={400}
|
||||||
surface="sparse"
|
surface="sparse"
|
||||||
alt={`${info.name} Cover`}
|
alt={`${info.name} Cover`}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface Props {
|
|||||||
initialArtworkBudget?: number;
|
initialArtworkBudget?: number;
|
||||||
/** Appended to `/album/:id` links, e.g. `lossless=1`. */
|
/** Appended to `/album/:id` links, e.g. `lossless=1`. */
|
||||||
albumLinkQuery?: string;
|
albumLinkQuery?: string;
|
||||||
|
/** Search/browse rows: API `coverArt` only — no per-card library_resolve IPC. */
|
||||||
|
libraryResolve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlbumRow({
|
export default function AlbumRow({
|
||||||
@@ -41,6 +43,7 @@ export default function AlbumRow({
|
|||||||
windowArtworkByViewport = false,
|
windowArtworkByViewport = false,
|
||||||
initialArtworkBudget = 8,
|
initialArtworkBudget = 8,
|
||||||
albumLinkQuery,
|
albumLinkQuery,
|
||||||
|
libraryResolve = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const perfFlags = usePerfProbeFlags();
|
const perfFlags = usePerfProbeFlags();
|
||||||
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
|
||||||
@@ -171,6 +174,7 @@ export default function AlbumRow({
|
|||||||
album={a}
|
album={a}
|
||||||
showRating={showRating}
|
showRating={showRating}
|
||||||
linkQuery={albumLinkQuery}
|
linkQuery={albumLinkQuery}
|
||||||
|
libraryResolve={libraryResolve}
|
||||||
disableArtwork={
|
disableArtwork={
|
||||||
artworkDisabled ||
|
artworkDisabled ||
|
||||||
(windowArtworkByViewport && idx >= artworkBudget)
|
(windowArtworkByViewport && idx >= artworkBudget)
|
||||||
|
|||||||
@@ -4,27 +4,29 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { coverArtIdFromArtist } from '../cover/ids';
|
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artist: SubsonicArtist;
|
artist: SubsonicArtist;
|
||||||
/** Appended to `/artist/:id`, e.g. `lossless=1`. */
|
/** Appended to `/artist/:id`, e.g. `lossless=1`. */
|
||||||
linkQuery?: string;
|
linkQuery?: string;
|
||||||
|
/** Search/browse rows: API `coverArt` only — no per-card library_resolve IPC. */
|
||||||
|
libraryResolve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistCardLocal({ artist, linkQuery }: Props) {
|
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const coverId = coverArtIdFromArtist(artist);
|
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
|
||||||
const href = linkQuery ? `/artist/${artist.id}?${linkQuery}` : `/artist/${artist.id}`;
|
const href = linkQuery ? `/artist/${artist.id}?${linkQuery}` : `/artist/${artist.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="artist-card" onClick={() => navigate(href)}>
|
<div className="artist-card" onClick={() => navigate(href)}>
|
||||||
<div className="artist-card-avatar">
|
<div className="artist-card-avatar">
|
||||||
{artist.coverArt || artist.id ? (
|
{coverRef ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={coverId}
|
coverRef={coverRef}
|
||||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ interface Props {
|
|||||||
moreLink?: string;
|
moreLink?: string;
|
||||||
moreText?: string;
|
moreText?: string;
|
||||||
artistLinkQuery?: string;
|
artistLinkQuery?: string;
|
||||||
|
/** Search results: use API coverArt ids only. */
|
||||||
|
libraryResolve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistRow({ title, artists, moreLink, moreText, artistLinkQuery }: Props) {
|
export default function ArtistRow({
|
||||||
|
title, artists, moreLink, moreText, artistLinkQuery, libraryResolve = false,
|
||||||
|
}: Props) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showLeft, setShowLeft] = useState(false);
|
const [showLeft, setShowLeft] = useState(false);
|
||||||
@@ -55,7 +59,14 @@ export default function ArtistRow({ title, artists, moreLink, moreText, artistLi
|
|||||||
|
|
||||||
<div className="album-grid-wrapper">
|
<div className="album-grid-wrapper">
|
||||||
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
|
||||||
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} linkQuery={artistLinkQuery} />)}
|
{artists.map(a => (
|
||||||
|
<ArtistCardLocal
|
||||||
|
key={a.id}
|
||||||
|
artist={a}
|
||||||
|
linkQuery={artistLinkQuery}
|
||||||
|
libraryResolve={libraryResolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{moreLink && (
|
{moreLink && (
|
||||||
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
<div className="album-card-more" onClick={() => navigate(moreLink)}>
|
||||||
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
|
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Play, ListPlus, Music } from 'lucide-react';
|
import { Play, ListPlus, Music } from 'lucide-react';
|
||||||
import { coverArtRef } from '../cover/ref';
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
|
||||||
import { coverImgSrc } from '../cover/imgSrc';
|
import { coverImgSrc } from '../cover/imgSrc';
|
||||||
import { useCoverArt } from '../cover/useCoverArt';
|
import { useCoverArt } from '../cover/useCoverArt';
|
||||||
import { primeAlbumCoversForDisplay } from '../cover/warmDiskPeek';
|
import { primeAlbumCoversForDisplay } from '../cover/warmDiskPeek';
|
||||||
@@ -500,11 +500,10 @@ export default function BecauseYouLikeRail({
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [pool, activeServerId, disableArtwork, poolKey]);
|
}, [pool, activeServerId, disableArtwork, poolKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLibraryCoverPrefetch(
|
||||||
if (disableArtwork || recs.length === 0) return;
|
disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }],
|
||||||
const refs = recs.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : []));
|
[recs, disableArtwork],
|
||||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
);
|
||||||
}, [recs, disableArtwork]);
|
|
||||||
|
|
||||||
if (pool.length === 0) {
|
if (pool.length === 0) {
|
||||||
return <div ref={containerRef} />;
|
return <div ref={containerRef} />;
|
||||||
@@ -562,7 +561,8 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const coverHandle = useCoverArt(album.coverArt, BECAUSE_CARD_COVER_CSS_PX, {
|
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
|
||||||
|
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
|
||||||
surface: 'dense',
|
surface: 'dense',
|
||||||
ensurePriority: 'high',
|
ensurePriority: 'high',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,25 +103,28 @@ describe('FullscreenPlayer — regression §4.5 of v2 plan', () => {
|
|||||||
// The `false` arg is load-bearing — it avoids a double crossfade by
|
// The `false` arg is load-bearing — it avoids a double crossfade by
|
||||||
// routing through the cache-only path. Pin it.
|
// routing through the cache-only path. Pin it.
|
||||||
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
|
it('passes opt=false on the cover-art useCachedUrl call (no fetchUrl fallback)', () => {
|
||||||
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
|
usePlayerStore.setState({
|
||||||
|
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||||
|
});
|
||||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||||
|
|
||||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||||
// Find the call whose cacheKey targets the 500 px cover (`...:cover:art-1:500`).
|
|
||||||
const coverCall = calls.find(c => c[2] === false);
|
const coverCall = calls.find(c => c[2] === false);
|
||||||
expect(coverCall).toBeDefined();
|
expect(coverCall).toBeDefined();
|
||||||
expect(typeof coverCall?.[1]).toBe('string');
|
expect(typeof coverCall?.[1]).toBe('string');
|
||||||
expect(String(coverCall?.[1])).toContain('art-1');
|
expect(String(coverCall?.[1])).toContain('album-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
it('also issues a useCachedUrl call with the default behaviour for the small art box', () => {
|
||||||
usePlayerStore.setState({ currentTrack: makeTrack({ coverArt: 'art-1' }) });
|
usePlayerStore.setState({
|
||||||
|
currentTrack: makeTrack({ coverArt: 'art-1', albumId: 'album-1' }),
|
||||||
|
});
|
||||||
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
renderWithProviders(<FullscreenPlayer onClose={() => {}} />);
|
||||||
|
|
||||||
const calls = vi.mocked(useCachedUrl).mock.calls;
|
const calls = vi.mocked(useCachedUrl).mock.calls;
|
||||||
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
const defaultOptCalls = calls.filter(c => c[2] !== false);
|
||||||
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
expect(defaultOptCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(defaultOptCalls.some(c => String(c[1]).includes('art-1'))).toBe(true);
|
expect(defaultOptCalls.some(c => String(c[1]).includes('album-1'))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { queueSongStar } from '../store/pendingStarSync';
|
import { queueSongStar } from '../store/pendingStarSync';
|
||||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
import { playbackCoverArtForId } from '../utils/playback/playbackServer';
|
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
|
import { playbackCoverArtForAlbum } from '../utils/playback/playbackServer';
|
||||||
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
SkipBack, SkipForward,
|
SkipBack, SkipForward,
|
||||||
@@ -50,11 +51,12 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
|
|
||||||
const duration = currentTrack?.duration ?? 0;
|
const duration = currentTrack?.duration ?? 0;
|
||||||
|
|
||||||
// 300px for the small art box; 500px for the right-side portrait fallback.
|
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||||
const artCover = usePlaybackCoverArt(currentTrack?.coverArt, 300);
|
|
||||||
|
const artCover = usePlaybackCoverArt(playbackCoverRef, 300);
|
||||||
const artUrl = artCover.src;
|
const artUrl = artCover.src;
|
||||||
const artKey = artCover.cacheKey;
|
const artKey = artCover.cacheKey;
|
||||||
const portraitCover = usePlaybackCoverArt(currentTrack?.coverArt, 500);
|
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
||||||
const coverUrl = portraitCover.src;
|
const coverUrl = portraitCover.src;
|
||||||
const coverKey = portraitCover.cacheKey;
|
const coverKey = portraitCover.cacheKey;
|
||||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||||
@@ -79,14 +81,17 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
// around the current index keeps it warm).
|
// around the current index keeps it warm).
|
||||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||||
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
const nextTrack = useQueueTrackAt(queueIndex + 1);
|
||||||
const nextCoverArt = queueIndex >= 0 ? (nextTrack?.coverArt ?? null) : null;
|
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!nextCoverArt) return;
|
if (!nextTrack?.albumId || !nextTrack.coverArt) return;
|
||||||
const { src: url, cacheKey: key } = playbackCoverArtForId(nextCoverArt, 300);
|
const { src: url, cacheKey: key } = playbackCoverArtForAlbum(
|
||||||
|
nextTrack.albumId,
|
||||||
|
nextTrack.coverArt,
|
||||||
|
300,
|
||||||
|
);
|
||||||
getCachedBlob(url, key).catch(() => {});
|
getCachedBlob(url, key).catch(() => {});
|
||||||
}, [nextCoverArt, queueServerId, activeServerId]);
|
}, [nextTrack?.albumId, nextTrack?.coverArt, queueServerId, activeServerId]);
|
||||||
|
|
||||||
// Lyrics settings popover state
|
// Lyrics settings popover state
|
||||||
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
const [lyricsMenuOpen, setLyricsMenuOpen] = useState(false);
|
||||||
|
|||||||
+16
-6
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Play, ListPlus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { useCoverArt } from '../cover/useCoverArt';
|
import { useCoverArt } from '../cover/useCoverArt';
|
||||||
|
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { playAlbum } from '../utils/playback/playAlbum';
|
import { playAlbum } from '../utils/playback/playAlbum';
|
||||||
@@ -74,7 +75,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
|
||||||
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
|
||||||
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
|
||||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() =>
|
||||||
|
albumsProp?.length ? albumsProp : [],
|
||||||
|
);
|
||||||
const [activeIdx, setActiveIdx] = useState(0);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const windowHidden = useWindowVisibility();
|
const windowHidden = useWindowVisibility();
|
||||||
@@ -125,6 +128,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
heroScrollRootRef.current =
|
heroScrollRootRef.current =
|
||||||
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
|
scrollRoot ?? (node.closest('.app-shell-route-scroll__viewport') as HTMLElement | null);
|
||||||
updateHeroVisibility();
|
updateHeroVisibility();
|
||||||
|
// Layout may settle after first paint (hero mounts after albums hydrate from props).
|
||||||
|
const layoutRaf = window.requestAnimationFrame(() => updateHeroVisibility());
|
||||||
const root = heroScrollRootRef.current;
|
const root = heroScrollRootRef.current;
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (visibilityRafRef.current != null) return;
|
if (visibilityRafRef.current != null) return;
|
||||||
@@ -148,8 +153,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
window.cancelAnimationFrame(visibilityRafRef.current);
|
window.cancelAnimationFrame(visibilityRafRef.current);
|
||||||
visibilityRafRef.current = null;
|
visibilityRafRef.current = null;
|
||||||
}
|
}
|
||||||
|
window.cancelAnimationFrame(layoutRaf);
|
||||||
};
|
};
|
||||||
}, [updateHeroVisibility]);
|
}, [updateHeroVisibility, albums.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateBlurState = () => {
|
const updateBlurState = () => {
|
||||||
@@ -259,7 +265,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
});
|
});
|
||||||
}, [album?.id]);
|
}, [album?.id]);
|
||||||
|
|
||||||
const bgHandle = useCoverArt(album?.coverArt, HERO_BG_CSS_PX, {
|
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
|
||||||
|
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
|
||||||
surface: 'dense',
|
surface: 'dense',
|
||||||
ensurePriority: 'high',
|
ensurePriority: 'high',
|
||||||
});
|
});
|
||||||
@@ -267,7 +274,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
// Keep the last known good URL so HeroBg never receives '' during a cache-miss
|
||||||
// transition (which would cause the background to flash empty before fading in).
|
// transition (which would cause the background to flash empty before fading in).
|
||||||
const stableBgUrl = useRef('');
|
const stableBgUrl = useRef('');
|
||||||
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
|
const albumId = album?.id;
|
||||||
|
useEffect(() => {
|
||||||
|
if (bgHandle.src) stableBgUrl.current = bgHandle.src;
|
||||||
|
}, [bgHandle.src, albumId]);
|
||||||
|
|
||||||
if (!album) return <div className="hero-placeholder" />;
|
if (!album) return <div className="hero-placeholder" />;
|
||||||
|
|
||||||
@@ -285,9 +295,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
|
|
||||||
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
{/* key causes re-mount → animate-fade-in triggers on each album change */}
|
||||||
<div className="hero-content" key={album.id}>
|
<div className="hero-content" key={album.id}>
|
||||||
{album.coverArt && !isMobile && (
|
{heroCoverRef && !isMobile && (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={album.coverArt}
|
coverRef={heroCoverRef}
|
||||||
displayCssPx={HERO_FG_CSS_PX}
|
displayCssPx={HERO_FG_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
ensurePriority="high"
|
ensurePriority="high"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
suggestionKey,
|
suggestionKey,
|
||||||
} from '../utils/orbit';
|
} from '../utils/orbit';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
|
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
|
||||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||||
|
|
||||||
const HOST_APPROVAL_COVER_CSS_PX = 36;
|
const HOST_APPROVAL_COVER_CSS_PX = 36;
|
||||||
@@ -85,9 +86,10 @@ export default function HostApprovalQueue() {
|
|||||||
const key = suggestionKey(q);
|
const key = suggestionKey(q);
|
||||||
return (
|
return (
|
||||||
<div key={key} className="host-approval__item">
|
<div key={key} className="host-approval__item">
|
||||||
{song?.coverArt ? (
|
{song ? (
|
||||||
<CoverArtImage
|
<TrackCoverArtImage
|
||||||
coverArtId={song.coverArt}
|
song={song}
|
||||||
|
libraryResolve
|
||||||
displayCssPx={HOST_APPROVAL_COVER_CSS_PX}
|
displayCssPx={HOST_APPROVAL_COVER_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||||
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
||||||
|
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
|
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
|
||||||
import { coverArtIdFromArtist } from '../cover/ids';
|
import { albumCoverRefForSong } from '../cover/ref';
|
||||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
|
||||||
import { coverArtRef } from '../cover/ref';
|
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useShareSearch } from '../hooks/useShareSearch';
|
import { useShareSearch } from '../hooks/useShareSearch';
|
||||||
import ShareSearchResults from './search/ShareSearchResults';
|
import ShareSearchResults from './search/ShareSearchResults';
|
||||||
@@ -40,31 +41,54 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
|
|||||||
|
|
||||||
type LiveSearchSource = 'local' | 'network';
|
type LiveSearchSource = 'local' | 'network';
|
||||||
|
|
||||||
function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) {
|
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={coverArt}
|
albumId={albumId}
|
||||||
|
coverArt={coverArt}
|
||||||
|
libraryResolve={false}
|
||||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
className="search-result-thumb"
|
className="search-result-thumb"
|
||||||
alt=""
|
alt=""
|
||||||
|
ensurePriority="high"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
|
||||||
|
const coverRef = React.useMemo(
|
||||||
|
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
|
||||||
|
[song.id, song.albumId, song.coverArt, song.discNumber],
|
||||||
|
);
|
||||||
|
if (!coverRef) return null;
|
||||||
|
return (
|
||||||
|
<CoverArtImage
|
||||||
|
coverRef={coverRef}
|
||||||
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||||
|
surface="dense"
|
||||||
|
className="search-result-thumb"
|
||||||
|
alt=""
|
||||||
|
ensurePriority="high"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const coverId = coverArtIdFromArtist(artist);
|
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||||
useEffect(() => { setFailed(false); }, [coverId]);
|
|
||||||
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<ArtistCoverArtImage
|
||||||
coverArtId={coverId}
|
artistId={artist.id}
|
||||||
|
coverArt={artist.coverArt}
|
||||||
|
libraryResolve={false}
|
||||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
className="search-result-thumb"
|
className="search-result-thumb"
|
||||||
alt=""
|
alt=""
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
ensurePriority="high"
|
||||||
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
|
||||||
onError={() => setFailed(true)}
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
@@ -422,19 +446,6 @@ export default function LiveSearch() {
|
|||||||
!!share.shareMatch ||
|
!!share.shareMatch ||
|
||||||
(results && (results.artists.length || results.albums.length || results.songs.length));
|
(results && (results.artists.length || results.albums.length || results.songs.length));
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!results || share.shareMatch) return () => {};
|
|
||||||
const refs = [
|
|
||||||
...results.artists.map(a => coverArtRef(coverArtIdFromArtist(a))),
|
|
||||||
...results.albums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])),
|
|
||||||
...results.songs.flatMap(s => {
|
|
||||||
const id = s.coverArt ?? s.albumId;
|
|
||||||
return id ? [coverArtRef(id)] : [];
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
|
||||||
}, [results, share.shareMatch]);
|
|
||||||
|
|
||||||
// Flat list of all navigable items for keyboard nav
|
// Flat list of all navigable items for keyboard nav
|
||||||
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
|
const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [
|
||||||
{
|
{
|
||||||
@@ -674,7 +685,7 @@ export default function LiveSearch() {
|
|||||||
}}
|
}}
|
||||||
role="option" aria-selected={activeIndex === i}>
|
role="option" aria-selected={activeIndex === i}>
|
||||||
{a.coverArt ? (
|
{a.coverArt ? (
|
||||||
<LiveSearchAlbumThumb coverArt={a.coverArt} />
|
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
|
||||||
) : (
|
) : (
|
||||||
<div className="search-result-icon"><Disc3 size={14} /></div>
|
<div className="search-result-icon"><Disc3 size={14} /></div>
|
||||||
)}
|
)}
|
||||||
@@ -710,7 +721,7 @@ export default function LiveSearch() {
|
|||||||
}}
|
}}
|
||||||
role="option" aria-selected={activeIndex === i}>
|
role="option" aria-selected={activeIndex === i}>
|
||||||
{(s.coverArt ?? s.albumId) ? (
|
{(s.coverArt ?? s.albumId) ? (
|
||||||
<LiveSearchAlbumThumb coverArt={s.coverArt ?? s.albumId!} />
|
<LiveSearchSongThumb song={s} />
|
||||||
) : (
|
) : (
|
||||||
<div className="search-result-icon"><Music size={14} /></div>
|
<div className="search-result-icon"><Music size={14} /></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
|
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { emit } from '@tauri-apps/api/event';
|
import { emit } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -140,7 +141,8 @@ export default function MiniPlayer() {
|
|||||||
}, [queueOpen, state.queueIndex]);
|
}, [queueOpen, state.queueIndex]);
|
||||||
|
|
||||||
const { track, isPlaying } = state;
|
const { track, isPlaying } = state;
|
||||||
const { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(track?.coverArt, 300);
|
const miniCoverRef = usePlaybackTrackCoverRef(track ?? undefined);
|
||||||
|
const { src: miniCoverSrc, cacheKey: miniCoverKey } = usePlaybackCoverArt(miniCoverRef, 300);
|
||||||
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { queueSongStar } from '../store/pendingStarSync';
|
import { queueSongStar } from '../store/pendingStarSync';
|
||||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
||||||
|
import { usePlaybackTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import type { Track } from '../store/playerStoreTypes';
|
import type { Track } from '../store/playerStoreTypes';
|
||||||
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
|
||||||
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
|
||||||
@@ -223,7 +224,8 @@ export default function MobilePlayerView() {
|
|||||||
|
|
||||||
const duration = currentTrack?.duration ?? 0;
|
const duration = currentTrack?.duration ?? 0;
|
||||||
|
|
||||||
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(currentTrack?.coverArt, 800);
|
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||||
|
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(playbackCoverRef, 800);
|
||||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||||
|
|
||||||
// Dynamic background color extracted from cover art
|
// Dynamic background color extracted from cover art
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { search } from '../api/subsonicSearch';
|
import { search } from '../api/subsonicSearch';
|
||||||
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
import { songToTrack } from '../utils/playback/songToTrack';
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||||
@@ -9,10 +9,10 @@ import { usePlayerStore } from '../store/playerStore';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||||
|
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
||||||
|
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { coverArtIdFromArtist } from '../cover/ids';
|
import { albumCoverRefForSong } from '../cover/ref';
|
||||||
import { coverPrefetchRegister } from '../cover/prefetchRegistry';
|
|
||||||
import { coverArtRef } from '../cover/ref';
|
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useShareSearch } from '../hooks/useShareSearch';
|
import { useShareSearch } from '../hooks/useShareSearch';
|
||||||
import ShareSearchResults from './search/ShareSearchResults';
|
import ShareSearchResults from './search/ShareSearchResults';
|
||||||
@@ -38,10 +38,31 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
|
|||||||
/** Mobile search row thumb — larger than desktop live search (32px). */
|
/** Mobile search row thumb — larger than desktop live search (32px). */
|
||||||
const MOBILE_SEARCH_THUMB_CSS_PX = 80;
|
const MOBILE_SEARCH_THUMB_CSS_PX = 80;
|
||||||
|
|
||||||
|
function MobileSearchSongThumb({
|
||||||
|
song,
|
||||||
|
}: {
|
||||||
|
song: Pick<SearchResults['songs'][number], 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
|
||||||
|
}) {
|
||||||
|
const coverRef = useMemo(
|
||||||
|
() => (song.albumId?.trim() ? albumCoverRefForSong(song) : undefined),
|
||||||
|
[song.id, song.albumId, song.coverArt, song.discNumber],
|
||||||
|
);
|
||||||
|
if (!coverRef) return null;
|
||||||
|
return (
|
||||||
|
<CoverArtImage
|
||||||
|
coverRef={coverRef}
|
||||||
|
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||||
|
surface="dense"
|
||||||
|
className="mobile-search-thumb"
|
||||||
|
alt=""
|
||||||
|
ensurePriority="high"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const coverId = coverArtIdFromArtist(artist);
|
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||||
useEffect(() => { setFailed(false); }, [coverId]);
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
return (
|
return (
|
||||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||||
@@ -50,8 +71,10 @@ function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id'
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<ArtistCoverArtImage
|
||||||
coverArtId={coverId}
|
artistId={artist.id}
|
||||||
|
coverArt={artist.coverArt}
|
||||||
|
libraryResolve={false}
|
||||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
className="mobile-search-thumb mobile-search-thumb--artist-round"
|
className="mobile-search-thumb mobile-search-thumb--artist-round"
|
||||||
@@ -103,16 +126,6 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
doSearch(query);
|
doSearch(query);
|
||||||
}, [query, doSearch, share.shareMatch]);
|
}, [query, doSearch, share.shareMatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!results) return () => {};
|
|
||||||
const refs = [
|
|
||||||
...results.artists.map(a => coverArtRef(coverArtIdFromArtist(a))),
|
|
||||||
...results.albums.flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])),
|
|
||||||
...results.songs.flatMap(s => (s.coverArt ? [coverArtRef(s.coverArt)] : [])),
|
|
||||||
];
|
|
||||||
return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' });
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
const commit = (q: string) => {
|
const commit = (q: string) => {
|
||||||
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
|
||||||
};
|
};
|
||||||
@@ -287,12 +300,15 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
{results!.albums.map(a => (
|
{results!.albums.map(a => (
|
||||||
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
|
||||||
{a.coverArt ? (
|
{a.coverArt ? (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={a.coverArt}
|
albumId={a.id}
|
||||||
|
coverArt={a.coverArt}
|
||||||
|
libraryResolve={false}
|
||||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
className="mobile-search-thumb"
|
className="mobile-search-thumb"
|
||||||
alt=""
|
alt=""
|
||||||
|
ensurePriority="high"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mobile-search-avatar">
|
<div className="mobile-search-avatar">
|
||||||
@@ -314,14 +330,8 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
<div className="mobile-search-section-label">{t('search.songs')}</div>
|
<div className="mobile-search-section-label">{t('search.songs')}</div>
|
||||||
{results!.songs.map(s => (
|
{results!.songs.map(s => (
|
||||||
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
|
<button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
|
||||||
{s.coverArt ? (
|
{s.albumId && (s.coverArt ?? s.albumId) ? (
|
||||||
<CoverArtImage
|
<MobileSearchSongThumb song={s} />
|
||||||
coverArtId={s.coverArt}
|
|
||||||
displayCssPx={MOBILE_SEARCH_THUMB_CSS_PX}
|
|
||||||
surface="dense"
|
|
||||||
className="mobile-search-thumb"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mobile-search-avatar">
|
<div className="mobile-search-avatar">
|
||||||
<Music size={20} />
|
<Music size={20} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
|
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
|
||||||
import { getNowPlaying } from '../api/subsonicScrobble';
|
import { getNowPlaying } from '../api/subsonicScrobble';
|
||||||
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
|
import type { SubsonicNowPlaying } from '../api/subsonicTypes';
|
||||||
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
|
||||||
@@ -161,9 +162,14 @@ export default function NowPlayingDropdown() {
|
|||||||
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
|
style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', background: 'var(--bg-hover)', padding: '0.5rem', borderRadius: '8px', cursor: stream.albumId ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
<div style={{ width: '48px', height: '48px', flexShrink: 0, borderRadius: '6px', overflow: 'hidden', background: 'var(--bg-surface)' }}>
|
||||||
{stream.coverArt ? (
|
{stream.albumId && stream.coverArt ? (
|
||||||
<CoverArtImage
|
<TrackCoverArtImage
|
||||||
coverArtId={stream.coverArt}
|
song={{
|
||||||
|
id: stream.id,
|
||||||
|
albumId: stream.albumId,
|
||||||
|
coverArt: stream.coverArt,
|
||||||
|
discNumber: undefined,
|
||||||
|
}}
|
||||||
displayCssPx={50}
|
displayCssPx={50}
|
||||||
surface="sparse"
|
surface="sparse"
|
||||||
alt="Cover"
|
alt="Cover"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Radio, Clock } from 'lucide-react';
|
import { Radio, Clock } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { TrackCoverArtImage } from '../cover/TrackCoverArtImage';
|
||||||
import OrbitQueueHead from './OrbitQueueHead';
|
import OrbitQueueHead from './OrbitQueueHead';
|
||||||
|
|
||||||
const ORBIT_QUEUE_COVER_LG_CSS_PX = 54;
|
const ORBIT_QUEUE_COVER_LG_CSS_PX = 54;
|
||||||
@@ -78,8 +78,9 @@ export default function OrbitGuestQueue() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="orbit-guest-queue__current-body">
|
<div className="orbit-guest-queue__current-body">
|
||||||
{currentSong?.coverArt ? (
|
{currentSong?.coverArt ? (
|
||||||
<CoverArtImage
|
<TrackCoverArtImage
|
||||||
coverArtId={currentSong.coverArt}
|
song={currentSong}
|
||||||
|
libraryResolve
|
||||||
displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
|
displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -115,8 +116,9 @@ export default function OrbitGuestQueue() {
|
|||||||
return (
|
return (
|
||||||
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
|
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
|
||||||
{song?.coverArt ? (
|
{song?.coverArt ? (
|
||||||
<CoverArtImage
|
<TrackCoverArtImage
|
||||||
coverArtId={song.coverArt}
|
song={song}
|
||||||
|
libraryResolve
|
||||||
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -154,8 +156,9 @@ export default function OrbitGuestQueue() {
|
|||||||
className="orbit-guest-queue__item"
|
className="orbit-guest-queue__item"
|
||||||
>
|
>
|
||||||
{song?.coverArt ? (
|
{song?.coverArt ? (
|
||||||
<CoverArtImage
|
<TrackCoverArtImage
|
||||||
coverArtId={song.coverArt}
|
song={song}
|
||||||
|
libraryResolve
|
||||||
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { queueSongStar } from '../store/pendingStarSync';
|
import { queueSongStar } from '../store/pendingStarSync';
|
||||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
|
||||||
import { coverArtIdFromRadio } from '../cover/ids';
|
import { coverArtIdFromRadio } from '../cover/ids';
|
||||||
|
import { resolvePlaybackTrackCoverArtId } from '../cover/resolveCoverArtId';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@@ -142,15 +142,15 @@ export default function PlayerBar() {
|
|||||||
// Preview takes visual priority over the queued track in the player-bar info
|
// Preview takes visual priority over the queued track in the player-bar info
|
||||||
// cell, but only when not in radio mode (radio has its own meta layout).
|
// cell, but only when not in radio mode (radio has its own meta layout).
|
||||||
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
|
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
|
||||||
const displayCoverArt = showPreviewMeta ? previewingTrack!.coverArt : currentTrack?.coverArt;
|
|
||||||
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
|
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
|
||||||
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
|
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
|
||||||
const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0
|
const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0
|
||||||
? currentTrack.artists
|
? currentTrack.artists
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
usePlaybackCoverArt(showPreviewMeta ? undefined : displayCoverArt, 128);
|
const coverArtId = showPreviewMeta
|
||||||
const coverArtId = showPreviewMeta ? previewingTrack?.coverArt : displayCoverArt;
|
? previewingTrack?.coverArt
|
||||||
|
: resolvePlaybackTrackCoverArtId(currentTrack);
|
||||||
|
|
||||||
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setVolume(parseFloat(e.target.value));
|
setVolume(parseFloat(e.target.value));
|
||||||
@@ -197,7 +197,6 @@ export default function PlayerBar() {
|
|||||||
radioMeta={radioMeta}
|
radioMeta={radioMeta}
|
||||||
radioCoverArtId={radioCoverArtId}
|
radioCoverArtId={radioCoverArtId}
|
||||||
coverArtId={coverArtId}
|
coverArtId={coverArtId}
|
||||||
displayCoverArt={displayCoverArt}
|
|
||||||
displayTitle={displayTitle}
|
displayTitle={displayTitle}
|
||||||
displayArtist={displayArtist}
|
displayArtist={displayArtist}
|
||||||
displayArtistRefs={displayArtistRefs}
|
displayArtistRefs={displayArtistRefs}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { QueueToolbar } from './queuePanel/QueueToolbar';
|
|||||||
import { QueueList } from './queuePanel/QueueList';
|
import { QueueList } from './queuePanel/QueueList';
|
||||||
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
import { QueueTabBar } from './queuePanel/QueueTabBar';
|
||||||
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
import { useQueueAutoScroll } from '../hooks/useQueueAutoScroll';
|
||||||
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
|
|
||||||
|
|
||||||
export default function QueuePanel() {
|
export default function QueuePanel() {
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
const orbitRole = useOrbitStore(s => s.role);
|
||||||
@@ -79,7 +78,6 @@ function QueuePanelHostOrSolo() {
|
|||||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
const { src: currentCoverSrc } = usePlaybackCoverArt(currentTrack?.coverArt, 128);
|
|
||||||
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
|
||||||
const playTrack = usePlayerStore(s => s.playTrack);
|
const playTrack = usePlayerStore(s => s.playTrack);
|
||||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||||
@@ -255,7 +253,6 @@ function QueuePanelHostOrSolo() {
|
|||||||
{currentTrack && !isNowPlayingCollapsed && (
|
{currentTrack && !isNowPlayingCollapsed && (
|
||||||
<QueueCurrentTrack
|
<QueueCurrentTrack
|
||||||
currentTrack={currentTrack}
|
currentTrack={currentTrack}
|
||||||
currentCoverSrc={currentCoverSrc}
|
|
||||||
userRatingOverrides={userRatingOverrides}
|
userRatingOverrides={userRatingOverrides}
|
||||||
orbitAttributionLabel={orbitAttributionLabel}
|
orbitAttributionLabel={orbitAttributionLabel}
|
||||||
navigate={navigatePlaybackLibrary}
|
navigate={navigatePlaybackLibrary}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { useCoverArt } from '../cover/useCoverArt';
|
import { useCoverArt } from '../cover/useCoverArt';
|
||||||
|
import { useTrackCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes';
|
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../cover/layoutSizes';
|
||||||
import { enqueueAndPlay } from '../utils/playback/playSong';
|
import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
@@ -32,8 +33,8 @@ function SongCard({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const coverArtId = song.coverArt ?? song.albumId;
|
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
|
||||||
const coverHandle = useCoverArt(coverArtId, layoutPx, {
|
const coverHandle = useCoverArt(coverRef, layoutPx, {
|
||||||
surface: 'dense',
|
surface: 'dense',
|
||||||
ensurePriority: 'middle',
|
ensurePriority: 'middle',
|
||||||
});
|
});
|
||||||
@@ -93,9 +94,9 @@ function SongCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="song-card-cover">
|
<div className="song-card-cover">
|
||||||
{!disableArtwork && coverArtId ? (
|
{!disableArtwork && coverRef ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={coverArtId}
|
coverRef={coverRef}
|
||||||
displayCssPx={layoutPx}
|
displayCssPx={layoutPx}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={`${song.album} Cover`}
|
alt={`${song.album} Cover`}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { useOfflineJobStore } from '../../store/offlineJobStore';
|
|||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { ArtistHeroCover } from '../../cover/artistHero';
|
import { ArtistHeroCover } from '../../cover/artistHero';
|
||||||
import { coverArtRef } from '../../cover/ref';
|
|
||||||
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||||
|
import type { CoverArtRef } from '../../cover/types';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
import LastfmIcon from '../LastfmIcon';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ interface Props {
|
|||||||
openedLink: string | null;
|
openedLink: string | null;
|
||||||
openLink: (url: string, key: string) => void;
|
openLink: (url: string, key: string) => void;
|
||||||
coverId: string;
|
coverId: string;
|
||||||
|
coverRef: CoverArtRef | null;
|
||||||
coverRevision: number;
|
coverRevision: number;
|
||||||
headerCoverFailed: boolean;
|
headerCoverFailed: boolean;
|
||||||
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
setHeaderCoverFailed: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -46,7 +47,7 @@ export default function ArtistDetailHero({
|
|||||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||||
openedLink, openLink,
|
openedLink, openLink,
|
||||||
coverId, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -58,10 +59,6 @@ export default function ArtistDetailHero({
|
|||||||
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
|
||||||
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
|
||||||
|
|
||||||
const coverRef = useMemo(
|
|
||||||
() => (coverId ? coverArtRef(coverId) : null),
|
|
||||||
[coverId],
|
|
||||||
);
|
|
||||||
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: artist.name });
|
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, { alt: artist.name });
|
||||||
|
|
||||||
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
|
import { resolveArtistPageSongCoverArtId } from '../../cover/resolveCoverArtId';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { usePreviewStore } from '../../store/previewStore';
|
||||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
import ArtistSuggestionTrackCover from './ArtistSuggestionTrackCover';
|
import ArtistTopTrackCover from './ArtistTopTrackCover';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
topSongs: SubsonicSong[];
|
topSongs: SubsonicSong[];
|
||||||
|
albums: SubsonicAlbum[];
|
||||||
marginTop: string;
|
marginTop: string;
|
||||||
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
|
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
|
||||||
losslessOnly?: boolean;
|
losslessOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtistDetailTopTracks({
|
export default function ArtistDetailTopTracks({
|
||||||
topSongs, marginTop, playTopSongWithContinuation, losslessOnly = false,
|
topSongs, albums, marginTop, playTopSongWithContinuation, losslessOnly = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
@@ -92,9 +94,19 @@ export default function ArtistDetailTopTracks({
|
|||||||
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
|
||||||
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
|
||||||
</button>
|
</button>
|
||||||
{song.coverArt && (
|
{(() => {
|
||||||
<ArtistSuggestionTrackCover coverArt={song.coverArt} album={song.album} />
|
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 ? (
|
||||||
|
<ArtistTopTrackCover
|
||||||
|
albumId={song.albumId}
|
||||||
|
coverArt={coverId}
|
||||||
|
album={song.album}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
<div className="track-title">{song.title}</div>
|
<div className="track-title">{song.title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<CoverArtImage
|
|
||||||
coverArtId={coverArt}
|
|
||||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
|
||||||
surface="dense"
|
|
||||||
alt={album}
|
|
||||||
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
|
||||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<AlbumCoverArtImage
|
||||||
|
albumId={albumId}
|
||||||
|
coverArt={coverArt}
|
||||||
|
displayCssPx={COVER_ARTIST_TOP_TRACK_CSS_PX}
|
||||||
|
surface="sparse"
|
||||||
|
ensurePriority="high"
|
||||||
|
alt={album}
|
||||||
|
style={{ width: '32px', height: '32px', borderRadius: '4px', objectFit: 'cover', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
import type { SubsonicArtist } from '../../api/subsonicTypes';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
||||||
import { coverArtIdFromArtist } from '../../cover/ids';
|
|
||||||
import {
|
import {
|
||||||
COVER_DENSE_ARTIST_LIST_CSS_PX,
|
COVER_DENSE_ARTIST_LIST_CSS_PX,
|
||||||
COVER_DENSE_GRID_MIN_CELL_CSS_PX,
|
COVER_DENSE_GRID_MIN_CELL_CSS_PX,
|
||||||
@@ -21,12 +20,12 @@ interface AvatarProps {
|
|||||||
*/
|
*/
|
||||||
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
||||||
const color = nameColor(artist.name);
|
const color = nameColor(artist.name);
|
||||||
const coverId = coverArtIdFromArtist(artist);
|
|
||||||
if (showImages && (artist.coverArt || artist.id)) {
|
if (showImages && (artist.coverArt || artist.id)) {
|
||||||
return (
|
return (
|
||||||
<div className="artist-card-avatar">
|
<div className="artist-card-avatar">
|
||||||
<CoverArtImage
|
<ArtistCoverArtImage
|
||||||
coverArtId={coverId}
|
artistId={artist.id}
|
||||||
|
coverArt={artist.coverArt}
|
||||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
@@ -48,12 +47,12 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
|
|||||||
*/
|
*/
|
||||||
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
|
export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
|
||||||
const color = nameColor(artist.name);
|
const color = nameColor(artist.name);
|
||||||
const coverId = coverArtIdFromArtist(artist);
|
|
||||||
if (showImages && (artist.coverArt || artist.id)) {
|
if (showImages && (artist.coverArt || artist.id)) {
|
||||||
return (
|
return (
|
||||||
<div className="artist-avatar">
|
<div className="artist-avatar">
|
||||||
<CoverArtImage
|
<ArtistCoverArtImage
|
||||||
coverArtId={coverId}
|
artistId={artist.id}
|
||||||
|
coverArt={artist.coverArt}
|
||||||
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
|
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
|
||||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import { albumCoverRef } from '../../cover/ref';
|
||||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }:
|
|||||||
<div className="album-card-cover">
|
<div className="album-card-cover">
|
||||||
{s.coverArt ? (
|
{s.coverArt ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={coverArtIdFromRadio(s.id)}
|
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
|
||||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={s.name}
|
alt={s.name}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Users } from 'lucide-react';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import { ArtistCoverArtImage } from '../../cover/ArtistCoverArtImage';
|
||||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||||
|
|
||||||
export interface TopFavoriteArtist {
|
export interface TopFavoriteArtist {
|
||||||
@@ -92,8 +93,9 @@ function TopFavoriteArtistCard({ artist, isSelected, onClick, songCountLabel }:
|
|||||||
>
|
>
|
||||||
<div className="artist-card-avatar">
|
<div className="artist-card-avatar">
|
||||||
{coverId ? (
|
{coverId ? (
|
||||||
<CoverArtImage
|
<ArtistCoverArtImage
|
||||||
coverArtId={coverId}
|
artistId={artist.id}
|
||||||
|
coverArt={artist.coverArtId}
|
||||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={artist.name}
|
alt={artist.name}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { open } from '@tauri-apps/plugin-shell';
|
|||||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||||
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import { albumCoverRef } from '../../cover/ref';
|
||||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||||
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export default function RadioCard({
|
|||||||
<div className="album-card-cover">
|
<div className="album-card-cover">
|
||||||
{s.coverArt ? (
|
{s.coverArt ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={coverArtIdFromRadio(s.id)}
|
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
|
||||||
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={s.name}
|
alt={s.name}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Camera, Cast, Loader2, X } from 'lucide-react';
|
import { Camera, Cast, Loader2, X } from 'lucide-react';
|
||||||
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
import type { InternetRadioStation } from '../../api/subsonicTypes';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import { albumCoverRef } from '../../cover/ref';
|
||||||
import { coverArtIdFromRadio } from '../../cover/ids';
|
import { coverArtIdFromRadio } from '../../cover/ids';
|
||||||
|
|
||||||
interface RadioEditModalProps {
|
interface RadioEditModalProps {
|
||||||
@@ -92,7 +93,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
|
|||||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
) : !coverRemoved && station?.coverArt ? (
|
) : !coverRemoved && station?.coverArt ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={coverArtIdFromRadio(station.id)}
|
coverRef={albumCoverRef(coverArtIdFromRadio(station.id), coverArtIdFromRadio(station.id))}
|
||||||
displayCssPx={140}
|
displayCssPx={140}
|
||||||
surface="sparse"
|
surface="sparse"
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { memo, useEffect, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Disc3, ExternalLink, Music } from 'lucide-react';
|
import { Disc3, ExternalLink, Music } from 'lucide-react';
|
||||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||||
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../../cover/layoutSizes';
|
import { COVER_DENSE_RAIL_CELL_CSS_PX } from '../../cover/layoutSizes';
|
||||||
|
|
||||||
interface DiscographyCardProps {
|
interface DiscographyCardProps {
|
||||||
@@ -52,8 +52,9 @@ const DiscographyCard = memo(function DiscographyCard({ artistId, albums, curren
|
|||||||
<div className="np-dash-disc-cover">
|
<div className="np-dash-disc-cover">
|
||||||
{a.coverArt
|
{a.coverArt
|
||||||
? (
|
? (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={a.coverArt}
|
albumId={a.id}
|
||||||
|
coverArt={a.coverArt}
|
||||||
displayCssPx={COVER_DENSE_RAIL_CELL_CSS_PX}
|
displayCssPx={COVER_DENSE_RAIL_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={a.name}
|
alt={a.name}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
|
import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
|
||||||
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import type { CoverArtRef } from '../../cover/types';
|
||||||
import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
|
import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
|
||||||
import LastfmIcon from '../LastfmIcon';
|
import LastfmIcon from '../LastfmIcon';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
@@ -19,7 +21,7 @@ interface HeroProps {
|
|||||||
lfmLoved: boolean;
|
lfmLoved: boolean;
|
||||||
lfmLoveEnabled: boolean;
|
lfmLoveEnabled: boolean;
|
||||||
activeLyricsTab: boolean;
|
activeLyricsTab: boolean;
|
||||||
coverUrl: string;
|
coverRef?: CoverArtRef;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onToggleStar: () => void;
|
onToggleStar: () => void;
|
||||||
onToggleLfmLove: () => 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 { t } = useTranslation();
|
||||||
const rating = userRatingOverride ?? track.userRating;
|
const rating = userRatingOverride ?? track.userRating;
|
||||||
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
|
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 (
|
return (
|
||||||
<div className="np-dash-hero">
|
<div className="np-dash-hero">
|
||||||
<div className="np-dash-hero-cover">
|
<div className="np-dash-hero-cover">
|
||||||
{coverUrl
|
{coverRef ? (
|
||||||
? <img src={coverUrl} alt="" className="np-cover" />
|
<CoverArtImage
|
||||||
: <div className="np-cover np-cover-fallback"><Music size={64} /></div>}
|
className="np-cover"
|
||||||
|
coverRef={coverRef}
|
||||||
|
displayCssPx={280}
|
||||||
|
surface="sparse"
|
||||||
|
ensurePriority="high"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="np-cover np-cover-fallback"><Music size={64} /></div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="np-dash-hero-body">
|
<div className="np-dash-hero-body">
|
||||||
<div className="np-dash-hero-title">{track.title}</div>
|
<div className="np-dash-hero-title">{track.title}</div>
|
||||||
@@ -160,4 +171,4 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Hero;
|
export default Hero;
|
||||||
@@ -6,6 +6,9 @@ import type { PlayerState, Track } from '../../store/playerStoreTypes';
|
|||||||
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
|
||||||
import type { PreviewingTrack } from '../../store/previewStore';
|
import type { PreviewingTrack } from '../../store/previewStore';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
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 LastfmIcon from '../LastfmIcon';
|
||||||
import MarqueeText from '../MarqueeText';
|
import MarqueeText from '../MarqueeText';
|
||||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
@@ -24,7 +27,6 @@ interface Props {
|
|||||||
radioMeta: RadioMetadata;
|
radioMeta: RadioMetadata;
|
||||||
radioCoverArtId?: string;
|
radioCoverArtId?: string;
|
||||||
coverArtId?: string;
|
coverArtId?: string;
|
||||||
displayCoverArt: string | undefined;
|
|
||||||
displayTitle: string;
|
displayTitle: string;
|
||||||
displayArtist: string;
|
displayArtist: string;
|
||||||
/** When set (OpenSubsonic `artists` on the playing track), render split links like album track rows. */
|
/** When set (OpenSubsonic `artists` on the playing track), render split links like album track rows. */
|
||||||
@@ -45,13 +47,20 @@ interface Props {
|
|||||||
|
|
||||||
export function PlayerTrackInfo({
|
export function PlayerTrackInfo({
|
||||||
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
|
currentTrack, currentRadio, isRadio, radioMeta, radioCoverArtId,
|
||||||
coverArtId, displayCoverArt, displayTitle, displayArtist, displayArtistRefs,
|
coverArtId, displayTitle, displayArtist, displayArtistRefs,
|
||||||
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
showPreviewMeta, previewingTrack, isStarred, toggleStar,
|
||||||
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
lastfmSessionKey, lastfmLoved, toggleLastfmLove,
|
||||||
userRatingOverrides, toggleFullscreen,
|
userRatingOverrides, toggleFullscreen,
|
||||||
navigate, openContextMenu, t,
|
navigate, openContextMenu, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
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 layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||||
@@ -64,10 +73,10 @@ export function PlayerTrackInfo({
|
|||||||
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
|
data-tooltip={!isRadio && !showPreviewMeta && currentTrack ? t('player.openFullscreen') : undefined}
|
||||||
>
|
>
|
||||||
{isRadio ? (
|
{isRadio ? (
|
||||||
radioCoverArtId ? (
|
radioCoverArtId && currentRadio ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
className="player-album-art"
|
className="player-album-art"
|
||||||
coverArtId={radioCoverArtId}
|
coverRef={albumCoverRef(radioCoverArtId, radioCoverArtId)}
|
||||||
displayCssPx={128}
|
displayCssPx={128}
|
||||||
surface="sparse"
|
surface="sparse"
|
||||||
alt={currentRadio?.name ?? ''}
|
alt={currentRadio?.name ?? ''}
|
||||||
@@ -77,13 +86,13 @@ export function PlayerTrackInfo({
|
|||||||
<Cast size={20} />
|
<Cast size={20} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : coverArtId ? (
|
) : !isRadio && (showPreviewMeta ? coverArtId : playbackCoverRef) ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
className="player-album-art"
|
className="player-album-art"
|
||||||
coverArtId={coverArtId}
|
coverRef={showPreviewMeta ? previewCoverRef! : playbackCoverRef!}
|
||||||
displayCssPx={128}
|
displayCssPx={128}
|
||||||
surface="sparse"
|
surface="sparse"
|
||||||
serverScope={showPreviewMeta ? { kind: 'active' } : { kind: 'playback' }}
|
ensurePriority="high"
|
||||||
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
|
alt={showPreviewMeta ? `${previewingTrack!.title} Cover` : `${currentTrack?.album ?? ''} Cover`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Camera, Loader2, X } from 'lucide-react';
|
|||||||
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
|
||||||
import type { CoverArtId } from '../../cover/types';
|
import type { CoverArtId } from '../../cover/types';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||||
|
|
||||||
@@ -79,8 +80,9 @@ export default function PlaylistEditModal({
|
|||||||
{coverPreview ? (
|
{coverPreview ? (
|
||||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
) : !coverRemoved && customCoverId ? (
|
) : !coverRemoved && customCoverId ? (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={customCoverId}
|
albumId={customCoverId}
|
||||||
|
coverArt={customCoverId}
|
||||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../utils/componentHelpers/playlistDetailHelpers';
|
} from '../../utils/componentHelpers/playlistDetailHelpers';
|
||||||
import type { CoverArtId } from '../../cover/types';
|
import type { CoverArtId } from '../../cover/types';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||||
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
import { PLAYLIST_MAIN_COVER_CSS_PX } from '../../hooks/usePlaylistCovers';
|
||||||
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
import { PlaylistSmartCoverCell } from '../playlists/PlaylistCoverImages';
|
||||||
|
|
||||||
@@ -87,8 +88,9 @@ export default function PlaylistHero({
|
|||||||
onClick={() => setEditingMeta(true)}
|
onClick={() => setEditingMeta(true)}
|
||||||
>
|
>
|
||||||
{customCoverId ? (
|
{customCoverId ? (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={customCoverId}
|
albumId={customCoverId}
|
||||||
|
coverArt={customCoverId}
|
||||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import type { SubsonicSong } from '../../api/subsonicTypes';
|
|||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { AlbumCoverArtImage } from '../../cover/AlbumCoverArtImage';
|
||||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||||
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
import { AddToPlaylistSubmenu } from '../ContextMenu';
|
||||||
|
|
||||||
function PlaylistSearchResultThumb({ coverArt }: { coverArt: string }) {
|
function PlaylistSearchResultThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={coverArt}
|
albumId={albumId}
|
||||||
|
coverArt={coverArt}
|
||||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -133,7 +134,7 @@ export default function PlaylistSongSearchPanel({
|
|||||||
return next;
|
return next;
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<PlaylistSearchResultThumb coverArt={song.coverArt ?? ''} />
|
<PlaylistSearchResultThumb albumId={song.albumId} coverArt={song.coverArt ?? ''} />
|
||||||
<div className="playlist-search-info">
|
<div className="playlist-search-info">
|
||||||
<span className="playlist-search-title">{song.title}</span>
|
<span className="playlist-search-title">{song.title}</span>
|
||||||
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
<span className="playlist-search-artist">{song.artist} · <span className="playlist-search-album">{song.album}</span></span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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. */
|
/** 2×2 collage cell — half of clamp(120px, 15vw, 200px) playlist hero grid. */
|
||||||
const PLAYLIST_QUAD_CELL_CSS_PX = 100;
|
const PLAYLIST_QUAD_CELL_CSS_PX = 100;
|
||||||
@@ -8,8 +8,9 @@ const PLAYLIST_MAIN_COVER_CSS_PX = 200;
|
|||||||
|
|
||||||
export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
|
export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={coverId}
|
albumId={coverId}
|
||||||
|
coverArt={coverId}
|
||||||
displayCssPx={PLAYLIST_QUAD_CELL_CSS_PX}
|
displayCssPx={PLAYLIST_QUAD_CELL_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
className="playlist-cover-cell"
|
className="playlist-cover-cell"
|
||||||
@@ -20,8 +21,9 @@ export function PlaylistSmartCoverCell({ coverId }: { coverId: string }) {
|
|||||||
|
|
||||||
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
|
export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt: string }) {
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={coverArt}
|
albumId={coverArt}
|
||||||
|
coverArt={coverArt}
|
||||||
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
alt={alt}
|
alt={alt}
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import { formatQueueBpmTech, formatQueueMoodLabels } from '../../utils/library/t
|
|||||||
import { useQueueTrackEnrichment } from '../../hooks/useQueueTrackEnrichment';
|
import { useQueueTrackEnrichment } from '../../hooks/useQueueTrackEnrichment';
|
||||||
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
|
import { QueueLufsTargetMenu } from './QueueLufsTargetMenu';
|
||||||
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
|
||||||
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
|
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentTrack: Track;
|
currentTrack: Track;
|
||||||
currentCoverSrc: string;
|
|
||||||
userRatingOverrides: Record<string, number>;
|
userRatingOverrides: Record<string, number>;
|
||||||
orbitAttributionLabel: (trackId: string) => string | null;
|
orbitAttributionLabel: (trackId: string) => string | null;
|
||||||
navigate: (to: string) => void | Promise<void>;
|
navigate: (to: string) => void | Promise<void>;
|
||||||
@@ -42,7 +43,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QueueCurrentTrack({
|
export function QueueCurrentTrack({
|
||||||
currentTrack, currentCoverSrc, userRatingOverrides, orbitAttributionLabel,
|
currentTrack, userRatingOverrides, orbitAttributionLabel,
|
||||||
navigate, playbackSource, normalizationEngine, normalizationEngineLive,
|
navigate, playbackSource, normalizationEngine, normalizationEngineLive,
|
||||||
normalizationNowDb, normalizationTargetLufs, authLoudnessTargetLufs,
|
normalizationNowDb, normalizationTargetLufs, authLoudnessTargetLufs,
|
||||||
loudnessPreAnalysisAttenuationDb, expandReplayGain, setExpandReplayGain,
|
loudnessPreAnalysisAttenuationDb, expandReplayGain, setExpandReplayGain,
|
||||||
@@ -50,6 +51,7 @@ export function QueueCurrentTrack({
|
|||||||
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||||
|
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
||||||
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
||||||
const bpmTech = formatQueueBpmTech(enrichment, t);
|
const bpmTech = formatQueueBpmTech(enrichment, t);
|
||||||
const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t);
|
const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t);
|
||||||
@@ -201,8 +203,15 @@ export function QueueCurrentTrack({
|
|||||||
})()}
|
})()}
|
||||||
<div className="queue-current-track-body">
|
<div className="queue-current-track-body">
|
||||||
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
||||||
{currentTrack.coverArt && currentCoverSrc ? (
|
{coverRef ? (
|
||||||
<img src={currentCoverSrc} alt="" loading="eager" />
|
<CoverArtImage
|
||||||
|
coverRef={coverRef}
|
||||||
|
displayCssPx={128}
|
||||||
|
surface="sparse"
|
||||||
|
ensurePriority="high"
|
||||||
|
alt=""
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="fallback"><Music size={32} /></div>
|
<div className="fallback"><Music size={32} /></div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import OverlayScrollArea from '../OverlayScrollArea';
|
|||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { CoverArtImage } from '../../cover/CoverArtImage';
|
import { CoverArtImage } from '../../cover/CoverArtImage';
|
||||||
import { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
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 = {
|
type ShareQueuePreviewModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -36,7 +37,7 @@ function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerS
|
|||||||
password: coverServer.password,
|
password: coverServer.password,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { kind: 'active' };
|
return COVER_SCOPE_ACTIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function QueuePreviewTrackRow({
|
function QueuePreviewTrackRow({
|
||||||
@@ -51,11 +52,12 @@ function QueuePreviewTrackRow({
|
|||||||
return (
|
return (
|
||||||
<li className="share-queue-preview-track">
|
<li className="share-queue-preview-track">
|
||||||
{song.coverArt ? (
|
{song.coverArt ? (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={coverId}
|
albumId={song.albumId ?? coverId}
|
||||||
|
coverArt={song.coverArt}
|
||||||
|
serverScope={shareCoverServerScope(coverServer)}
|
||||||
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
serverScope={shareCoverServerScope(coverServer)}
|
|
||||||
className="share-queue-preview-track__thumb"
|
className="share-queue-preview-track__thumb"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchP
|
|||||||
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
|
import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch';
|
||||||
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview';
|
import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview';
|
||||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage';
|
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 { COVER_DENSE_SEARCH_CSS_PX } from '../../cover/layoutSizes';
|
||||||
import { coverArtIdFromArtist } from '../../cover/ids';
|
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../../cover/types';
|
||||||
import type { CoverServerScope } from '../../cover/types';
|
|
||||||
import { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
|
import { useShareQueuePreview } from '../../hooks/useShareQueuePreview';
|
||||||
import ShareQueuePreviewModal from './ShareQueuePreviewModal';
|
import ShareQueuePreviewModal from './ShareQueuePreviewModal';
|
||||||
|
|
||||||
@@ -42,25 +42,28 @@ function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerS
|
|||||||
password: coverServer.password,
|
password: coverServer.password,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { kind: 'active' };
|
return COVER_SCOPE_ACTIVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShareAlbumThumb({
|
function ShareAlbumThumb({
|
||||||
|
albumId,
|
||||||
coverArt,
|
coverArt,
|
||||||
displayCssPx,
|
displayCssPx,
|
||||||
coverServer,
|
coverServer,
|
||||||
}: {
|
}: {
|
||||||
|
albumId: string;
|
||||||
coverArt: string;
|
coverArt: string;
|
||||||
displayCssPx: number;
|
displayCssPx: number;
|
||||||
coverServer?: ServerProfile | null;
|
coverServer?: ServerProfile | null;
|
||||||
}) {
|
}) {
|
||||||
const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
|
const cls = displayCssPx >= 64 ? 'mobile-search-thumb' : 'search-result-thumb';
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<AlbumCoverArtImage
|
||||||
coverArtId={coverArt}
|
albumId={albumId}
|
||||||
|
coverArt={coverArt}
|
||||||
|
serverScope={shareCoverServerScope(coverServer)}
|
||||||
displayCssPx={displayCssPx}
|
displayCssPx={displayCssPx}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
serverScope={shareCoverServerScope(coverServer)}
|
|
||||||
className={cls}
|
className={cls}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
@@ -77,8 +80,7 @@ function ShareArtistThumb({
|
|||||||
coverServer?: ServerProfile | null;
|
coverServer?: ServerProfile | null;
|
||||||
}) {
|
}) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const coverId = coverArtIdFromArtist(artist);
|
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
|
||||||
useEffect(() => { setFailed(false); }, [coverId]);
|
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
if (displayCssPx >= 64) {
|
if (displayCssPx >= 64) {
|
||||||
@@ -100,11 +102,12 @@ function ShareArtistThumb({
|
|||||||
? 'mobile-search-thumb mobile-search-thumb--artist-round'
|
? 'mobile-search-thumb mobile-search-thumb--artist-round'
|
||||||
: 'search-result-thumb';
|
: 'search-result-thumb';
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<ArtistCoverArtImage
|
||||||
coverArtId={coverId}
|
artistId={artist.id}
|
||||||
|
coverArt={artist.coverArt}
|
||||||
|
serverScope={shareCoverServerScope(coverServer)}
|
||||||
displayCssPx={displayCssPx}
|
displayCssPx={displayCssPx}
|
||||||
surface="dense"
|
surface="dense"
|
||||||
serverScope={shareCoverServerScope(coverServer)}
|
|
||||||
className={cls}
|
className={cls}
|
||||||
alt=""
|
alt=""
|
||||||
loading="eager"
|
loading="eager"
|
||||||
@@ -319,7 +322,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
|||||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
>
|
>
|
||||||
{shareAlbum.coverArt ? (
|
{shareAlbum.coverArt ? (
|
||||||
<ShareAlbumThumb coverArt={shareAlbum.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
<ShareAlbumThumb albumId={shareAlbum.id} coverArt={shareAlbum.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||||
) : (
|
) : (
|
||||||
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
|
<StaticIcon className={iconCls}><Disc3 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
)}
|
)}
|
||||||
@@ -370,7 +373,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
|||||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
>
|
>
|
||||||
{shareTrackSong.coverArt ? (
|
{shareTrackSong.coverArt ? (
|
||||||
<ShareAlbumThumb coverArt={shareTrackSong.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
<ShareAlbumThumb albumId={shareTrackSong.albumId} coverArt={shareTrackSong.coverArt} displayCssPx={thumbDisplayCssPx} coverServer={shareCoverServer} />
|
||||||
) : (
|
) : (
|
||||||
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
|
<StaticIcon className={iconCls}><Music size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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)',
|
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<CoverArtImageProps, 'coverRef'> & {
|
||||||
|
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 <CoverArtImage coverRef={coverRef} {...rest} />;
|
||||||
|
}
|
||||||
@@ -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<CoverArtImageProps, 'coverRef'> & {
|
||||||
|
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 <CoverArtImage coverRef={coverRef} {...rest} />;
|
||||||
|
}
|
||||||
+31
-16
@@ -1,20 +1,19 @@
|
|||||||
import type { ImgHTMLAttributes } from 'react';
|
import type { ImgHTMLAttributes } from 'react';
|
||||||
|
import type React from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { DEFAULT_CACHED_IMAGE_PREPARE_MARGIN } from '../components/CachedImage';
|
import { DEFAULT_CACHED_IMAGE_PREPARE_MARGIN } from '../components/CachedImage';
|
||||||
import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot';
|
import { resolveIntersectionScrollRoot } from '../utils/ui/resolveIntersectionScrollRoot';
|
||||||
import { coverEnsureBump } from './ensureQueue';
|
import { coverEnsureBump } from './ensureQueue';
|
||||||
import { coverPrefetchBumpPriority } from './prefetchRegistry';
|
import { coverPrefetchBumpPriority } from './prefetchRegistry';
|
||||||
import { coverArtRef } from './ref';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import { coverStorageKey } from './storageKeys';
|
|
||||||
import { resolveCoverDisplayTier } from './tiers';
|
import { resolveCoverDisplayTier } from './tiers';
|
||||||
import { coverImgSrc } from './imgSrc';
|
import { coverImgSrc } from './imgSrc';
|
||||||
import { useCoverArt } from './useCoverArt';
|
import { useCoverArt } from './useCoverArt';
|
||||||
import type { CoverArtId, CoverPrefetchPriority, CoverServerScope, CoverSurfaceKind } from './types';
|
import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types';
|
||||||
|
|
||||||
export type CoverArtImageProps = {
|
export type CoverArtImageProps = {
|
||||||
coverArtId: CoverArtId | null | undefined;
|
coverRef: CoverArtRef;
|
||||||
displayCssPx: number;
|
displayCssPx: number;
|
||||||
serverScope?: CoverServerScope;
|
|
||||||
surface?: CoverSurfaceKind;
|
surface?: CoverSurfaceKind;
|
||||||
fullRes?: boolean;
|
fullRes?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -22,14 +21,12 @@ export type CoverArtImageProps = {
|
|||||||
fetchQueueBias?: number;
|
fetchQueueBias?: number;
|
||||||
observeRootMargin?: string;
|
observeRootMargin?: string;
|
||||||
observeScrollRootId?: string;
|
observeScrollRootId?: string;
|
||||||
/** Initial ensure tier — use `high` for hero / above-the-fold cells. */
|
|
||||||
ensurePriority?: CoverPrefetchPriority;
|
ensurePriority?: CoverPrefetchPriority;
|
||||||
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'>;
|
} & Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'>;
|
||||||
|
|
||||||
export function CoverArtImage({
|
export function CoverArtImage({
|
||||||
coverArtId,
|
coverRef,
|
||||||
displayCssPx,
|
displayCssPx,
|
||||||
serverScope,
|
|
||||||
surface,
|
surface,
|
||||||
fullRes,
|
fullRes,
|
||||||
className,
|
className,
|
||||||
@@ -41,34 +38,37 @@ export function CoverArtImage({
|
|||||||
onError: restOnError,
|
onError: restOnError,
|
||||||
...rest
|
...rest
|
||||||
}: CoverArtImageProps) {
|
}: CoverArtImageProps) {
|
||||||
const scope = serverScope ?? { kind: 'active' };
|
|
||||||
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
|
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
|
||||||
ensurePriorityProp ?? 'middle',
|
ensurePriorityProp ?? 'middle',
|
||||||
);
|
);
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const [imgLoadFailed, setImgLoadFailed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ensurePriorityProp) setEnsurePriority(ensurePriorityProp);
|
if (ensurePriorityProp) setEnsurePriority(ensurePriorityProp);
|
||||||
}, [ensurePriorityProp]);
|
}, [ensurePriorityProp]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImgLoadFailed(false);
|
||||||
|
}, [coverRef.cacheEntityId, coverRef.cacheKind, coverRef.fetchCoverArtId, displayCssPx, surface, fullRes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = imgRef.current;
|
const el = imgRef.current;
|
||||||
if (!el || !coverArtId) return;
|
if (!el) return;
|
||||||
|
|
||||||
const root =
|
const root =
|
||||||
(observeScrollRootId
|
(observeScrollRootId
|
||||||
? (document.getElementById(observeScrollRootId) as Element | null)
|
? (document.getElementById(observeScrollRootId) as Element | null)
|
||||||
: null) ?? resolveIntersectionScrollRoot(el);
|
: null) ?? resolveIntersectionScrollRoot(el);
|
||||||
|
|
||||||
const ref = coverArtRef(coverArtId, scope);
|
|
||||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface, fullRes });
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface, fullRes });
|
||||||
const storageKey = coverStorageKey(scope, coverArtId, tier);
|
const storageKey = coverStorageKeyFromRef(coverRef, tier);
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
entries => {
|
entries => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
setEnsurePriority('high');
|
setEnsurePriority('high');
|
||||||
coverPrefetchBumpPriority(ref, 'high');
|
coverPrefetchBumpPriority(coverRef, 'high');
|
||||||
coverEnsureBump(storageKey, 'high');
|
coverEnsureBump(storageKey, 'high');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,10 +81,9 @@ export function CoverArtImage({
|
|||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [coverArtId, scope, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]);
|
}, [coverRef, displayCssPx, surface, fullRes, observeRootMargin, observeScrollRootId]);
|
||||||
|
|
||||||
const { src, provisional, onImgError } = useCoverArt(coverArtId, displayCssPx, {
|
const { src, provisional, onImgError } = useCoverArt(coverRef, displayCssPx, {
|
||||||
serverScope: scope,
|
|
||||||
surface,
|
surface,
|
||||||
fullRes,
|
fullRes,
|
||||||
ensurePriority,
|
ensurePriority,
|
||||||
@@ -93,6 +92,21 @@ export function CoverArtImage({
|
|||||||
|
|
||||||
const imgSrc = coverImgSrc(src);
|
const imgSrc = coverImgSrc(src);
|
||||||
|
|
||||||
|
if (!imgSrc || imgLoadFailed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={imgRef as React.RefObject<HTMLDivElement | null>}
|
||||||
|
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<HTMLDivElement>)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@@ -104,6 +118,7 @@ export function CoverArtImage({
|
|||||||
data-observe-scroll-root={observeScrollRootId}
|
data-observe-scroll-root={observeScrollRootId}
|
||||||
{...rest}
|
{...rest}
|
||||||
onError={e => {
|
onError={e => {
|
||||||
|
setImgLoadFailed(true);
|
||||||
onImgError?.();
|
onImgError?.();
|
||||||
restOnError?.(e);
|
restOnError?.(e);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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<CoverArtImageProps, 'coverRef'> & {
|
||||||
|
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
|
||||||
|
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 <CoverArtImage coverRef={coverRef} {...rest} />;
|
||||||
|
}
|
||||||
@@ -71,11 +71,10 @@ export function ArtistHeroCover({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverFallback?.coverArtId) {
|
if (coverFallback?.fetchCoverArtId) {
|
||||||
return (
|
return (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverArtId={coverFallback.coverArtId}
|
coverRef={coverFallback}
|
||||||
serverScope={coverFallback.serverScope}
|
|
||||||
displayCssPx={displayCssPx}
|
displayCssPx={displayCssPx}
|
||||||
surface={surface}
|
surface={surface}
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../api/coverCache', () => ({
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,3 +85,13 @@ function scheduleNavigationResume(): void {
|
|||||||
syncBackfillUiPriority();
|
syncBackfillUiPriority();
|
||||||
}, NAVIGATION_QUIET_MS);
|
}, 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { convertFileSrc, isTauri } from '@tauri-apps/api/core';
|
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. */
|
/** Stable asset URLs for disk `.webp` tiers — survives route unmount. */
|
||||||
const diskSrcByStorageKey = new Map<string, string>();
|
const diskSrcByStorageKey = new Map<string, string>();
|
||||||
@@ -27,23 +29,60 @@ export function getDiskSrcCacheGeneration(): number {
|
|||||||
return cacheGeneration;
|
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. */
|
/** True when `convertFileSrc` failed and returned the filesystem path unchanged. */
|
||||||
function isRawFsPath(url: string, fsPath: string): boolean {
|
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.
|
* 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 `<img src>`).
|
* Returns empty when not in Tauri or path is outside asset scope (never put raw paths in `<img src>`).
|
||||||
*/
|
*/
|
||||||
|
function tryCoverDiskUrl(fsPath: string): string {
|
||||||
|
const paths = fsPath.includes('\\')
|
||||||
|
? [normalizePathForConvert(fsPath), fsPath]
|
||||||
|
: [fsPath, normalizePathForConvert(fsPath)];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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 {
|
export function coverDiskUrl(fsPath: string): string {
|
||||||
if (!fsPath || !isTauri()) return '';
|
if (!fsPath || !isTauri()) return '';
|
||||||
const src = convertFileSrc(fsPath);
|
const src = tryCoverDiskUrl(fsPath);
|
||||||
if (isRawFsPath(src, fsPath)) {
|
if (!src && import.meta.env.DEV) {
|
||||||
if (import.meta.env.DEV) {
|
console.warn('[cover] convertFileSrc out of asset scope — check tauri.conf assetProtocol', {
|
||||||
console.warn('[cover] convertFileSrc out of asset scope — check tauri.conf assetProtocol', fsPath);
|
fsPath,
|
||||||
}
|
src: convertFileSrc(normalizePathForConvert(fsPath)),
|
||||||
return '';
|
});
|
||||||
}
|
}
|
||||||
return src;
|
return src;
|
||||||
}
|
}
|
||||||
@@ -67,8 +106,13 @@ export function forgetDiskSrc(storageKey: string): void {
|
|||||||
if (diskSrcByStorageKey.delete(storageKey)) bumpDiskSrcCache();
|
if (diskSrcByStorageKey.delete(storageKey)) bumpDiskSrcCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forgetDiskSrcPrefix(serverIndexKey: string, coverArtId: string): void {
|
export function forgetDiskSrcPrefix(ref: {
|
||||||
const prefix = `${serverIndexKey}:cover:${coverArtId}:`;
|
serverScope: CoverServerScope;
|
||||||
|
cacheKind: string;
|
||||||
|
cacheEntityId: string;
|
||||||
|
}): void {
|
||||||
|
const serverIndexKey = coverIndexKeyFromScope(ref.serverScope);
|
||||||
|
const prefix = `${serverIndexKey}:cover:${ref.cacheKind}:${ref.cacheEntityId}:`;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const key of diskSrcByStorageKey.keys()) {
|
for (const key of diskSrcByStorageKey.keys()) {
|
||||||
if (key.startsWith(prefix)) {
|
if (key.startsWith(prefix)) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { albumCoverRef } from './ref';
|
||||||
|
|
||||||
vi.mock('./diskSrcCache', () => ({
|
vi.mock('./diskSrcCache', () => ({
|
||||||
rememberDiskSrc: vi.fn(() => 'asset://cover.webp'),
|
rememberDiskSrc: vi.fn(() => 'asset://cover.webp'),
|
||||||
@@ -12,7 +13,7 @@ vi.mock('./diskHandoff', () => ({
|
|||||||
|
|
||||||
import { rememberDiskSrc } from './diskSrcCache';
|
import { rememberDiskSrc } from './diskSrcCache';
|
||||||
import { notifyCoverDiskReady } from './diskHandoff';
|
import { notifyCoverDiskReady } from './diskHandoff';
|
||||||
import { gridDiskSrcLookupOrder, rememberGridDiskSrc } from './diskSrcLookup';
|
import { gridDiskSrcLookupOrder, rememberDiskSrcLadder, rememberGridDiskSrc } from './diskSrcLookup';
|
||||||
|
|
||||||
describe('gridDiskSrcLookupOrder', () => {
|
describe('gridDiskSrcLookupOrder', () => {
|
||||||
it('prefers 800 right after 512 when 512 is wanted', () => {
|
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)', () => {
|
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(hit).toBe(true);
|
||||||
expect(vi.mocked(rememberDiskSrc).mock.calls.length).toBeGreaterThanOrEqual(2);
|
expect(vi.mocked(rememberDiskSrc).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(vi.mocked(notifyCoverDiskReady)).toHaveBeenCalledTimes(1);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+25
-23
@@ -1,7 +1,7 @@
|
|||||||
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
|
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
|
||||||
import { hasCoverDiskReadyListeners, notifyCoverDiskReady } from './diskHandoff';
|
import { hasCoverDiskReadyListeners, notifyCoverDiskReady } from './diskHandoff';
|
||||||
import { coverStorageKey } from './storageKeys';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import type { CoverArtId, CoverArtTier, CoverServerScope } from './types';
|
import type { CoverArtRef, CoverArtTier } from './types';
|
||||||
|
|
||||||
/** Dense grids: prefer a larger on-disk tier (800) before tiny thumbs when the ideal tier is missing. */
|
/** Dense grids: prefer a larger on-disk tier (800) before tiny thumbs when the ideal tier is missing. */
|
||||||
export function gridDiskSrcLookupOrder(want: CoverArtTier): CoverArtTier[] {
|
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. */
|
/** Synchronous hit from `diskSrcCache` — any tier already warmed/peeked for this cover. */
|
||||||
export function getDiskSrcForGrid(
|
export function getDiskSrcForGrid(ref: CoverArtRef, wantTier: CoverArtTier): string {
|
||||||
scope: CoverServerScope,
|
|
||||||
coverArtId: CoverArtId,
|
|
||||||
wantTier: CoverArtTier,
|
|
||||||
): string {
|
|
||||||
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
|
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
|
||||||
const src = getDiskSrc(coverStorageKey(scope, coverArtId, tier));
|
const src = getDiskSrc(coverStorageKeyFromRef(ref, tier));
|
||||||
if (src) return src;
|
if (src) return src;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Seed lookup-order tier keys (512 + 800 fallback path, etc.) — no subscriber wakeups. */
|
/** Seed lookup-order tier keys (512 + 800 fallback path, etc.) — no subscriber wakeups. */
|
||||||
export function seedGridDiskSrcCache(
|
export function seedGridDiskSrcCache(ref: CoverArtRef, wantTier: CoverArtTier, fsPath: string): boolean {
|
||||||
scope: CoverServerScope,
|
|
||||||
coverArtId: CoverArtId,
|
|
||||||
wantTier: CoverArtTier,
|
|
||||||
fsPath: string,
|
|
||||||
): boolean {
|
|
||||||
if (!fsPath) return false;
|
if (!fsPath) return false;
|
||||||
let hit = false;
|
let hit = false;
|
||||||
for (const tier of gridDiskSrcLookupOrder(wantTier)) {
|
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;
|
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).
|
* After peek/ensure: seed cache and wake mounted cells once (avoids 4× notify / re-render storms).
|
||||||
*/
|
*/
|
||||||
export function rememberGridDiskSrc(
|
export function rememberGridDiskSrc(ref: CoverArtRef, wantTier: CoverArtTier, fsPath: string): boolean {
|
||||||
scope: CoverServerScope,
|
const hit = seedGridDiskSrcCache(ref, wantTier, fsPath);
|
||||||
coverArtId: CoverArtId,
|
|
||||||
wantTier: CoverArtTier,
|
|
||||||
fsPath: string,
|
|
||||||
): boolean {
|
|
||||||
const hit = seedGridDiskSrcCache(scope, coverArtId, wantTier, fsPath);
|
|
||||||
if (!hit) return false;
|
if (!hit) return false;
|
||||||
const wantKey = coverStorageKey(scope, coverArtId, wantTier);
|
const wantKey = coverStorageKeyFromRef(ref, wantTier);
|
||||||
if (hasCoverDiskReadyListeners(wantKey)) {
|
if (hasCoverDiskReadyListeners(wantKey)) {
|
||||||
notifyCoverDiskReady(wantKey, fsPath);
|
notifyCoverDiskReady(wantKey, fsPath);
|
||||||
}
|
}
|
||||||
return true;
|
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<CoverArtRef, 'cacheKind' | 'cacheEntityId'>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
const { ensureImpl } = vi.hoisted(() => ({
|
const { ensureImpl } = vi.hoisted(() => ({
|
||||||
ensureImpl: vi.fn(
|
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));
|
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 };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function trimQueue(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function coverInflightKey(ref: CoverArtRef): string {
|
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. */
|
/** 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. */
|
/** @internal Vitest-only — queued cover art IDs front-to-back. */
|
||||||
export function __test_queuedCoverIds(): string[] {
|
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 {
|
function ensureMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
|
||||||
if (getDiskSrc(storageKey)) return true;
|
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. */
|
/** Rust disk ensure — parallel slots; one download chain per cover art ID. */
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import type { CoverArtRef, CoverArtTier } from './types';
|
|||||||
|
|
||||||
/** Builds ephemeral getCoverArt URL — NOT a cache key */
|
/** Builds ephemeral getCoverArt URL — NOT a cache key */
|
||||||
export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): string {
|
export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): string {
|
||||||
const { coverArtId, serverScope } = ref;
|
const { fetchCoverArtId, serverScope } = ref;
|
||||||
if (serverScope.kind === 'server') {
|
if (serverScope.kind === 'server') {
|
||||||
return buildCoverArtUrlForServer(
|
return buildCoverArtUrlForServer(
|
||||||
serverScope.url,
|
serverScope.url,
|
||||||
serverScope.username,
|
serverScope.username,
|
||||||
serverScope.password,
|
serverScope.password,
|
||||||
coverArtId,
|
fetchCoverArtId,
|
||||||
tier,
|
tier,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -28,11 +28,11 @@ export function buildCoverArtFetchUrl(ref: CoverArtRef, tier: CoverArtTier): str
|
|||||||
server.url,
|
server.url,
|
||||||
server.username,
|
server.username,
|
||||||
server.password,
|
server.password,
|
||||||
coverArtId,
|
fetchCoverArtId,
|
||||||
tier,
|
tier,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buildCoverArtUrl(coverArtId, tier);
|
return buildCoverArtUrl(fetchCoverArtId, tier);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ export * from './ids';
|
|||||||
export * from './storageKeys';
|
export * from './storageKeys';
|
||||||
export * from './reachability';
|
export * from './reachability';
|
||||||
export * from './layoutSizes';
|
export * from './layoutSizes';
|
||||||
|
export * from './resolveEntry';
|
||||||
|
export * from './resolveEntryLibrary';
|
||||||
export * from './ref';
|
export * from './ref';
|
||||||
export { useCoverArt } from './useCoverArt';
|
export { useCoverArt } from './useCoverArt';
|
||||||
|
export {
|
||||||
|
useAlbumCoverRef,
|
||||||
|
useArtistCoverRef,
|
||||||
|
usePlaybackTrackCoverRef,
|
||||||
|
useTrackCoverRef,
|
||||||
|
} from './useLibraryCoverRef';
|
||||||
export { CoverArtImage } from './CoverArtImage';
|
export { CoverArtImage } from './CoverArtImage';
|
||||||
|
export { AlbumCoverArtImage } from './AlbumCoverArtImage';
|
||||||
|
export { ArtistCoverArtImage } from './ArtistCoverArtImage';
|
||||||
|
export { TrackCoverArtImage } from './TrackCoverArtImage';
|
||||||
|
export { useLibraryCoverPrefetch } from './useLibraryCoverPrefetch';
|
||||||
export {
|
export {
|
||||||
clearAllDiskSrcCache,
|
clearAllDiskSrcCache,
|
||||||
forgetDiskSrc,
|
forgetDiskSrc,
|
||||||
@@ -18,6 +30,24 @@ export {
|
|||||||
rememberDiskSrc,
|
rememberDiskSrc,
|
||||||
} from './diskSrcCache';
|
} from './diskSrcCache';
|
||||||
export { usePlaybackCoverArt } from './usePlaybackCoverArt';
|
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 { ensureCoverTierJs } from './resolveJs';
|
||||||
export { ensureCoverTierDiskSrc, ensureCoverTierDiskBlob } from './resolveDisk';
|
export { ensureCoverTierDiskSrc, ensureCoverTierDiskBlob } from './resolveDisk';
|
||||||
export { buildCoverArtFetchUrl } from './fetchUrl';
|
export { buildCoverArtFetchUrl } from './fetchUrl';
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { computeCardGridColumnCount, computeCellWidthPx } from '../utils/cardGridLayout';
|
import { computeCardGridColumnCount, computeCellWidthPx } from '../utils/cardGridLayout';
|
||||||
|
|
||||||
export const COVER_DENSE_SEARCH_CSS_PX = 40;
|
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_ARTIST_LIST_CSS_PX = 64;
|
||||||
export const COVER_DENSE_RAIL_CELL_CSS_PX = 180;
|
export const COVER_DENSE_RAIL_CELL_CSS_PX = 180;
|
||||||
export const COVER_DENSE_GRID_MIN_CELL_CSS_PX = 140;
|
export const COVER_DENSE_GRID_MIN_CELL_CSS_PX = 140;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function useCoverLightboxSrc(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, ref?.coverArtId, ref?.serverScope]);
|
}, [open, ref?.cacheEntityId, ref?.cacheKind, ref?.fetchCoverArtId, ref?.serverScope]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) return;
|
if (open) return;
|
||||||
|
|||||||
+18
-16
@@ -3,12 +3,12 @@ import { getDiskSrc } from './diskSrcCache';
|
|||||||
import { getDiskSrcForGrid } from './diskSrcLookup';
|
import { getDiskSrcForGrid } from './diskSrcLookup';
|
||||||
import { coverTrafficServerSwitchPaused } from './coverTraffic';
|
import { coverTrafficServerSwitchPaused } from './coverTraffic';
|
||||||
import { rememberGridDiskSrc } from './diskSrcLookup';
|
import { rememberGridDiskSrc } from './diskSrcLookup';
|
||||||
import { coverIndexKeyFromRef } from './storageKeys';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import type { CoverArtRef, CoverArtTier } from './types';
|
import type { CoverArtRef, CoverArtTier } from './types';
|
||||||
|
|
||||||
function peekMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
|
function peekMemoryHit(storageKey: string, ref: CoverArtRef, tier: CoverArtTier): boolean {
|
||||||
if (getDiskSrc(storageKey)) return true;
|
if (getDiskSrc(storageKey)) return true;
|
||||||
return Boolean(getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier));
|
return Boolean(getDiskSrcForGrid(ref, tier));
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeekJob = {
|
type PeekJob = {
|
||||||
@@ -51,22 +51,24 @@ async function flush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (needDisk.length === 0) return;
|
if (needDisk.length === 0) return;
|
||||||
|
|
||||||
const hits = await coverCachePeekBatch(
|
const byTier = new Map<CoverArtTier, PeekJob[]>();
|
||||||
needDisk.map(job => ({
|
|
||||||
serverIndexKey: coverIndexKeyFromRef(job.ref),
|
|
||||||
coverArtId: job.ref.coverArtId,
|
|
||||||
tier: job.tier,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const job of needDisk) {
|
for (const job of needDisk) {
|
||||||
const path = hits[job.storageKey];
|
const list = byTier.get(job.tier) ?? [];
|
||||||
const hit = Boolean(
|
list.push(job);
|
||||||
path
|
byTier.set(job.tier, list);
|
||||||
&& rememberGridDiskSrc(job.ref.serverScope, job.ref.coverArtId, job.tier, path),
|
}
|
||||||
|
|
||||||
|
for (const [tier, jobs] of byTier) {
|
||||||
|
const hits = await coverCachePeekBatch(
|
||||||
|
jobs.map(j => j.ref),
|
||||||
|
tier,
|
||||||
);
|
);
|
||||||
job.resolve(hit);
|
for (const job of jobs) {
|
||||||
inflight.delete(job.storageKey);
|
const path = hits[job.storageKey] ?? '';
|
||||||
|
const hit = Boolean(path && rememberGridDiskSrc(job.ref, job.tier, path));
|
||||||
|
job.resolve(hit);
|
||||||
|
inflight.delete(job.storageKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const MAX_REGISTRY = 120;
|
|||||||
const registry = new Map<string, { ref: CoverArtRef; priority: CoverPrefetchPriority }>();
|
const registry = new Map<string, { ref: CoverArtRef; priority: CoverPrefetchPriority }>();
|
||||||
|
|
||||||
function registryKey(ref: CoverArtRef): string {
|
function registryKey(ref: CoverArtRef): string {
|
||||||
return `${coverIndexKeyFromRef(ref)}:${ref.coverArtId}`;
|
return `${coverIndexKeyFromRef(ref)}:${ref.cacheKind}:${ref.cacheEntityId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function coverPrefetchRegister(
|
export function coverPrefetchRegister(
|
||||||
@@ -23,7 +23,7 @@ export function coverPrefetchRegister(
|
|||||||
|
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) continue;
|
if (!ref.cacheEntityId || !coverServerReachable(ref.serverScope)) continue;
|
||||||
const key = registryKey(ref);
|
const key = registryKey(ref);
|
||||||
if (registry.size >= MAX_REGISTRY && !registry.has(key)) {
|
if (registry.size >= MAX_REGISTRY && !registry.has(key)) {
|
||||||
const drop = [...registry.entries()].find(([, v]) => v.priority === 'low');
|
const drop = [...registry.entries()].find(([, v]) => v.priority === 'low');
|
||||||
@@ -62,7 +62,7 @@ export function coverPrefetchBumpPriority(
|
|||||||
ref: CoverArtRef,
|
ref: CoverArtRef,
|
||||||
priority: CoverPrefetchPriority,
|
priority: CoverPrefetchPriority,
|
||||||
): void {
|
): void {
|
||||||
if (!ref.coverArtId || !coverServerReachable(ref.serverScope)) return;
|
if (!ref.cacheEntityId || !coverServerReachable(ref.serverScope)) return;
|
||||||
const key = registryKey(ref);
|
const key = registryKey(ref);
|
||||||
const existing = registry.get(key);
|
const existing = registry.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+179
-3
@@ -1,19 +1,195 @@
|
|||||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { useAuthStore } from '../store/authStore';
|
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<string, boolean>();
|
||||||
|
|
||||||
|
export function rememberAlbumDistinctDiscCovers(
|
||||||
|
albumId: string,
|
||||||
|
songs: ReadonlyArray<Pick<SubsonicSong, 'discNumber' | 'coverArt' | 'id' | 'albumId'>>,
|
||||||
|
): 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<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
|
||||||
|
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<SubsonicSong, 'coverArt' | 'id' | 'discNumber'> & { 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<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
|
||||||
|
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(
|
export function coverArtRef(
|
||||||
coverArtId: CoverArtId,
|
coverArtId: CoverArtId,
|
||||||
serverScope: CoverServerScope = { kind: 'active' },
|
serverScope: CoverServerScope = { kind: 'active' },
|
||||||
): CoverArtRef {
|
): 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 {
|
export function resolvePlaybackCoverScope(): CoverServerScope {
|
||||||
const playbackSid = getPlaybackServerId();
|
const playbackSid = getPlaybackServerId();
|
||||||
const activeSid = useAuthStore.getState().activeServerId;
|
const activeSid = useAuthStore.getState().activeServerId;
|
||||||
if (playbackSid && activeSid && playbackSid !== activeSid) {
|
if (playbackSid && activeSid && playbackSid !== activeSid) {
|
||||||
const server = useAuthStore.getState().servers.find(s => s.id === playbackSid);
|
const server = findServerByIdOrIndexKey(playbackSid);
|
||||||
if (server) {
|
if (server) {
|
||||||
return {
|
return {
|
||||||
kind: 'server',
|
kind: 'server',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { isTauri } from '@tauri-apps/api/core';
|
import { isTauri } from '@tauri-apps/api/core';
|
||||||
import { coverCacheEnsure } from '../api/coverCache';
|
import { coverCacheEnsure } from '../api/coverCache';
|
||||||
import { invalidateCacheKey } from '../utils/imageCache';
|
import { invalidateCacheKey } from '../utils/imageCache';
|
||||||
|
import { getDiskSrcForGrid } from './diskSrcLookup';
|
||||||
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
|
import { getDiskSrc, rememberDiskSrc } from './diskSrcCache';
|
||||||
import { coverStorageKey } from './storageKeys';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import type { CoverArtRef, CoverArtTier } from './types';
|
import type { CoverArtRef, CoverArtTier } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,10 +13,10 @@ export async function ensureCoverTierDiskSrc(
|
|||||||
ref: CoverArtRef,
|
ref: CoverArtRef,
|
||||||
tier: CoverArtTier,
|
tier: CoverArtTier,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!ref.coverArtId || !isTauri()) return '';
|
if (!ref.fetchCoverArtId || !isTauri()) return '';
|
||||||
|
|
||||||
const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
|
const storageKey = coverStorageKeyFromRef(ref, tier);
|
||||||
const cached = getDiskSrc(storageKey);
|
const cached = getDiskSrcForGrid(ref, tier) || getDiskSrc(storageKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const result = await coverCacheEnsure(ref, tier, 'high');
|
const result = await coverCacheEnsure(ref, tier, 'high');
|
||||||
@@ -34,7 +35,7 @@ export async function ensureCoverTierDiskBlob(
|
|||||||
tier: CoverArtTier,
|
tier: CoverArtTier,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<Blob | null> {
|
): Promise<Blob | null> {
|
||||||
const storageKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
|
const storageKey = coverStorageKeyFromRef(ref, tier);
|
||||||
const existing = getDiskSrc(storageKey);
|
const existing = getDiskSrc(storageKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SubsonicSong, 'id' | 'coverArt'> & {
|
||||||
|
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<Pick<SubsonicSong, 'discNumber' | 'coverArt' | 'id' | 'albumId'>>,
|
||||||
|
): boolean {
|
||||||
|
const artByDisc = new Map<number, string>();
|
||||||
|
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<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
|
||||||
|
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<SubsonicSong, 'id' | 'coverArt' | 'albumId' | 'album' | 'discNumber'>,
|
||||||
|
albums: ReadonlyArray<Pick<SubsonicAlbum, 'id' | 'name' | 'coverArt'>>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -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<string, CoverEntry | null>();
|
||||||
|
const inflightResolves = new Map<string, Promise<CoverEntry | null>>();
|
||||||
|
|
||||||
|
const LIBRARY_RESOLVE_MAX_INFLIGHT = 4;
|
||||||
|
let libraryResolveActive = 0;
|
||||||
|
const libraryResolveWaiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
function runLibraryResolveLimited<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
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<CoverEntry | null> {
|
||||||
|
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<LibraryCoverEntryDto | null>('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<CoverArtRef> {
|
||||||
|
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<CoverArtRef> {
|
||||||
|
const entry =
|
||||||
|
(await libraryResolveCoverEntry(libraryServerIdFromScope(serverScope), 'artist', artistId))
|
||||||
|
?? resolveArtistCoverEntry(artistId, fallbackCoverArt);
|
||||||
|
return coverEntryToRef(entry!, serverScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTrackCoverEntry(
|
||||||
|
song: Parameters<typeof resolveTrackCoverEntry>[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<typeof resolveTrackCoverEntry>[0],
|
||||||
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
||||||
|
distinctDiscCovers?: boolean,
|
||||||
|
): Promise<CoverArtRef | undefined> {
|
||||||
|
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<CoverArtRef[]> {
|
||||||
|
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<CoverArtRef[]> {
|
||||||
|
return Promise.all(
|
||||||
|
artists.map(a => resolveArtistCoverRefFromLibrary(a.id, a.coverArt, serverScope)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTrackCoverRefsFromLibrary(
|
||||||
|
songs: ReadonlyArray<Parameters<typeof resolveTrackCoverEntry>[0]>,
|
||||||
|
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
|
||||||
|
): Promise<CoverArtRef[]> {
|
||||||
|
const refs = await Promise.all(
|
||||||
|
songs.map(s => resolveTrackCoverRefFromLibrary(s, serverScope)),
|
||||||
|
);
|
||||||
|
return refs.filter((r): r is CoverArtRef => !!r);
|
||||||
|
}
|
||||||
@@ -8,9 +8,9 @@ vi.mock('../utils/imageCache', () => ({
|
|||||||
|
|
||||||
vi.mock('../utils/imageCache/coverSiblings', () => ({
|
vi.mock('../utils/imageCache/coverSiblings', () => ({
|
||||||
parseCoverCacheKey: (key: string) => {
|
parseCoverCacheKey: (key: string) => {
|
||||||
const m = key.match(/^(.+):cover:(.+):(\d+)$/);
|
const m = key.match(/^(.+):cover:(.+):(.+):(\d+)$/);
|
||||||
if (!m) return null;
|
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,
|
probeSiblingCoverBlobInMemory: () => null,
|
||||||
probeSiblingCoverBlobFromIDB: async () => null,
|
probeSiblingCoverBlobFromIDB: async () => null,
|
||||||
@@ -38,7 +38,9 @@ vi.mock('./fetchUrl', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const ref: CoverArtRef = {
|
const ref: CoverArtRef = {
|
||||||
coverArtId: 'al-1',
|
cacheKind: 'album',
|
||||||
|
cacheEntityId: 'al-1',
|
||||||
|
fetchCoverArtId: 'al-1',
|
||||||
serverScope: { kind: 'active' },
|
serverScope: { kind: 'active' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ describe('ensureCoverTierJs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when cover id missing', async () => {
|
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 () => {
|
it('fetches via getCachedBlob on cold path', async () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { rememberBlob } from '../utils/imageCache/blobCache';
|
|||||||
import { putBlob } from '../utils/imageCache/idbStore';
|
import { putBlob } from '../utils/imageCache/idbStore';
|
||||||
import { buildCoverArtFetchUrl } from './fetchUrl';
|
import { buildCoverArtFetchUrl } from './fetchUrl';
|
||||||
import { coverServerReachable } from './reachability';
|
import { coverServerReachable } from './reachability';
|
||||||
import { coverStorageKey } from './storageKeys';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import type { CoverArtRef, CoverArtTier } from './types';
|
import type { CoverArtRef, CoverArtTier } from './types';
|
||||||
|
|
||||||
const CANONICAL_TIER = 800 as CoverArtTier;
|
const CANONICAL_TIER = 800 as CoverArtTier;
|
||||||
@@ -40,8 +40,8 @@ async function scheduleColdCoverRace(
|
|||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
getPriority?: () => number,
|
getPriority?: () => number,
|
||||||
): Promise<Blob | null> {
|
): Promise<Blob | null> {
|
||||||
const targetKey = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
|
const targetKey = coverStorageKeyFromRef(ref, tier);
|
||||||
const canonicalKey = coverStorageKey(ref.serverScope, ref.coverArtId, CANONICAL_TIER);
|
const canonicalKey = coverStorageKeyFromRef(ref, CANONICAL_TIER);
|
||||||
const chainCtl = new AbortController();
|
const chainCtl = new AbortController();
|
||||||
const directCtl = new AbortController();
|
const directCtl = new AbortController();
|
||||||
let winner = false;
|
let winner = false;
|
||||||
@@ -96,9 +96,9 @@ export async function ensureCoverTierJs(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
getPriority?: () => number,
|
getPriority?: () => number,
|
||||||
): Promise<Blob | null> {
|
): Promise<Blob | null> {
|
||||||
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);
|
const mem = blobCache.get(cacheKey);
|
||||||
if (mem) return mem;
|
if (mem) return mem;
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ export async function ensureCoverTierJs(
|
|||||||
return getCachedBlob(buildCoverArtFetchUrl(ref, 2000), cacheKey, signal, getPriority);
|
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 =
|
const hasChain =
|
||||||
blobCache.has(canonicalKey) ||
|
blobCache.has(canonicalKey) ||
|
||||||
(parsed && (await probeSiblingCoverBlobFromIDB(parsed.stem, parsed.size)) !== null);
|
(parsed && (await probeSiblingCoverBlobFromIDB(parsed.stem, parsed.size)) !== null);
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ describe('coverStorageKey', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses host index key for active scope (not profile uuid)', () => {
|
it('uses host index key for active scope (not profile uuid)', () => {
|
||||||
expect(coverStorageKey({ kind: 'active' }, 'al-42', 128)).toBe(
|
expect(
|
||||||
'music.local:4533:cover:al-42:128',
|
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', () => {
|
it('uses host index key from explicit server url', () => {
|
||||||
@@ -51,10 +51,10 @@ describe('coverStorageKey', () => {
|
|||||||
username: 'u',
|
username: 'u',
|
||||||
password: 'p',
|
password: 'p',
|
||||||
},
|
},
|
||||||
'ar-1',
|
{ cacheKind: 'artist', cacheEntityId: 'ar-1' },
|
||||||
512,
|
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', () => {
|
it('coverIndexKeyFromScope matches library-style keys', () => {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||||
import {
|
import {
|
||||||
|
resolveIndexKey,
|
||||||
serverIndexKeyForProfile,
|
serverIndexKeyForProfile,
|
||||||
serverIndexKeyFromUrl,
|
serverIndexKeyFromUrl,
|
||||||
} from '../utils/server/serverIndexKey';
|
} 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).
|
* 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 playbackSid = getPlaybackServerId();
|
||||||
const activeSid = useAuthStore.getState().activeServerId;
|
const activeSid = useAuthStore.getState().activeServerId;
|
||||||
const sid = playbackSid || activeSid;
|
const sid = playbackSid || activeSid;
|
||||||
const server = sid
|
const server = sid ? findServerByIdOrIndexKey(sid) : undefined;
|
||||||
? useAuthStore.getState().servers.find(s => s.id === sid)
|
|
||||||
: undefined;
|
|
||||||
if (server) return serverIndexKeyForProfile(server);
|
if (server) return serverIndexKeyForProfile(server);
|
||||||
|
if (sid) return resolveIndexKey(sid) || sid;
|
||||||
return '_';
|
return '_';
|
||||||
}
|
}
|
||||||
const server = useAuthStore.getState().getActiveServer();
|
const server = useAuthStore.getState().getActiveServer();
|
||||||
@@ -38,8 +39,12 @@ export const serverIdFromScope = coverIndexKeyFromScope;
|
|||||||
|
|
||||||
export function coverStorageKey(
|
export function coverStorageKey(
|
||||||
serverScope: CoverServerScope,
|
serverScope: CoverServerScope,
|
||||||
coverArtId: CoverArtId,
|
ref: Pick<CoverArtRef, 'cacheKind' | 'cacheEntityId'>,
|
||||||
tier: CoverArtTier,
|
tier: CoverArtTier,
|
||||||
): string {
|
): 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -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 CoverArtId = string;
|
||||||
|
|
||||||
|
export type CoverCacheKind = 'album' | 'artist';
|
||||||
|
|
||||||
/** Fixed storage / server-request tiers */
|
/** Fixed storage / server-request tiers */
|
||||||
export const COVER_ART_TIERS = [64, 128, 256, 512, 800, 2000] as const;
|
export const COVER_ART_TIERS = [64, 128, 256, 512, 800, 2000] as const;
|
||||||
|
|
||||||
@@ -14,12 +16,26 @@ export type CoverServerScope =
|
|||||||
| { kind: 'playback' }
|
| { kind: 'playback' }
|
||||||
| { kind: 'server'; serverId: string; url: string; username: string; password: string };
|
| { 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 CoverSurfaceKind = 'dense' | 'sparse';
|
||||||
|
|
||||||
export type CoverPrefetchPriority = 'high' | 'middle' | 'low';
|
export type CoverPrefetchPriority = 'high' | 'middle' | 'low';
|
||||||
|
|
||||||
|
/** Disk cache is keyed by `cacheKind` + `cacheEntityId`; HTTP uses `fetchCoverArtId`. */
|
||||||
export type CoverArtRef = {
|
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;
|
serverScope: CoverServerScope;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+18
-36
@@ -3,22 +3,18 @@ import { coverEnsureQueued, coverEnsureRelease } from './ensureQueue';
|
|||||||
import { coverPeekQueued } from './peekQueue';
|
import { coverPeekQueued } from './peekQueue';
|
||||||
import { getDiskSrcForGrid, seedGridDiskSrcCache } from './diskSrcLookup';
|
import { getDiskSrcForGrid, seedGridDiskSrcCache } from './diskSrcLookup';
|
||||||
import {
|
import {
|
||||||
forgetDiskSrc,
|
forgetDiskSrcPrefix,
|
||||||
getDiskSrc,
|
|
||||||
getDiskSrcCacheGeneration,
|
getDiskSrcCacheGeneration,
|
||||||
rememberDiskSrc,
|
|
||||||
subscribeDiskSrcCache,
|
subscribeDiskSrcCache,
|
||||||
} from './diskSrcCache';
|
} from './diskSrcCache';
|
||||||
import { subscribeCoverDiskReady } from './diskHandoff';
|
import { subscribeCoverDiskReady } from './diskHandoff';
|
||||||
import { coverArtRef } from './ref';
|
|
||||||
import { coverServerReachable } from './reachability';
|
import { coverServerReachable } from './reachability';
|
||||||
import { coverStorageKey } from './storageKeys';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import { resolveCoverDisplayTier } from './tiers';
|
import { resolveCoverDisplayTier } from './tiers';
|
||||||
import type {
|
import type {
|
||||||
CoverArtHandle,
|
CoverArtHandle,
|
||||||
CoverArtId,
|
CoverArtRef,
|
||||||
CoverPrefetchPriority,
|
CoverPrefetchPriority,
|
||||||
CoverServerScope,
|
|
||||||
CoverSurfaceKind,
|
CoverSurfaceKind,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -26,73 +22,58 @@ import type {
|
|||||||
* Disk cache in Rust (WebP tiers) — no webview `getCoverArt` fetch when server is reachable.
|
* Disk cache in Rust (WebP tiers) — no webview `getCoverArt` fetch when server is reachable.
|
||||||
*/
|
*/
|
||||||
export function useCoverArt(
|
export function useCoverArt(
|
||||||
coverArtId: CoverArtId | null | undefined,
|
coverRef: CoverArtRef | null | undefined,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
opts?: {
|
opts?: {
|
||||||
serverScope?: CoverServerScope;
|
|
||||||
surface?: CoverSurfaceKind;
|
surface?: CoverSurfaceKind;
|
||||||
fullRes?: boolean;
|
fullRes?: boolean;
|
||||||
fetchQueueBias?: number;
|
fetchQueueBias?: number;
|
||||||
observeRootMargin?: string;
|
observeRootMargin?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
/** Download / ensure ordering — visible cells should pass `high`. */
|
|
||||||
ensurePriority?: CoverPrefetchPriority;
|
ensurePriority?: CoverPrefetchPriority;
|
||||||
},
|
},
|
||||||
): CoverArtHandle {
|
): CoverArtHandle {
|
||||||
const serverScope = opts?.serverScope ?? { kind: 'active' };
|
const ref = coverRef ?? null;
|
||||||
const surface = opts?.surface ?? 'sparse';
|
const surface = opts?.surface ?? 'sparse';
|
||||||
const reachable = coverServerReachable(serverScope);
|
const reachable = ref ? coverServerReachable(ref.serverScope) : false;
|
||||||
|
|
||||||
const tier = useMemo(
|
const tier = useMemo(
|
||||||
() =>
|
() =>
|
||||||
coverArtId
|
ref
|
||||||
? resolveCoverDisplayTier(displayCssPx, {
|
? resolveCoverDisplayTier(displayCssPx, {
|
||||||
surface,
|
surface,
|
||||||
fullRes: opts?.fullRes,
|
fullRes: opts?.fullRes,
|
||||||
})
|
})
|
||||||
: 128,
|
: 128,
|
||||||
[coverArtId, displayCssPx, surface, opts?.fullRes],
|
[ref, displayCssPx, surface, opts?.fullRes],
|
||||||
);
|
|
||||||
|
|
||||||
const ref = useMemo(
|
|
||||||
() => (coverArtId ? coverArtRef(coverArtId, serverScope) : null),
|
|
||||||
[coverArtId, serverScope],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const storageKey = useMemo(
|
const storageKey = useMemo(
|
||||||
() => (ref ? coverStorageKey(ref.serverScope, ref.coverArtId, tier) : ''),
|
() => (ref ? coverStorageKeyFromRef(ref, tier) : ''),
|
||||||
[ref, tier],
|
[ref, tier],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ensurePriority: CoverPrefetchPriority = opts?.ensurePriority ?? 'middle';
|
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 deferEnsureUntilVisible = surface === 'dense' && ensurePriority !== 'high';
|
||||||
|
|
||||||
const readCachedSrc = useCallback(() => {
|
const readCachedSrc = useCallback(() => {
|
||||||
if (!ref) return '';
|
if (!ref) return '';
|
||||||
if (surface === 'dense') {
|
return getDiskSrcForGrid(ref, tier);
|
||||||
return getDiskSrcForGrid(ref.serverScope, ref.coverArtId, tier);
|
}, [ref, tier]);
|
||||||
}
|
|
||||||
return getDiskSrc(storageKey);
|
|
||||||
}, [ref, storageKey, surface, tier]);
|
|
||||||
|
|
||||||
useSyncExternalStore(subscribeDiskSrcCache, getDiskSrcCacheGeneration);
|
useSyncExternalStore(subscribeDiskSrcCache, getDiskSrcCacheGeneration);
|
||||||
|
|
||||||
const cachedSrc = readCachedSrc();
|
const cachedSrc = readCachedSrc();
|
||||||
|
|
||||||
const applyDiskPath = useCallback((path: string) => {
|
const applyDiskPath = useCallback((path: string) => {
|
||||||
if (!ref || !storageKey) return;
|
if (!ref) return;
|
||||||
if (!path) {
|
if (!path) {
|
||||||
forgetDiskSrc(storageKey);
|
forgetDiskSrcPrefix(ref);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (surface === 'dense') {
|
seedGridDiskSrcCache(ref, tier, path);
|
||||||
seedGridDiskSrcCache(ref.serverScope, ref.coverArtId, tier, path);
|
}, [ref, tier]);
|
||||||
} else {
|
|
||||||
rememberDiskSrc(storageKey, path);
|
|
||||||
}
|
|
||||||
}, [ref, storageKey, tier, surface, readCachedSrc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref || !storageKey) return;
|
if (!ref || !storageKey) return;
|
||||||
@@ -139,8 +120,9 @@ export function useCoverArt(
|
|||||||
const provisional = Boolean(ref && storageKey && !src);
|
const provisional = Boolean(ref && storageKey && !src);
|
||||||
|
|
||||||
const onImgError = useCallback(() => {
|
const onImgError = useCallback(() => {
|
||||||
forgetDiskSrc(storageKey);
|
if (!ref) return;
|
||||||
if (ref && reachable) {
|
forgetDiskSrcPrefix(ref);
|
||||||
|
if (reachable) {
|
||||||
void coverEnsureQueued(storageKey, ref, tier, 'high').then(result => {
|
void coverEnsureQueued(storageKey, ref, tier, 'high').then(result => {
|
||||||
if (result.hit && result.path) applyDiskPath(result.path);
|
if (result.hit && result.path) applyDiskPath(result.path);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { coverPrefetchDrainBatch } from './prefetchRegistry';
|
import { coverPrefetchDrainBatch } from './prefetchRegistry';
|
||||||
import { coverTrafficBackgroundPaused } from './coverTraffic';
|
import { coverTrafficBackgroundPaused } from './coverTraffic';
|
||||||
import { coverEnsureQueued } from './ensureQueue';
|
import { coverEnsureQueued } from './ensureQueue';
|
||||||
import { coverStorageKey } from './storageKeys';
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import { resolveCoverDisplayTier } from './tiers';
|
import { resolveCoverDisplayTier } from './tiers';
|
||||||
import type { CoverArtTier } from './types';
|
import type { CoverArtTier } from './types';
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export function useCoverArtPrefetch(enabled = true): void {
|
|||||||
if (batch.length > 0) {
|
if (batch.length > 0) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(ref => {
|
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');
|
return coverEnsureQueued(key, ref, DENSE_PREFETCH_TIER, 'low');
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<T extends CoverArtRef | null | undefined>(
|
||||||
|
setRef: Dispatch<SetStateAction<T>>,
|
||||||
|
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<CoverArtRef | null>(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<CoverArtRef | null>(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<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | 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<CoverArtRef | undefined>(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<typeof albumCoverRefForPlayback>[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<CoverArtRef | undefined>(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<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { CoverArtHandle } from './types';
|
import type { CoverArtHandle } from './types';
|
||||||
|
import { albumCoverRef } from './ref';
|
||||||
import { usePlaybackCoverArt } from './usePlaybackCoverArt';
|
import { usePlaybackCoverArt } from './usePlaybackCoverArt';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
@@ -10,7 +11,7 @@ import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
|||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
useCoverArtMock: vi.fn(
|
useCoverArtMock: vi.fn(
|
||||||
(_coverArtId?: unknown, _displayCssPx?: unknown, _opts?: unknown): CoverArtHandle => ({
|
(_coverRef?: unknown, _displayCssPx?: unknown, _opts?: unknown): CoverArtHandle => ({
|
||||||
src: '',
|
src: '',
|
||||||
storageKey: '',
|
storageKey: '',
|
||||||
cacheKey: '',
|
cacheKey: '',
|
||||||
@@ -24,7 +25,7 @@ vi.mock('./useCoverArt', () => ({
|
|||||||
useCoverArt: hoisted.useCoverArtMock,
|
useCoverArt: hoisted.useCoverArtMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function seedPlaybackState(): { active: string; playback: string } {
|
function seedPlaybackState(): { active: string; playback: string; track: ReturnType<typeof makeTrack> } {
|
||||||
const active = useAuthStore.getState().addServer({
|
const active = useAuthStore.getState().addServer({
|
||||||
name: 'Active',
|
name: 'Active',
|
||||||
url: 'https://active.test',
|
url: 'https://active.test',
|
||||||
@@ -38,14 +39,14 @@ function seedPlaybackState(): { active: string; playback: string } {
|
|||||||
password: 'play-pass',
|
password: 'play-pass',
|
||||||
});
|
});
|
||||||
useAuthStore.getState().setActiveServer(active);
|
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({
|
usePlayerStore.setState({
|
||||||
queueItems: toQueueItemRefs(playback, [track]),
|
queueItems: toQueueItemRefs(playback, [track]),
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
queueServerId: playback,
|
queueServerId: playback,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
});
|
});
|
||||||
return { active, playback };
|
return { active, playback, track };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('usePlaybackCoverArt', () => {
|
describe('usePlaybackCoverArt', () => {
|
||||||
@@ -55,13 +56,14 @@ describe('usePlaybackCoverArt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('recomputes server scope when playback server credentials change', async () => {
|
it('recomputes server scope when playback server credentials change', async () => {
|
||||||
const { playback } = seedPlaybackState();
|
const { playback, track } = seedPlaybackState();
|
||||||
const { rerender } = renderHook(() => usePlaybackCoverArt('cover-1', 300));
|
const coverRef = albumCoverRef(track.albumId!, track.coverArt!);
|
||||||
|
const { rerender } = renderHook(() => usePlaybackCoverArt(coverRef, 300));
|
||||||
|
|
||||||
const calls = hoisted.useCoverArtMock.mock.calls as Array<
|
const calls = hoisted.useCoverArtMock.mock.calls as Array<
|
||||||
[unknown, unknown, { serverScope?: Record<string, unknown> }]
|
[{ serverScope?: Record<string, unknown> } | null, unknown, unknown]
|
||||||
>;
|
>;
|
||||||
const firstScope = calls[0]?.[2]?.serverScope;
|
const firstScope = calls[0]?.[0]?.serverScope;
|
||||||
expect(firstScope).toMatchObject({
|
expect(firstScope).toMatchObject({
|
||||||
kind: 'server',
|
kind: 'server',
|
||||||
serverId: playback,
|
serverId: playback,
|
||||||
@@ -79,7 +81,7 @@ describe('usePlaybackCoverArt', () => {
|
|||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const latestScope = calls[calls.length - 1]?.[2]?.serverScope;
|
const latestScope = calls[calls.length - 1]?.[0]?.serverScope;
|
||||||
expect(latestScope).toMatchObject({
|
expect(latestScope).toMatchObject({
|
||||||
kind: 'server',
|
kind: 'server',
|
||||||
serverId: playback,
|
serverId: playback,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { resolvePlaybackCoverScope } from './ref';
|
import { resolvePlaybackCoverScope } from './ref';
|
||||||
import type { CoverArtHandle, CoverArtId } from './types';
|
import type { CoverArtHandle, CoverArtRef } from './types';
|
||||||
import { useCoverArt } from './useCoverArt';
|
import { useCoverArt } from './useCoverArt';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
|
|
||||||
/** Cover art for playback queue — uses queue server when it differs from browsed server. */
|
/** Cover art for playback queue — uses queue server when it differs from browsed server. */
|
||||||
export function usePlaybackCoverArt(
|
export function usePlaybackCoverArt(
|
||||||
coverArtId: CoverArtId | undefined,
|
coverRef: CoverArtRef | undefined,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
): CoverArtHandle {
|
): CoverArtHandle {
|
||||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||||
@@ -23,8 +23,11 @@ export function usePlaybackCoverArt(
|
|||||||
() => resolvePlaybackCoverScope(),
|
() => resolvePlaybackCoverScope(),
|
||||||
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
[queueServerId, queueLength, activeServerId, serversFingerprint],
|
||||||
);
|
);
|
||||||
return useCoverArt(coverArtId, displayCssPx, {
|
const refWithScope = useMemo(
|
||||||
serverScope: scope,
|
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
|
||||||
|
[coverRef, scope],
|
||||||
|
);
|
||||||
|
return useCoverArt(refWithScope, displayCssPx, {
|
||||||
surface: 'sparse',
|
surface: 'sparse',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-42
@@ -2,8 +2,9 @@ import { coverCachePeekBatch } from '../api/coverCache';
|
|||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import { coverEnsureQueued } from './ensureQueue';
|
import { coverEnsureQueued } from './ensureQueue';
|
||||||
import { getDiskSrcForGrid, rememberGridDiskSrc } from './diskSrcLookup';
|
import { getDiskSrcForGrid, rememberGridDiskSrc } from './diskSrcLookup';
|
||||||
import { coverArtRef } from './ref';
|
import { albumCoverRef } from './ref';
|
||||||
import { coverIndexKeyFromRef, coverStorageKey } from './storageKeys';
|
import { resolveAlbumCoverRefFromLibrary } from './resolveEntryLibrary';
|
||||||
|
import { coverStorageKeyFromRef } from './storageKeys';
|
||||||
import { resolveCoverDisplayTier } from './tiers';
|
import { resolveCoverDisplayTier } from './tiers';
|
||||||
import type { CoverArtRef, CoverArtTier, CoverSurfaceKind } from './types';
|
import type { CoverArtRef, CoverArtTier, CoverSurfaceKind } from './types';
|
||||||
|
|
||||||
@@ -13,30 +14,66 @@ export type CoverWarmItem = {
|
|||||||
storageKey: string;
|
storageKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated Sync fallback — prefer {@link coverWarmItemFromLibrary}. */
|
||||||
export function coverWarmItem(
|
export function coverWarmItem(
|
||||||
coverArtId: string,
|
albumId: string,
|
||||||
|
fetchCoverArtId: string,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
surface: CoverSurfaceKind = 'dense',
|
surface: CoverSurfaceKind = 'dense',
|
||||||
): CoverWarmItem {
|
): CoverWarmItem {
|
||||||
const ref = coverArtRef(coverArtId);
|
const ref = albumCoverRef(albumId, fetchCoverArtId);
|
||||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
|
||||||
return {
|
return {
|
||||||
ref,
|
ref,
|
||||||
tier,
|
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<CoverWarmItem> {
|
||||||
|
const ref = await resolveAlbumCoverRefFromLibrary(albumId, fetchCoverArtId);
|
||||||
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
|
||||||
|
return {
|
||||||
|
ref,
|
||||||
|
tier,
|
||||||
|
storageKey: coverStorageKeyFromRef(ref, tier),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectAlbumCoverWarmItems(
|
export function collectAlbumCoverWarmItems(
|
||||||
albums: ReadonlyArray<{ coverArt?: string | null }>,
|
albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
surface: CoverSurfaceKind = 'dense',
|
surface: CoverSurfaceKind = 'dense',
|
||||||
limit = 96,
|
limit = 96,
|
||||||
): CoverWarmItem[] {
|
): CoverWarmItem[] {
|
||||||
const out: CoverWarmItem[] = [];
|
const out: CoverWarmItem[] = [];
|
||||||
for (const a of albums) {
|
for (const a of albums) {
|
||||||
if (!a.coverArt || out.length >= limit) break;
|
if (out.length >= limit) break;
|
||||||
out.push(coverWarmItem(a.coverArt, displayCssPx, surface));
|
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<CoverWarmItem[]> {
|
||||||
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -48,20 +85,14 @@ export async function warmCoverDiskSrcBatch(items: CoverWarmItem[]): Promise<num
|
|||||||
if (items.length === 0) return 0;
|
if (items.length === 0) return 0;
|
||||||
|
|
||||||
const hits = await coverCachePeekBatch(
|
const hits = await coverCachePeekBatch(
|
||||||
items.map(item => ({
|
items.map(item => item.ref),
|
||||||
serverIndexKey: coverIndexKeyFromRef(item.ref),
|
items[0]!.tier,
|
||||||
coverArtId: item.ref.coverArtId,
|
|
||||||
tier: item.tier,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let warmed = 0;
|
let warmed = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const path = hits[item.storageKey];
|
const path = hits[item.storageKey];
|
||||||
if (
|
if (path && rememberGridDiskSrc(item.ref, item.tier, path)) {
|
||||||
path
|
|
||||||
&& rememberGridDiskSrc(item.ref.serverScope, item.ref.coverArtId, item.tier, path)
|
|
||||||
) {
|
|
||||||
warmed += 1;
|
warmed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +101,7 @@ export async function warmCoverDiskSrcBatch(items: CoverWarmItem[]): Promise<num
|
|||||||
|
|
||||||
/** High-priority ensure for albums still missing disk `src` after peek. */
|
/** High-priority ensure for albums still missing disk `src` after peek. */
|
||||||
export async function ensureAlbumCoverMisses(
|
export async function ensureAlbumCoverMisses(
|
||||||
albums: ReadonlyArray<{ coverArt?: string | null }>,
|
albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
opts?: { surface?: CoverSurfaceKind; limit?: number },
|
opts?: { surface?: CoverSurfaceKind; limit?: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -79,23 +110,26 @@ export async function ensureAlbumCoverMisses(
|
|||||||
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
|
const tier = resolveCoverDisplayTier(displayCssPx, { surface });
|
||||||
const slice = albums.slice(0, limit);
|
const slice = albums.slice(0, limit);
|
||||||
|
|
||||||
const needEnsure = slice.filter(album => {
|
const needEnsure: Array<{ entityId: string; coverArt: string; ref: CoverArtRef }> = [];
|
||||||
if (!album.coverArt) return false;
|
for (const album of slice) {
|
||||||
return !getDiskSrcForGrid({ kind: 'active' }, album.coverArt, tier);
|
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;
|
if (needEnsure.length === 0) return;
|
||||||
|
|
||||||
const PRIME_CHUNK = 8;
|
const PRIME_CHUNK = 8;
|
||||||
for (let i = 0; i < needEnsure.length; i += PRIME_CHUNK) {
|
for (let i = 0; i < needEnsure.length; i += PRIME_CHUNK) {
|
||||||
const chunk = needEnsure.slice(i, i + PRIME_CHUNK);
|
const chunk = needEnsure.slice(i, i + PRIME_CHUNK);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
chunk.map(async album => {
|
chunk.map(async ({ ref }) => {
|
||||||
const id = album.coverArt!;
|
const key = coverStorageKeyFromRef(ref, tier);
|
||||||
const ref = coverArtRef(id);
|
|
||||||
const key = coverStorageKey(ref.serverScope, ref.coverArtId, tier);
|
|
||||||
const result = await coverEnsureQueued(key, ref, tier, 'high');
|
const result = await coverEnsureQueued(key, ref, tier, 'high');
|
||||||
if (result.hit && result.path) {
|
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.
|
* Peek + high-priority ensure so cards paint with `src` on first frame.
|
||||||
*/
|
*/
|
||||||
export async function primeAlbumCoversForDisplay(
|
export async function primeAlbumCoversForDisplay(
|
||||||
albums: ReadonlyArray<{ coverArt?: string | null }>,
|
albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
opts?: { surface?: CoverSurfaceKind; limit?: number; disabled?: boolean },
|
opts?: { surface?: CoverSurfaceKind; limit?: number; disabled?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -138,7 +172,7 @@ export async function warmHomeMainstageCovers(snapshot: {
|
|||||||
mostPlayed: SubsonicAlbum[];
|
mostPlayed: SubsonicAlbum[];
|
||||||
recentlyPlayed: SubsonicAlbum[];
|
recentlyPlayed: SubsonicAlbum[];
|
||||||
starred: SubsonicAlbum[];
|
starred: SubsonicAlbum[];
|
||||||
discoverSongs?: Array<{ coverArt?: string | null }>;
|
discoverSongs?: Array<{ albumId?: string; coverArt?: string | null }>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const items = dedupeWarmItems([
|
const items = dedupeWarmItems([
|
||||||
...collectAlbumCoverWarmItems(snapshot.heroAlbums, 220, 'dense', 12),
|
...collectAlbumCoverWarmItems(snapshot.heroAlbums, 220, 'dense', 12),
|
||||||
@@ -147,34 +181,34 @@ export async function warmHomeMainstageCovers(snapshot: {
|
|||||||
...collectAlbumCoverWarmItems(snapshot.mostPlayed, 300, 'dense', 20),
|
...collectAlbumCoverWarmItems(snapshot.mostPlayed, 300, 'dense', 20),
|
||||||
...collectAlbumCoverWarmItems(snapshot.recentlyPlayed, 300, 'dense', 20),
|
...collectAlbumCoverWarmItems(snapshot.recentlyPlayed, 300, 'dense', 20),
|
||||||
...collectAlbumCoverWarmItems(snapshot.starred, 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);
|
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 ?? [];
|
const discoverSongsForEnsure = snapshot.discoverSongs ?? [];
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
ensureAlbumCoverMisses(snapshot.heroAlbums, 220, { surface: 'dense', limit: 8 }),
|
ensureAlbumCoverMisses(snapshot.heroAlbums, 220, { surface: 'dense', limit: 8 }),
|
||||||
ensureAlbumCoverMisses(snapshot.recent, 300, { surface: 'dense', limit: 14 }),
|
ensureAlbumCoverMisses(snapshot.recent, 300, { surface: 'dense', limit: 14 }),
|
||||||
ensureAlbumCoverMisses(snapshot.random, 300, { surface: 'dense', limit: 10 }),
|
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.heroAlbums, 220, 8);
|
||||||
void predecodeWarmAlbums(snapshot.recent, 300, 10);
|
void predecodeWarmAlbums(snapshot.recent, 300, 10);
|
||||||
void predecodeWarmAlbums(snapshot.random, 300, 8);
|
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(
|
async function predecodeWarmAlbums(
|
||||||
albums: ReadonlyArray<{ coverArt?: string | null }>,
|
albums: ReadonlyArray<{ id?: string; coverArt?: string | null }>,
|
||||||
displayCssPx: number,
|
displayCssPx: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -183,7 +217,10 @@ async function predecodeWarmAlbums(
|
|||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
for (const album of albums) {
|
for (const album of albums) {
|
||||||
if (!album.coverArt || urls.length >= limit) continue;
|
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;
|
if (!src) continue;
|
||||||
urls.push(src);
|
urls.push(src);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,25 @@ import { listen } from '@tauri-apps/api/event';
|
|||||||
import {
|
import {
|
||||||
clearAllDiskSrcCache,
|
clearAllDiskSrcCache,
|
||||||
forgetDiskSrcPrefix,
|
forgetDiskSrcPrefix,
|
||||||
rememberDiskSrc,
|
|
||||||
} from '../../cover/diskSrcCache';
|
} from '../../cover/diskSrcCache';
|
||||||
|
import { rememberDiskSrcLadder } from '../../cover/diskSrcLookup';
|
||||||
import { notifyCoverDiskReady } from '../../cover/diskHandoff';
|
import { notifyCoverDiskReady } from '../../cover/diskHandoff';
|
||||||
import { invalidateCacheKey } from '../../utils/imageCache';
|
import { invalidateCacheKey } from '../../utils/imageCache';
|
||||||
import { COVER_ART_TIERS } from '../../cover/tiers';
|
import { COVER_ART_TIERS } from '../../cover/tiers';
|
||||||
import type { CoverArtTier } from '../../cover/types';
|
import type { CoverArtTier, CoverCacheKind } from '../../cover/types';
|
||||||
|
|
||||||
type CoverTierReadyPayload = {
|
type CoverTierReadyPayload = {
|
||||||
serverIndexKey: string;
|
serverIndexKey: string;
|
||||||
coverArtId: string;
|
cacheKind: CoverCacheKind;
|
||||||
|
cacheEntityId: string;
|
||||||
tier: CoverArtTier;
|
tier: CoverArtTier;
|
||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CoverEvictedPayload = {
|
type CoverEvictedPayload = {
|
||||||
serverIndexKey: string;
|
serverIndexKey: string;
|
||||||
coverArtId: string;
|
cacheKind: CoverCacheKind;
|
||||||
|
cacheEntityId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Rust → UI: disk `.webp` ready — do not invalidate IDB (that caused webview refetch storms). */
|
/** Rust → UI: disk `.webp` ready — do not invalidate IDB (that caused webview refetch storms). */
|
||||||
@@ -29,10 +31,10 @@ export function useCoverArtBridge(): void {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
await listen<CoverTierReadyPayload>('cover:tier-ready', ev => {
|
await listen<CoverTierReadyPayload>('cover:tier-ready', ev => {
|
||||||
const { serverIndexKey, coverArtId, tier, path } = ev.payload;
|
const { serverIndexKey, cacheKind, cacheEntityId, tier, path } = ev.payload;
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
const key = `${serverIndexKey}:cover:${coverArtId}:${tier}`;
|
const key = `${serverIndexKey}:cover:${cacheKind}:${cacheEntityId}:${tier}`;
|
||||||
rememberDiskSrc(key, path);
|
rememberDiskSrcLadder(serverIndexKey, { cacheKind, cacheEntityId }, tier, path);
|
||||||
notifyCoverDiskReady(key, path);
|
notifyCoverDiskReady(key, path);
|
||||||
void invalidateCacheKey(key);
|
void invalidateCacheKey(key);
|
||||||
}),
|
}),
|
||||||
@@ -44,10 +46,14 @@ export function useCoverArtBridge(): void {
|
|||||||
);
|
);
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
await listen<CoverEvictedPayload>('cover:evicted', ev => {
|
await listen<CoverEvictedPayload>('cover:evicted', ev => {
|
||||||
const { serverIndexKey, coverArtId } = ev.payload;
|
const { serverIndexKey, cacheKind, cacheEntityId } = ev.payload;
|
||||||
forgetDiskSrcPrefix(serverIndexKey, coverArtId);
|
forgetDiskSrcPrefix({
|
||||||
|
serverScope: { kind: 'active' },
|
||||||
|
cacheKind,
|
||||||
|
cacheEntityId,
|
||||||
|
});
|
||||||
for (const tier of COVER_ART_TIERS) {
|
for (const tier of COVER_ART_TIERS) {
|
||||||
notifyCoverDiskReady(`${serverIndexKey}:cover:${coverArtId}:${tier}`, '');
|
notifyCoverDiskReady(`${serverIndexKey}:cover:${cacheKind}:${cacheEntityId}:${tier}`, '');
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function useCoverNavigationPriority(): void {
|
|||||||
coverTrafficBeginNavigation();
|
coverTrafficBeginNavigation();
|
||||||
coverTrafficEndNavigation();
|
coverTrafficEndNavigation();
|
||||||
return () => {
|
return () => {
|
||||||
coverTrafficBeginNavigation();
|
coverTrafficEndNavigation();
|
||||||
};
|
};
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import { libraryIsReady } from '../utils/library/libraryReady';
|
|||||||
const TOP_UP_POLL_MS = 500;
|
const TOP_UP_POLL_MS = 500;
|
||||||
const STEADY_POLL_MS = 2000;
|
const STEADY_POLL_MS = 2000;
|
||||||
const READY_POLL_MS = 5000;
|
const READY_POLL_MS = 5000;
|
||||||
const EXHAUSTED_PAUSE_MS = 60_000;
|
const EXHAUSTED_PAUSE_MS = 15_000;
|
||||||
const COMPLETED_RECHECK_MS = 5 * 60_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 = {
|
const EMPTY_PIPELINE_STATS = {
|
||||||
pipelineWorkers: 1,
|
pipelineWorkers: 1,
|
||||||
@@ -56,6 +58,7 @@ export function useLibraryAnalysisBackfill(enabled = true): void {
|
|||||||
);
|
);
|
||||||
const cursorRef = useRef<string | null>(null);
|
const cursorRef = useRef<string | null>(null);
|
||||||
const completedTotalRef = useRef<number | null>(null);
|
const completedTotalRef = useRef<number | null>(null);
|
||||||
|
const exhaustedIdleStreakRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -69,19 +72,9 @@ export function useLibraryAnalysisBackfill(enabled = true): void {
|
|||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const serverId = activeServerId;
|
const serverId = activeServerId;
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
while (!cancelled) {
|
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) {
|
if (completedTotalRef.current !== null) {
|
||||||
const totalTracks = await libraryCountLiveTracks(serverId).catch(() => null);
|
const totalTracks = await libraryCountLiveTracks(serverId).catch(() => null);
|
||||||
if (!Number.isFinite(totalTracks)) {
|
if (!Number.isFinite(totalTracks)) {
|
||||||
@@ -94,6 +87,7 @@ export function useLibraryAnalysisBackfill(enabled = true): void {
|
|||||||
}
|
}
|
||||||
completedTotalRef.current = null;
|
completedTotalRef.current = null;
|
||||||
cursorRef.current = null;
|
cursorRef.current = null;
|
||||||
|
exhaustedIdleStreakRef.current = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await libraryIsReady(serverId))) {
|
if (!(await libraryIsReady(serverId))) {
|
||||||
@@ -145,19 +139,31 @@ export function useLibraryAnalysisBackfill(enabled = true): void {
|
|||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (batch.trackIds.length > 0) {
|
||||||
|
exhaustedIdleStreakRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (batch.exhausted) {
|
if (batch.exhausted) {
|
||||||
cursorRef.current = null;
|
cursorRef.current = null;
|
||||||
const progress = await libraryAnalysisProgress(serverId).catch(() => null);
|
const progress = await libraryAnalysisProgress(serverId).catch(() => null);
|
||||||
if (progress && progress.pendingTracks <= 0) {
|
const pending = progress?.pendingTracks ?? -1;
|
||||||
completedTotalRef.current = progress.totalTracks;
|
if (pending <= 0 && batch.trackIds.length === 0) {
|
||||||
await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS));
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, EXHAUSTED_PAUSE_MS));
|
await new Promise(r => setTimeout(r, EXHAUSTED_PAUSE_MS));
|
||||||
} else if (batch.trackIds.length === 0) {
|
} else if (batch.trackIds.length === 0) {
|
||||||
await new Promise(r => setTimeout(r, TOP_UP_POLL_MS));
|
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;
|
cancelled = true;
|
||||||
cursorRef.current = null;
|
cursorRef.current = null;
|
||||||
completedTotalRef.current = null;
|
completedTotalRef.current = null;
|
||||||
|
exhaustedIdleStreakRef.current = 0;
|
||||||
};
|
};
|
||||||
}, [strategy, activeServerId, advancedParallelism, enabled]);
|
}, [strategy, activeServerId, advancedParallelism, enabled]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
|
||||||
import { serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
|
import { coverIndexKeyFromRef } from '../cover/storageKeys';
|
||||||
import { useNowPlayingPrewarm } from './useNowPlayingPrewarm';
|
import { useNowPlayingPrewarm } from './useNowPlayingPrewarm';
|
||||||
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
|
import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
@@ -10,10 +10,14 @@ import { makeTrack } from '../test/helpers/factories';
|
|||||||
import { resetAllStores } from '../test/helpers/storeReset';
|
import { resetAllStores } from '../test/helpers/storeReset';
|
||||||
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
import { toQueueItemRefs } from '../utils/library/queueItemRef';
|
||||||
|
|
||||||
vi.mock('../api/coverCache', () => ({
|
vi.mock('../api/coverCache', async importOriginal => {
|
||||||
coverCachePeekBatch: vi.fn(async () => ({})),
|
const actual = await importOriginal<typeof import('../api/coverCache')>();
|
||||||
coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })),
|
return {
|
||||||
}));
|
...actual,
|
||||||
|
coverCachePeekBatch: vi.fn(async () => ({})),
|
||||||
|
coverCacheEnsure: vi.fn(async () => ({ hit: false, path: '', tier: 800 })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('./useNowPlayingFetchers', () => ({
|
vi.mock('./useNowPlayingFetchers', () => ({
|
||||||
prewarmNowPlayingFetchers: vi.fn(async () => undefined),
|
prewarmNowPlayingFetchers: vi.fn(async () => undefined),
|
||||||
@@ -65,10 +69,10 @@ describe('useNowPlayingPrewarm', () => {
|
|||||||
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
|
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);
|
const playbackProfile = useAuthStore.getState().servers.find(s => s.id === playback);
|
||||||
expect(playbackProfile).toBeDefined();
|
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];
|
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
|
||||||
expect(ensureRef?.serverScope.kind).toBe('server');
|
expect(ensureRef?.serverScope.kind).toBe('server');
|
||||||
});
|
});
|
||||||
@@ -95,10 +99,10 @@ describe('useNowPlayingPrewarm', () => {
|
|||||||
expect(coverCachePeekBatch).toHaveBeenCalledTimes(1);
|
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);
|
const activeProfile = useAuthStore.getState().servers.find(s => s.id === active);
|
||||||
expect(activeProfile).toBeDefined();
|
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];
|
const ensureRef = vi.mocked(coverCacheEnsure).mock.calls[0]?.[0];
|
||||||
expect(ensureRef?.serverScope).toEqual({ kind: 'active' });
|
expect(ensureRef?.serverScope).toEqual({ kind: 'active' });
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user