mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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:
@@ -34,6 +34,13 @@ enum ScanMode {
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Candidate SQL skips tracks that already have `content_hash` and an analysis BPM
|
||||
/// fact. Those rows can still need waveform/LUFS — the full-table pass must start
|
||||
/// from the first id, not from the last candidate cursor.
|
||||
fn begin_full_library_scan() -> (ScanMode, Option<String>) {
|
||||
(ScanMode::Full, None)
|
||||
}
|
||||
|
||||
pub fn collect_analysis_backfill_batch(
|
||||
app: &AppHandle,
|
||||
runtime: &LibraryRuntime,
|
||||
@@ -63,7 +70,7 @@ pub fn collect_analysis_backfill_batch(
|
||||
if page.is_empty() {
|
||||
match mode {
|
||||
ScanMode::Candidates => {
|
||||
mode = ScanMode::Full;
|
||||
(mode, after) = begin_full_library_scan();
|
||||
continue;
|
||||
}
|
||||
ScanMode::Full => {
|
||||
@@ -98,7 +105,7 @@ pub fn collect_analysis_backfill_batch(
|
||||
if page_len < SCAN_CHUNK {
|
||||
match mode {
|
||||
ScanMode::Candidates => {
|
||||
mode = ScanMode::Full;
|
||||
(mode, after) = begin_full_library_scan();
|
||||
}
|
||||
ScanMode::Full => {
|
||||
return Ok(LibraryAnalysisBackfillBatchDto {
|
||||
|
||||
@@ -16,6 +16,7 @@ use psysonic_integration::subsonic::SubsonicClient;
|
||||
|
||||
use crate::advanced_search;
|
||||
use crate::analysis_backfill::{self, LibraryAnalysisBackfillBatchDto, LibraryAnalysisProgressDto};
|
||||
use crate::cover_resolve::CoverEntryDto;
|
||||
use crate::cross_server;
|
||||
use crate::dto::{
|
||||
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
||||
@@ -51,6 +52,28 @@ pub struct LibraryServerKeyMigrationDto {
|
||||
pub index_key: String,
|
||||
}
|
||||
|
||||
/// Resolve cover disk + fetch ids from the local library (`album` | `artist` | `track`).
|
||||
#[tauri::command]
|
||||
pub fn library_resolve_cover_entry(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
server_id: String,
|
||||
entity: String,
|
||||
entity_id: String,
|
||||
) -> Result<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]
|
||||
pub fn library_analysis_backfill_batch(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Library cursor scan for background cover disk warm-up.
|
||||
//!
|
||||
//! Cover IDs for backfill come from **track** + **album** rows using
|
||||
//! `COALESCE(cover_art_id, album_id)` (album table id as fallback).
|
||||
//! Artist IDs are excluded — `getCoverArt` with `artist_id` often 404s and stalled the queue.
|
||||
//! Catalog rows come from SQLite (`album` / `artist` tables) with explicit `kind`.
|
||||
//! On-disk paths — `psysonic_core::cover_cache_layout`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use psysonic_core::cover_cache_layout::{self, is_fetch_only_cover_id};
|
||||
use crate::cover_resolve::{
|
||||
cover_backfill_items_for_album, resolve_album_cover_entry, resolve_artist_cover_entry,
|
||||
CoverEntryDto,
|
||||
};
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
const DEFAULT_BATCH: u32 = 32;
|
||||
@@ -13,9 +17,19 @@ const MAX_BATCH: u32 = 48;
|
||||
const SCAN_PAGE: i64 = 256;
|
||||
const MAX_SCAN_PAGES: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoverBackfillItem {
|
||||
pub cache_kind: String,
|
||||
pub cache_entity_id: String,
|
||||
pub fetch_cover_art_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryCoverBackfillBatchDto {
|
||||
pub items: Vec<CoverBackfillItem>,
|
||||
/// Entity ids only — compatibility shim for older callers.
|
||||
pub cover_ids: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub exhausted: bool,
|
||||
@@ -29,26 +43,108 @@ pub struct LibraryCoverProgressDto {
|
||||
pub done: i64,
|
||||
}
|
||||
|
||||
const COVER_ID_SUBQUERY: &str = "
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(album_id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
||||
FROM track
|
||||
WHERE server_id = ?1 AND deleted = 0
|
||||
AND (
|
||||
NULLIF(TRIM(album_id), '') IS NOT NULL
|
||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
||||
)
|
||||
UNION
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(id), ''), NULLIF(TRIM(cover_art_id), '')) AS id
|
||||
/// `kind`, entity `id`, and HTTP `getCoverArt` id (Navidrome `cover_art_id` or fallback to entity id).
|
||||
///
|
||||
/// The `artist` table is often empty (IS-4 only stores a watermark). Artists are also taken from
|
||||
/// `track.artist_id` and `album.artist_id` so backfill matches library browse / prefetch.
|
||||
const COVER_CATALOG_SUBQUERY: &str = "
|
||||
SELECT 'album' AS kind,
|
||||
TRIM(id) AS id,
|
||||
COALESCE(NULLIF(TRIM(cover_art_id), ''), TRIM(id)) AS fetch_id
|
||||
FROM album
|
||||
WHERE server_id = ?1
|
||||
AND (
|
||||
NULLIF(TRIM(id), '') IS NOT NULL
|
||||
OR NULLIF(TRIM(cover_art_id), '') IS NOT NULL
|
||||
)";
|
||||
WHERE server_id = ?1 AND NULLIF(TRIM(id), '') IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'album',
|
||||
TRIM(album_id),
|
||||
COALESCE(NULLIF(TRIM(cover_art_id), ''), TRIM(album_id))
|
||||
FROM track
|
||||
WHERE server_id = ?1 AND deleted = 0 AND NULLIF(TRIM(album_id), '') IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'artist',
|
||||
TRIM(id),
|
||||
TRIM(id)
|
||||
FROM artist
|
||||
WHERE server_id = ?1 AND NULLIF(TRIM(id), '') IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'artist',
|
||||
TRIM(artist_id),
|
||||
TRIM(artist_id)
|
||||
FROM track
|
||||
WHERE server_id = ?1 AND deleted = 0 AND NULLIF(TRIM(artist_id), '') IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'artist',
|
||||
TRIM(artist_id),
|
||||
TRIM(artist_id)
|
||||
FROM album
|
||||
WHERE server_id = ?1 AND NULLIF(TRIM(artist_id), '') IS NOT NULL";
|
||||
|
||||
/// Composite catalog cursor: `{kind}\x1f{id}` — avoids skipping rows when ids collide across kinds.
|
||||
const CURSOR_SEP: char = '\x1f';
|
||||
|
||||
fn format_catalog_cursor(kind: &str, id: &str) -> String {
|
||||
format!("{kind}{CURSOR_SEP}{id}")
|
||||
}
|
||||
|
||||
fn parse_catalog_cursor(cursor: &str) -> (String, String) {
|
||||
if let Some((kind, id)) = cursor.split_once(CURSOR_SEP) {
|
||||
return (kind.to_string(), id.to_string());
|
||||
}
|
||||
// Legacy id-only cursors (pre composite): continue album scan by id.
|
||||
("album".to_string(), cursor.to_string())
|
||||
}
|
||||
|
||||
pub const COVER_FETCH_FAIL_MARKER: &str = ".fetch-failed";
|
||||
|
||||
/// Recent HTTP failure — skip in backfill cursor so slots go to fetchable album art.
|
||||
fn dto_to_backfill_item(dto: CoverEntryDto) -> CoverBackfillItem {
|
||||
CoverBackfillItem {
|
||||
cache_kind: dto.cache_kind,
|
||||
cache_entity_id: dto.cache_entity_id,
|
||||
fetch_cover_art_id: dto.fetch_cover_art_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-resolve catalog row through `cover_resolve` (multi-CD per-disc `mf-*` slots, …).
|
||||
fn expand_backfill_items(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
item: CoverBackfillItem,
|
||||
) -> Result<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 {
|
||||
let marker = cover_dir.join(COVER_FETCH_FAIL_MARKER);
|
||||
let Ok(meta) = std::fs::metadata(&marker) else {
|
||||
@@ -66,45 +162,76 @@ pub fn cover_fetch_recently_failed(cover_dir: &Path) -> bool {
|
||||
/// Remove `.fetch-failed` markers so the next library pass retries HTTP.
|
||||
pub fn clear_cover_fetch_failures(cover_root: &Path, server_index_key: &str) -> u32 {
|
||||
let server_dir = cover_root.join(server_index_key);
|
||||
let Ok(entries) = std::fs::read_dir(&server_dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut cleared = 0u32;
|
||||
for id_dir in entries.flatten() {
|
||||
let marker = id_dir.path().join(COVER_FETCH_FAIL_MARKER);
|
||||
if marker.is_file() && std::fs::remove_file(&marker).is_ok() {
|
||||
cleared += 1;
|
||||
for kind in cover_cache_layout::SEGMENT_KINDS {
|
||||
let kind_dir = server_dir.join(kind);
|
||||
let Ok(entries) = std::fs::read_dir(&kind_dir) else {
|
||||
continue;
|
||||
};
|
||||
for ent in entries.flatten() {
|
||||
if !ent.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let marker = ent.path().join(COVER_FETCH_FAIL_MARKER);
|
||||
if marker.is_file() && std::fs::remove_file(&marker).is_ok() {
|
||||
cleared += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
cleared
|
||||
}
|
||||
|
||||
fn fetch_cover_id_page(
|
||||
fn fetch_catalog_page(
|
||||
store: &LibraryStore,
|
||||
library_server_id: &str,
|
||||
after: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<String>, String> {
|
||||
) -> Result<Vec<CoverBackfillItem>, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
let (after_kind, after_id) = parse_catalog_cursor(after);
|
||||
let sql = format!(
|
||||
"SELECT id FROM ({COVER_ID_SUBQUERY})
|
||||
WHERE id > ?2
|
||||
ORDER BY id ASC
|
||||
LIMIT ?3"
|
||||
"SELECT kind, id, fetch_id FROM (
|
||||
SELECT kind, id, MAX(fetch_id) AS fetch_id
|
||||
FROM ({COVER_CATALOG_SUBQUERY})
|
||||
GROUP BY kind, id
|
||||
)
|
||||
WHERE kind > ?2 OR (kind = ?2 AND id > ?3)
|
||||
ORDER BY kind ASC, id ASC
|
||||
LIMIT ?4"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let ids = stmt
|
||||
.query_map(rusqlite::params![library_server_id, after, limit], |row| {
|
||||
row.get::<_, String>(0)
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params![library_server_id, after_kind, after_id, limit],
|
||||
|row| {
|
||||
let kind: String = row.get(0)?;
|
||||
let id: String = row.get(1)?;
|
||||
let fetch_id: String = row.get(2)?;
|
||||
Ok(CoverBackfillItem {
|
||||
cache_kind: kind,
|
||||
cache_entity_id: id.clone(),
|
||||
fetch_cover_art_id: fetch_id,
|
||||
})
|
||||
})?
|
||||
.collect::<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> {
|
||||
store.with_read_conn(|conn| {
|
||||
let sql = format!("SELECT COUNT(*) FROM ({COVER_ID_SUBQUERY})");
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM (
|
||||
SELECT kind, id FROM ({COVER_CATALOG_SUBQUERY})
|
||||
GROUP BY kind, id
|
||||
)"
|
||||
);
|
||||
conn.query_row(&sql, rusqlite::params![library_server_id], |row| row.get(0))
|
||||
})
|
||||
}
|
||||
@@ -126,21 +253,27 @@ fn cover_ladder_complete_on_disk(dir: &Path) -> bool {
|
||||
.all(|&tier| tier_file_ready(dir, tier))
|
||||
}
|
||||
|
||||
fn cover_cache_dir(cover_root: &Path, server_index_key: &str, kind: &str, entity_id: &str) -> PathBuf {
|
||||
cover_cache_layout::cover_dir(cover_root, server_index_key, kind, entity_id)
|
||||
}
|
||||
|
||||
pub fn cover_canonical_cached_on_disk(
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cover_art_id: &str,
|
||||
cache_kind: &str,
|
||||
cache_entity_id: &str,
|
||||
) -> bool {
|
||||
let dir = cover_root.join(server_index_key).join(cover_art_id);
|
||||
let dir = cover_cache_dir(cover_root, server_index_key, cache_kind, cache_entity_id);
|
||||
tier_file_ready(&dir, LIBRARY_COVER_CANONICAL_TIER)
|
||||
}
|
||||
|
||||
pub fn cover_ladder_cached_on_disk(
|
||||
cover_root: &Path,
|
||||
server_index_key: &str,
|
||||
cover_art_id: &str,
|
||||
cache_kind: &str,
|
||||
cache_entity_id: &str,
|
||||
) -> bool {
|
||||
let dir = cover_root.join(server_index_key).join(cover_art_id);
|
||||
let dir = cover_cache_dir(cover_root, server_index_key, cache_kind, cache_entity_id);
|
||||
cover_ladder_complete_on_disk(&dir)
|
||||
}
|
||||
|
||||
@@ -161,32 +294,51 @@ pub fn collect_cover_backfill_batch(
|
||||
if pending.len() >= want {
|
||||
break;
|
||||
}
|
||||
let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
let page = fetch_catalog_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
let page_len = page.len();
|
||||
if page.is_empty() {
|
||||
sql_exhausted = true;
|
||||
break;
|
||||
}
|
||||
for id in &page {
|
||||
after.clone_from(id);
|
||||
let dir = cover_root.join(server_index_key).join(id);
|
||||
if cover_canonical_cached_on_disk(cover_root, server_index_key, id)
|
||||
|| cover_fetch_recently_failed(&dir)
|
||||
{
|
||||
continue;
|
||||
for item in page {
|
||||
after = format_catalog_cursor(&item.cache_kind, &item.cache_entity_id);
|
||||
for normalized in expand_backfill_items(store, library_server_id, item)? {
|
||||
if cover_canonical_cached_on_disk(
|
||||
cover_root,
|
||||
server_index_key,
|
||||
&normalized.cache_kind,
|
||||
&normalized.cache_entity_id,
|
||||
) || cover_fetch_recently_failed(&cover_cache_dir(
|
||||
cover_root,
|
||||
server_index_key,
|
||||
&normalized.cache_kind,
|
||||
&normalized.cache_entity_id,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
pending.push(normalized);
|
||||
if pending.len() >= want {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pending.push(id.clone());
|
||||
if pending.len() >= want {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (page.len() as i64) < SCAN_PAGE {
|
||||
if (page_len as i64) < SCAN_PAGE {
|
||||
sql_exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let cover_ids = pending
|
||||
.iter()
|
||||
.map(|i| i.cache_entity_id.clone())
|
||||
.collect();
|
||||
|
||||
Ok(LibraryCoverBackfillBatchDto {
|
||||
cover_ids: pending,
|
||||
items: pending,
|
||||
cover_ids,
|
||||
next_cursor: if sql_exhausted { None } else { Some(after) },
|
||||
exhausted: sql_exhausted,
|
||||
})
|
||||
@@ -202,17 +354,25 @@ pub fn count_pending_canonical_covers(
|
||||
let mut after = String::new();
|
||||
let mut pending = 0i64;
|
||||
loop {
|
||||
let page = fetch_cover_id_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
let page = fetch_catalog_page(store, library_server_id, &after, SCAN_PAGE)?;
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
for id in &page {
|
||||
after.clone_from(id);
|
||||
if !cover_canonical_cached_on_disk(cover_root, server_index_key, id) {
|
||||
pending += 1;
|
||||
let page_len = page.len();
|
||||
for item in page {
|
||||
after = format_catalog_cursor(&item.cache_kind, &item.cache_entity_id);
|
||||
for normalized in expand_backfill_items(store, library_server_id, item)? {
|
||||
if !cover_canonical_cached_on_disk(
|
||||
cover_root,
|
||||
server_index_key,
|
||||
&normalized.cache_kind,
|
||||
&normalized.cache_entity_id,
|
||||
) {
|
||||
pending += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (page.len() as i64) < SCAN_PAGE {
|
||||
if (page_len as i64) < SCAN_PAGE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -279,6 +439,24 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_includes_navidrome_bare_album_id() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "0DurV2S7arIOBQVEknOPWX", None);
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
Path::new("/tmp/empty-cover-root"),
|
||||
"srv-host",
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.cover_ids, vec!["0DurV2S7arIOBQVEknOPWX".to_string()]);
|
||||
assert_eq!(batch.items[0].cache_kind, "album");
|
||||
assert_eq!(batch.items[0].fetch_cover_art_id, "0DurV2S7arIOBQVEknOPWX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_uses_track_album_id_when_cover_art_null() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
@@ -295,13 +473,39 @@ mod tests {
|
||||
assert_eq!(batch.cover_ids, vec!["al-99".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_uses_stored_cover_art_id_for_fetch() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(
|
||||
&store,
|
||||
"srv",
|
||||
"tr1",
|
||||
"ca78bec6a62f3cb0ff31b2682ba05410",
|
||||
Some("al-ca78bec6a62f3cb0ff31b2682ba05410_60fc987f"),
|
||||
);
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
Path::new("/tmp/empty-cover-root"),
|
||||
"srv-host",
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.items[0].cache_entity_id, "ca78bec6a62f3cb0ff31b2682ba05410");
|
||||
assert_eq!(
|
||||
batch.items[0].fetch_cover_art_id,
|
||||
"al-ca78bec6a62f3cb0ff31b2682ba05410_60fc987f"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_skips_when_canonical_800_exists() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-partial", None);
|
||||
let root = std::env::temp_dir().join("psysonic-cover-backfill-test");
|
||||
let host = "srv-host";
|
||||
let id_dir = root.join(host).join("al-partial");
|
||||
let id_dir = cover_cache_layout::cover_dir(&root, host, "album", "al-partial");
|
||||
std::fs::create_dir_all(&id_dir).unwrap();
|
||||
std::fs::write(id_dir.join("128.webp"), b"x").unwrap();
|
||||
|
||||
@@ -332,11 +536,120 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_distinct_includes_artist_ids() {
|
||||
fn backfill_includes_per_disc_mf_when_discs_differ() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-1", Some("cv-1"));
|
||||
store
|
||||
.with_conn_mut("seed_box", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO album (server_id, id, name, synced_at, raw_json)
|
||||
VALUES ('srv', 'al-box', 'Box', 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, disc_number, duration_sec, deleted, synced_at, raw_json, cover_art_id
|
||||
) VALUES ('srv', 'tr1', 't', 'Box', 'al-box', 1, 200, 0, 1, '{}', 'mf-a')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, disc_number, duration_sec, deleted, synced_at, raw_json, cover_art_id
|
||||
) VALUES ('srv', 'tr2', 't', 'Box', 'al-box', 2, 200, 0, 1, '{}', 'mf-b')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
Path::new("/tmp/empty-cover-root"),
|
||||
"srv-host",
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
let ids: Vec<_> = batch
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.cache_entity_id.as_str())
|
||||
.collect();
|
||||
assert!(ids.contains(&"mf-a"));
|
||||
assert!(ids.contains(&"mf-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_includes_artists_from_track_without_artist_table() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn_mut("test_artist_track", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json
|
||||
) VALUES ('srv', 'tr1', 't', 'al', 'al-1', 'ar-from-track', 200, 0, 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
Path::new("/tmp/empty-cover-root"),
|
||||
"srv-host",
|
||||
None,
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.items.len(), 2);
|
||||
assert!(batch.items.iter().any(|i| i.cache_kind == "album" && i.cache_entity_id == "al-1"));
|
||||
assert!(
|
||||
batch
|
||||
.items
|
||||
.iter()
|
||||
.any(|i| i.cache_kind == "artist" && i.cache_entity_id == "ar-from-track")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_cursor_kind_then_id_orders_artists_after_albums() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
store
|
||||
.with_conn_mut("seed", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json
|
||||
) VALUES ('srv', 'tr1', 't', 'al', 'al-z-last', 'ar-1', 200, 0, 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let batch = collect_cover_backfill_batch(
|
||||
&store,
|
||||
"srv",
|
||||
Path::new("/tmp/x"),
|
||||
"host",
|
||||
Some("album\x1fal-z-last"),
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(batch.items.len(), 1);
|
||||
assert_eq!(batch.items[0].cache_kind, "artist");
|
||||
assert_eq!(batch.items[0].cache_entity_id, "ar-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_distinct_includes_albums_and_artists_not_mf() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
seed_track(&store, "srv", "tr1", "al-1", Some("mf-1"));
|
||||
store
|
||||
.with_conn_mut("test_artist", |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO artist (server_id, id, name, synced_at, raw_json)
|
||||
VALUES ('srv', 'ar-1', 'A', 1, '{}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO track (
|
||||
server_id, id, title, album, album_id, artist_id, duration_sec, deleted, synced_at, raw_json
|
||||
@@ -347,6 +660,6 @@ mod tests {
|
||||
})
|
||||
.unwrap();
|
||||
let n = count_distinct_cover_ids(&store, "srv").unwrap();
|
||||
assert_eq!(n, 2); // cv-1, al-1 — artist ids excluded from backfill catalog
|
||||
assert_eq!(n, 3); // al-1, al-2, ar-1 — mf-1 is not an entity id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 artist_lossless_browse;
|
||||
pub mod cover_backfill;
|
||||
pub mod cover_resolve;
|
||||
pub mod canonical;
|
||||
pub mod commands;
|
||||
pub mod cross_server;
|
||||
|
||||
Reference in New Issue
Block a user