diff --git a/src-tauri/crates/app/src/commands/stats.rs b/src-tauri/crates/app/src/commands/stats.rs index d8855242..c20e9a2c 100644 --- a/src-tauri/crates/app/src/commands/stats.rs +++ b/src-tauri/crates/app/src/commands/stats.rs @@ -234,6 +234,11 @@ struct TopArtistRaw { listened_ms: i64, picture_url: Option, picture_hash: Option, + /// Local sidecar image (`artist.jpg` / `.jpg`), via + /// `artist.artwork_id`. Independent of the Deezer cache below — + /// an artist can have one, both, or neither. + artwork_hash: Option, + artwork_format: Option, } #[tauri::command] @@ -243,11 +248,21 @@ pub async fn stats_top_artists( limit: i64, ) -> AppResult> { let pool = state.require_profile_pool().await?; - stats_top_artists_inner(&pool, &state.paths.metadata_artwork_dir, &range, limit).await + let profile_id = state.require_profile_id().await?; + let artwork_dir = state.paths.profile_artwork_dir(profile_id); + stats_top_artists_inner( + &pool, + &artwork_dir, + &state.paths.metadata_artwork_dir, + &range, + limit, + ) + .await } async fn stats_top_artists_inner( pool: &SqlitePool, + artwork_dir: &Path, metadata_dir: &Path, range: &str, limit: i64, @@ -261,10 +276,13 @@ async fn stats_top_artists_inner( COUNT(*) AS plays, COALESCE(SUM(pe.listened_ms), 0) AS listened_ms, da.picture_url AS picture_url, - da.picture_hash AS picture_hash + da.picture_hash AS picture_hash, + aw.hash AS artwork_hash, + aw.format AS artwork_format FROM play_event pe JOIN track_artist ta ON ta.track_id = pe.track_id JOIN artist ar ON ar.id = ta.artist_id + LEFT JOIN artwork aw ON aw.id = ar.artwork_id LEFT JOIN app.metadata_artist da ON da.deezer_id = ar.deezer_id WHERE (?1 IS NULL OR pe.played_at >= ?1) GROUP BY ar.id @@ -280,20 +298,50 @@ async fn stats_top_artists_inner( let rows = raw .into_iter() .map(|r| { - let (picture_path_1x, picture_path_2x) = match r.picture_hash.as_deref() { - Some(h) => crate::thumbnails::thumbnail_paths_for(metadata_dir, h), - None => (None, None), + // A user-supplied sidecar image wins over the Deezer cache, + // matching how the artist list + artist page resolve it + // (`list_artists` / `expand_artist_rows`). Before this join + // existed, an artist with only a local image resolved to + // nothing here and the row fell back to its initial — + // while the very same artist showed a picture elsewhere + // in the app (issue #453). + let local = r.artwork_hash.as_deref().zip(r.artwork_format.as_deref()); + let local_full = local + .map(|(hash, format)| artwork_dir.join(format!("{hash}.{format}"))) + .filter(|p| p.exists()) + .map(|p| p.to_string_lossy().into_owned()); + + let (picture_path, picture_path_1x, picture_path_2x) = match (&local_full, local) { + (Some(full), Some((hash, _))) => { + let (p1, p2) = crate::thumbnails::thumbnail_paths_for(artwork_dir, hash); + (Some(full.clone()), p1, p2) + } + _ => { + let (p1, p2) = match r.picture_hash.as_deref() { + Some(h) => crate::thumbnails::thumbnail_paths_for(metadata_dir, h), + None => (None, None), + }; + let full = r + .picture_hash + .as_deref() + .and_then(|h| crate::metadata_artwork::existing_path(metadata_dir, h)); + (full, p1, p2) + } }; + TopArtistRow { artist_id: r.artist_id, name: r.name, plays: r.plays, listened_ms: r.listened_ms, - picture_path: r - .picture_hash - .as_deref() - .and_then(|h| crate::metadata_artwork::existing_path(metadata_dir, h)), - picture_url: r.picture_url, + picture_path, + // Only meaningful as a Deezer remote fallback; a local + // image is served from disk, never over the network. + picture_url: if local_full.is_some() { + None + } else { + r.picture_url + }, picture_path_1x, picture_path_2x, } @@ -589,7 +637,7 @@ pub async fn export_stats_json( let overview = stats_overview_inner(&pool, &range).await?; let top_tracks = stats_top_tracks_inner(&pool, &artwork_dir, &range, 100).await?; - let top_artists = stats_top_artists_inner(&pool, metadata_dir, &range, 100).await?; + let top_artists = stats_top_artists_inner(&pool, &artwork_dir, metadata_dir, &range, 100).await?; let top_albums = stats_top_albums_inner(&pool, &artwork_dir, &range, 100).await?; let top_genres = stats_top_genres_inner(&pool, &range, 100).await?; let listening_by_day = stats_listening_by_day_inner(&pool, &range).await?; diff --git a/src/components/views/statistics/BarChart.tsx b/src/components/views/statistics/BarChart.tsx index 539661f6..41680931 100644 --- a/src/components/views/statistics/BarChart.tsx +++ b/src/components/views/statistics/BarChart.tsx @@ -97,11 +97,25 @@ export function BarChart({ ); })} + {/* + Labels sit in one cell per bar. At 24 bars in a third-width + column (the "by hour" chart on a 1080p screen with the sidebar + open) a cell is narrower than "00", so clipping it to the cell + chopped the hour in half — issue #453. + + When `thinLabels` thins the row out, the neighbouring cells are + empty strings, so a rendered label can safely overflow into + them: `overflow-visible` + `whitespace-nowrap` lets it stay + centred and complete. With every cell filled (labelStep === 1) + that would overlap real text, so clipping stays on there. + */}
{data.map((d, i) => (
1 ? "overflow-visible whitespace-nowrap" : "truncate" + }`} style={{ width: `${barWidthPct}%` }} > {i % labelStep === 0 ? d.label : ""} diff --git a/src/components/views/statistics/KpiCard.tsx b/src/components/views/statistics/KpiCard.tsx index 604d1381..4a4ed200 100644 --- a/src/components/views/statistics/KpiCard.tsx +++ b/src/components/views/statistics/KpiCard.tsx @@ -9,7 +9,12 @@ interface KpiCardProps { export function KpiCard({ icon, label, value, hint }: KpiCardProps) { return ( -
+ // `h-full` so a card with a `hint` (a third line — only "unique + // tracks" has one today) doesn't stand taller than its neighbours: + // the grid stretches the wrapper, but the card inside would + // otherwise keep its content height and leave the row ragged + // along the bottom edge (issue #453). +
{icon}