diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b550f5..3452cbd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -302,6 +302,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Album grids โ€” album artist on compilation cards + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1057](https://github.com/Psychotoxical/psysonic/pull/1057), reported in [#1056](https://github.com/Psychotoxical/psysonic/issues/1056)** + +* Random Albums, New Releases, All Albums, and other album grids no longer show a track artist on compilation albums when the tags set a single album artist (e.g. **Underworld** on a various-artists mix); the card matches the album page. +* Local index browse, live search, and FTS album dedupe prefer `album_artist` over per-track `artist`; Hero, Most Played, and offline pin labels use the same display helper. + + + ## [1.47.0] > **๐Ÿ™ Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here โ€” thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u) diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index 2348e480..db9e9ce5 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -49,6 +49,7 @@ type AlbumBrowseTrackRow = ( String, Option, Option, + Option, Option, Option, Option, @@ -648,8 +649,8 @@ fn build_album_from_fts( let where_sql = w.where_sql(); store.with_read_conn(|conn| { let sql = format!( - "SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ - t.genre, t.cover_art_id, t.starred_at, t.synced_at \ + "SELECT t.server_id, t.album_id, t.album, t.artist, t.album_artist, t.artist_id, \ + t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at \ FROM track t \ WHERE {where_sql}" ); @@ -668,13 +669,27 @@ fn build_album_from_fts( r.get(7)?, r.get(8)?, r.get(9)?, + r.get(10)?, )) })? .collect::>>()?; let mut seen = HashSet::new(); let mut deduped: Vec = Vec::new(); - for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows { + for ( + server_id, + album_id, + album, + track_artist, + album_artist, + artist_id, + year, + genre, + cover_art_id, + starred_at, + synced_at, + ) in rows + { if !seen.insert(album_id.clone()) { continue; } @@ -682,7 +697,10 @@ fn build_album_from_fts( server_id, id: album_id, name: album, - artist, + artist: crate::album_compilation_filter::pick_album_group_artist( + track_artist, + album_artist, + ), artist_id, song_count: None, duration_sec: None, @@ -2067,6 +2085,24 @@ mod tests { assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists")); } + #[test] + fn track_grouped_album_browse_prefers_album_artist_over_track_artist() { + let store = LibraryStore::open_in_memory(); + let mut t1 = track("s1", "t1", "Anthem", "Groove Armada", "Back to Mine"); + t1.album_id = Some("al_mix".into()); + t1.album_artist = Some("Underworld".into()); + let mut t2 = track("s1", "t2", "Zebra", "UNKLE", "Back to Mine"); + t2.album_id = Some("al_mix".into()); + t2.album_artist = Some("Underworld".into()); + TrackRepository::new(&store) + .upsert_batch(&[t1, t2]) + .unwrap(); + let r = req("s1", &[EntityKind::Album]); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].artist.as_deref(), Some("Underworld")); + } + #[test] fn compilation_filter_on_track_grouped_album_browse() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/album_compilation_filter.rs b/src-tauri/crates/psysonic-library/src/album_compilation_filter.rs index 8be040a3..5e8ab2eb 100644 --- a/src-tauri/crates/psysonic-library/src/album_compilation_filter.rs +++ b/src-tauri/crates/psysonic-library/src/album_compilation_filter.rs @@ -49,16 +49,30 @@ pub fn various_artists_label(s: &str) -> bool { s.trim().to_ascii_lowercase().contains("various artists") } -/// Track-grouped album rows: prefer album artist when it marks a VA compilation. +/// SQL mirror of [`pick_album_group_artist`] for track-grouped browse subqueries +/// (`la`). Used where `ORDER BY` / `COALESCE(a.artist, โ€ฆ)` must stay in SQL; +/// keep both implementations in sync. +pub fn sql_track_group_display_artist(alias: &str) -> String { + format!( + "CASE WHEN trim(coalesce({a}.album_artist, '')) != '' \ + THEN trim({a}.album_artist) \ + ELSE NULLIF(trim(coalesce({a}.artist, '')), '') END", + a = alias + ) +} + +/// Row-mapper form of the album-artist display rule โ€” mirror of +/// [`sql_track_group_display_artist`]. Prefer a non-empty album-artist tag; +/// fall back to track artist only when album artist is absent (solo albums without TALB). pub fn pick_album_group_artist( track_artist: Option, album_artist: Option, ) -> Option { let aa = album_artist.as_deref().unwrap_or("").trim(); - if various_artists_label(aa) { + if !aa.is_empty() { return Some(aa.to_string()); } - track_artist + track_artist.filter(|s| !s.trim().is_empty()) } #[cfg(test)] @@ -81,14 +95,70 @@ mod tests { } #[test] - fn pick_album_group_artist_prefers_va_album_artist() { + fn pick_album_group_artist_prefers_nonempty_album_artist() { assert_eq!( pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())), Some("Various Artists".to_string()) ); + assert_eq!( + pick_album_group_artist(Some("Groove Armada".into()), Some("Underworld".into())), + Some("Underworld".to_string()) + ); assert_eq!( pick_album_group_artist(Some("Alice".into()), Some("Bob".into())), - Some("Alice".to_string()) + Some("Bob".to_string()) ); } + + #[test] + fn pick_album_group_artist_falls_back_to_track_artist() { + assert_eq!( + pick_album_group_artist(Some("Alice".into()), None), + Some("Alice".to_string()) + ); + assert_eq!( + pick_album_group_artist(Some("Alice".into()), Some("".into())), + Some("Alice".to_string()) + ); + assert_eq!(pick_album_group_artist(None, None), None); + } + + #[test] + fn sql_track_group_display_artist_matches_pick_album_group_artist() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE la (artist TEXT, album_artist TEXT)", + [], + ) + .unwrap(); + let sql = format!("SELECT {} FROM la", sql_track_group_display_artist("la")); + + let cases: [(&str, &str); 7] = [ + ("Groove Armada", "Underworld"), + ("Alice", ""), + ("", "Various Artists"), + ("Alice", "Bob"), + (" ", "Bob"), + ("Alice", " "), + ("", ""), + ]; + + for (track, album) in cases { + conn.execute("DELETE FROM la", []).unwrap(); + conn.execute( + "INSERT INTO la (artist, album_artist) VALUES (?1, ?2)", + rusqlite::params![track, album], + ) + .unwrap(); + let sql_out: Option = conn.query_row(&sql, [], |r| r.get(0)).ok(); + let rust_out = pick_album_group_artist( + (!track.is_empty()).then(|| track.to_string()), + (!album.is_empty()).then(|| album.to_string()), + ); + assert_eq!( + sql_out, rust_out, + "track={track:?} album={album:?}" + ); + } + } } diff --git a/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs index 2290f996..bbbf5d71 100644 --- a/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs +++ b/src-tauri/crates/psysonic-library/src/artist_lossless_browse.rs @@ -81,12 +81,13 @@ pub fn get_artist_lossless_browse( } let album_where_sql = album_where.join(" AND "); + let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la"); let albums_sql = format!( "SELECT \ la.server_id, \ la.album_id, \ COALESCE(a.name, la.album_name), \ - COALESCE(a.artist, la.artist), \ + COALESCE(a.artist, {la_artist}), \ COALESCE(a.artist_id, la.artist_id), \ COALESCE(a.song_count, la.track_count), \ COALESCE(a.duration_sec, la.duration_sec), \ @@ -102,6 +103,7 @@ pub fn get_artist_lossless_browse( t.album_id, \ MAX(t.album) AS album_name, \ MAX(t.artist) AS artist, \ + MAX(t.album_artist) AS album_artist, \ MAX(t.artist_id) AS artist_id, \ MAX(t.year) AS year, \ MAX(t.genre) AS genre, \ diff --git a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs index 7fc7c3e9..0251ffc3 100644 --- a/src-tauri/crates/psysonic-library/src/genre_album_browse.rs +++ b/src-tauri/crates/psysonic-library/src/genre_album_browse.rs @@ -19,19 +19,20 @@ fn trimmed_nonempty(s: Option<&str>) -> Option { } fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String { + let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la"); let mut keys: Vec = Vec::new(); for s in sort { let col = match s.field.as_str() { - "name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE", - "artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE", - "year" => "COALESCE(a.year, la.year)", + "name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE".to_string(), + "artist" => format!("COALESCE(a.artist, {la_artist}) COLLATE NOCASE"), + "year" => "COALESCE(a.year, la.year)".to_string(), _ => continue, }; let dir = match s.dir { SortDir::Asc => "ASC", SortDir::Desc => "DESC", }; - keys.push(format!("{col} {dir}")); + keys.push(format!("{col} {dir}", col = col)); } if keys.is_empty() { keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string()); @@ -123,12 +124,13 @@ pub fn list_albums_by_genre( } let where_sql = where_clauses.join(" AND "); + let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la"); let sql = format!( "SELECT \ la.server_id, \ la.album_id, \ COALESCE(a.name, la.album_name), \ - COALESCE(a.artist, la.artist), \ + COALESCE(a.artist, {la_artist}), \ COALESCE(a.artist_id, la.artist_id), \ COALESCE(a.song_count, la.track_count), \ COALESCE(a.duration_sec, la.duration_sec), \ @@ -144,6 +146,7 @@ pub fn list_albums_by_genre( t.album_id, \ MAX(t.album) AS album_name, \ MAX(t.artist) AS artist, \ + MAX(t.album_artist) AS album_artist, \ MAX(t.artist_id) AS artist_id, \ MAX(t.year) AS year, \ MAX(t.genre) AS genre, \ diff --git a/src-tauri/crates/psysonic-library/src/live_search.rs b/src-tauri/crates/psysonic-library/src/live_search.rs index f1b7ae8c..b7364eed 100644 --- a/src-tauri/crates/psysonic-library/src/live_search.rs +++ b/src-tauri/crates/psysonic-library/src/live_search.rs @@ -244,8 +244,9 @@ fn query_albums( ORDER BY rank \ LIMIT ?\ ) \ - SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ - t.genre, t.cover_art_id, t.starred_at, t.synced_at, MIN(h.rank) AS best_rank \ + SELECT t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.album_artist), \ + MAX(t.artist_id), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \ + MAX(t.starred_at), MAX(t.synced_at), MIN(h.rank) AS best_rank \ FROM fts_hits h \ JOIN track t ON t.rowid = h.rowid \ WHERE t.server_id = ? \ @@ -261,24 +262,29 @@ fn query_albums( params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP)); params.push(rusqlite::types::Value::Text(server_id.to_string())); append_library_scope(&mut sql, &mut params, library_scope); - sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?"); + sql.push_str(" GROUP BY t.server_id, t.album_id ORDER BY best_rank LIMIT ?"); params.push(rusqlite::types::Value::Integer(i64::from(limit))); let mut stmt = conn.prepare(&sql)?; let mut out = Vec::new(); for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + let track_artist: Option = r.get(3)?; + let album_artist: Option = r.get(4)?; Ok(LibraryAlbumDto { server_id: r.get(0)?, id: r.get(1)?, name: r.get(2)?, - artist: r.get(3)?, - artist_id: r.get(4)?, + artist: crate::album_compilation_filter::pick_album_group_artist( + track_artist, + album_artist, + ), + artist_id: r.get(5)?, song_count: None, duration_sec: None, - year: r.get(5)?, - genre: r.get(6)?, - cover_art_id: r.get(7)?, - starred_at: r.get(8)?, - synced_at: r.get(9)?, + year: r.get(6)?, + genre: r.get(7)?, + cover_art_id: r.get(8)?, + starred_at: r.get(9)?, + synced_at: r.get(10)?, raw_json: serde_json::Value::Null, }) })? { diff --git a/src-tauri/crates/psysonic-library/src/lossless_albums.rs b/src-tauri/crates/psysonic-library/src/lossless_albums.rs index d177a9b6..58adf44c 100644 --- a/src-tauri/crates/psysonic-library/src/lossless_albums.rs +++ b/src-tauri/crates/psysonic-library/src/lossless_albums.rs @@ -44,12 +44,13 @@ pub fn list_lossless_albums( } let where_sql = where_clauses.join(" AND "); + let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la"); let sql = format!( "SELECT \ la.server_id, \ la.album_id, \ COALESCE(a.name, la.album_name), \ - COALESCE(a.artist, la.artist), \ + COALESCE(a.artist, {la_artist}), \ COALESCE(a.artist_id, la.artist_id), \ COALESCE(a.song_count, la.track_count), \ COALESCE(a.duration_sec, la.duration_sec), \ @@ -65,6 +66,7 @@ pub fn list_lossless_albums( t.album_id, \ MAX(t.album) AS album_name, \ MAX(t.artist) AS artist, \ + MAX(t.album_artist) AS album_artist, \ MAX(t.artist_id) AS artist_id, \ MAX(t.year) AS year, \ MAX(t.genre) AS genre, \ diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 64c336ad..c6b5c5fa 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -21,7 +21,7 @@ import { useLongPressAction } from '../hooks/useLongPressAction'; import { LongPressWaveOverlay } from './LongPressWaveOverlay'; import { useDragDrop } from '../contexts/DragDropContext'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; -import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; +import { albumArtistDisplayName, deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { coverServerScopeForServerId } from '../cover/serverScope'; import { appendServerQuery } from '../utils/navigation/detailServerScope'; @@ -93,6 +93,7 @@ function AlbumCard({ }, [coverRef, displayCssPx]); const isNewAlbum = isAlbumRecentlyAdded(album.created); const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]); + const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]); const handleClick = (opts?: { shiftKey?: boolean }) => { if (selectionMode) { onToggleSelect?.(album.id, opts); return; } @@ -105,7 +106,7 @@ function AlbumCard({ onClick={e => handleClick({ shiftKey: e.shiftKey })} role="button" tabIndex={0} - aria-label={`${album.name} von ${album.artist}`} + aria-label={`${album.name} von ${artistLabel}`} onKeyDown={e => e.key === 'Enter' && handleClick()} onContextMenu={(e) => { e.preventDefault(); @@ -213,7 +214,7 @@ function AlbumCard({

navigate(`/artist/${id}`)} as="none" linkTag="span" diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index 610cad38..59754efe 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -25,6 +25,7 @@ import { useLongPressAction } from '../hooks/useLongPressAction'; import { LongPressWaveOverlay } from './LongPressWaveOverlay'; import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration'; import AlbumRow from './AlbumRow'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:'; const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:'; @@ -599,6 +600,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e }); const imgSrc = coverImgSrc(coverHandle.src); const bgResolved = coverHandle.src; + const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]); const handleOpen = () => navigate(`/album/${album.id}`); const handleEnqueue = async (e: React.MouseEvent) => { e.stopPropagation(); @@ -620,7 +622,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e className={`because-card${enter ? ' because-card--slot-enter' : ''}`} onClick={handleOpen} onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }} - aria-label={`${album.name} โ€“ ${album.artist}`} + aria-label={`${album.name} โ€“ ${artistLabel}`} > {!disableArtwork && bgResolved && (

{album.name}
-
{album.artist}
+
{artistLabel}
{album.releaseTypes && album.releaseTypes[0] ? (
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 7fd371c3..dc1f4514 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -19,6 +19,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum'; import { useLongPressAction } from '../hooks/useLongPressAction'; import { LongPressWaveOverlay } from './LongPressWaveOverlay'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; const INTERVAL_MS = 10000; const HERO_ALBUM_COUNT = 8; @@ -266,6 +267,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { }, [albums.length, startTimer]); const album = albums[activeIdx] ?? null; + const heroArtistLabel = useMemo( + () => (album ? albumArtistDisplayName(album) : ''), + [album], + ); // Lazily fetch format label for the currently-visible album (cached by id) const [albumFormats, setAlbumFormats] = useState>({}); @@ -335,7 +340,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{t('hero.eyebrow')}

{album.name}

-

{album.artist}

+

{heroArtistLabel}

{album.year && {album.year}} {album.genre && {album.genre}} diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 6da2c0ee..ec7bafea 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -28,6 +28,7 @@ import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { useTranslation } from 'react-i18next'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage'; import type { SubsonicSong } from '../api/subsonicTypes'; import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage'; @@ -775,7 +776,7 @@ export default function LiveSearch() { )}
{a.name}
-
{a.artist}
+
{albumArtistDisplayName(a)}
); diff --git a/src/components/MobileSearchOverlay.tsx b/src/components/MobileSearchOverlay.tsx index 757fe4af..ee4661f1 100644 --- a/src/components/MobileSearchOverlay.tsx +++ b/src/components/MobileSearchOverlay.tsx @@ -16,6 +16,7 @@ import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage'; import { CoverArtImage } from '../cover/CoverArtImage'; import { albumCoverRefForSong } from '../cover/ref'; import { showToast } from '../utils/ui/toast'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { useShareSearch } from '../hooks/useShareSearch'; import ShareSearchResults from './search/ShareSearchResults'; import { @@ -383,7 +384,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void } )}
{a.name} - {a.artist} + {albumArtistDisplayName(a)}
diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 90f6aca7..f3b37c6b 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -38,7 +38,7 @@ import { useTranslation } from 'react-i18next'; import { showToast } from '../utils/ui/toast'; import { useSelectionStore } from '../store/selectionStore'; import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers'; -import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; +import { albumArtistDisplayName, deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { albumGridWarmCovers } from '../cover/layoutSizes'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; @@ -295,7 +295,7 @@ const handleShuffleAll = () => { } } if (isOfflinePinComplete(album.album.id, serverId, songs.map(s => s.id))) return; - downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, songs, serverId); + downloadAlbum(album.album.id, album.album.name, albumArtistDisplayName(album.album), album.album.coverArt, album.album.year, songs, serverId); }, [album, downloadAlbum, serverId, effectiveSongs, losslessOnly, resolvedOfflineStatus]); const handleRemoveOffline = () => { diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index a5cdf4c9..0b390127 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -40,6 +40,7 @@ import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset'; import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch'; import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore'; import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds'; import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; @@ -281,7 +282,7 @@ export default function Albums() { try { const detail = await resolveAlbum(serverId, album.id); if (!detail) throw new Error('album unavailable'); - downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); + downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx index 1a7ac18e..d86e3969 100644 --- a/src/pages/LosslessAlbums.tsx +++ b/src/pages/LosslessAlbums.tsx @@ -28,6 +28,7 @@ import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport'; import InpageScrollSentinel from '../components/InpageScrollSentinel'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import SortDropdown from '../components/SortDropdown'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { albumBrowseSortForServer, useAlbumBrowseSessionStore, @@ -252,7 +253,7 @@ export default function LosslessAlbums() { try { const detail = await resolveAlbum(serverId, album.id); if (!detail) throw new Error('album unavailable'); - downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); + downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx index 4100af12..0b1efdba 100644 --- a/src/pages/MostPlayed.tsx +++ b/src/pages/MostPlayed.tsx @@ -13,6 +13,7 @@ import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum'; import { useLongPressAction } from '../hooks/useLongPressAction'; import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay'; import { useTranslation } from 'react-i18next'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; const PAGE_SIZE = 50; @@ -256,7 +257,7 @@ export default function MostPlayed() { className="mp-album-artist truncate track-artist-link" onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }} > - {album.artist} + {albumArtistDisplayName(album)}
diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index fa360840..7296232f 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -34,6 +34,7 @@ import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset'; import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch'; import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters'; import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters'; import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore'; @@ -160,7 +161,7 @@ export default function NewReleases() { try { const detail = await resolveAlbum(serverId, album.id); if (!detail) throw new Error('album unavailable'); - downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); + downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index a3dcb9f2..ffc7b2a4 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -36,6 +36,7 @@ import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hook import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore'; import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters'; import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation'; +import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs'; const ALBUM_COUNT = 30; /** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */ @@ -202,7 +203,7 @@ export default function RandomAlbums() { try { const detail = await resolveAlbum(serverId, album.id); if (!detail) throw new Error('album unavailable'); - downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); + downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); diff --git a/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts b/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts index b2dac0f7..efefc5ec 100644 --- a/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts +++ b/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { deriveAlbumArtistRefs, deriveAlbumHeaderArtistRefs } from './deriveAlbumHeaderArtistRefs'; +import { + albumArtistDisplayName, + deriveAlbumArtistRefs, + deriveAlbumHeaderArtistRefs, +} from './deriveAlbumHeaderArtistRefs'; import type { SubsonicAlbum } from '../../api/subsonicTypes'; import { makeSubsonicSong } from '@/test/helpers/factories'; @@ -37,6 +41,16 @@ describe('deriveAlbumArtistRefs', () => { }; expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'a1', name: 'Solo' }]); }); + + it('prefers OpenSubsonic displayArtist over legacy artist', () => { + const album: SubsonicAlbum = { + ...baseAlbum(), + artist: 'Groove Armada', + displayArtist: 'Underworld', + }; + expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'ar-first', name: 'Underworld' }]); + expect(albumArtistDisplayName(album)).toBe('Underworld'); + }); }); describe('deriveAlbumHeaderArtistRefs', () => { diff --git a/src/utils/album/deriveAlbumHeaderArtistRefs.ts b/src/utils/album/deriveAlbumHeaderArtistRefs.ts index be57de62..5fa11818 100644 --- a/src/utils/album/deriveAlbumHeaderArtistRefs.ts +++ b/src/utils/album/deriveAlbumHeaderArtistRefs.ts @@ -13,11 +13,21 @@ function nonEmpty(refs: SubsonicOpenArtistRef[]): refs is SubsonicOpenArtistRef[ export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] { const albumArtists = coerceOpenArtistRefs(album.artists); if (nonEmpty(albumArtists)) return albumArtists; - const name = album.artist?.trim() || 'โ€”'; + const display = album.displayArtist?.trim(); + const legacy = album.artist?.trim(); + const name = display || legacy || 'โ€”'; const id = album.artistId?.trim(); return id ? [{ id, name }] : [{ name }]; } +/** Single-line album-artist label for cards and rails (matches `deriveAlbumArtistRefs`). */ +export function albumArtistDisplayName(album: SubsonicAlbum): string { + const parts = deriveAlbumArtistRefs(album) + .map(r => r.name?.trim() ?? '') + .filter(Boolean); + return parts.length > 0 ? parts.join(' ยท ') : 'โ€”'; +} + /** * OpenSubsonic album credits for the album-detail header. * Prefer the album's `artists` array, then any child song's `albumArtists` diff --git a/src/utils/export/exportNewAlbums.ts b/src/utils/export/exportNewAlbums.ts index 89ec196b..baddbba9 100644 --- a/src/utils/export/exportNewAlbums.ts +++ b/src/utils/export/exportNewAlbums.ts @@ -2,6 +2,7 @@ import { getAlbumList } from '../../api/subsonicLibrary'; import { coverArtRef } from '../../cover/ref'; import { loadCoverBlobForExport } from '../../cover/integrations/export'; import type { SubsonicAlbum } from '../../api/subsonicTypes'; +import { albumArtistDisplayName } from '../album/deriveAlbumHeaderArtistRefs'; import { writeFile } from '@tauri-apps/plugin-fs'; import { downloadDir, join } from '@tauri-apps/api/path'; import { useAuthStore } from '../../store/authStore'; @@ -190,7 +191,7 @@ async function renderPage( const lineY = coverY + COVER_SIZE / 2 + 6; const sep = ' โ€” '; - const artistClamp = clampText(ctx, album.artist, TEXT_W * 0.42); + const artistClamp = clampText(ctx, albumArtistDisplayName(album), TEXT_W * 0.42); const artistW = ctx.measureText(artistClamp).width; const sepW = ctx.measureText(sep).width; const remaining = TEXT_W - artistW - sepW; diff --git a/src/utils/offline/offlineLibraryHelpers.ts b/src/utils/offline/offlineLibraryHelpers.ts index c382176a..cdd11915 100644 --- a/src/utils/offline/offlineLibraryHelpers.ts +++ b/src/utils/offline/offlineLibraryHelpers.ts @@ -276,7 +276,10 @@ export async function hydrateOfflineLibraryCards( ? (group.pinSource.displayName ?? first?.artist ?? '') : pinKind === 'playlist' ? '' - : (first?.artist ?? first?.albumArtist ?? ''); + : (pinnedMeta?.artist?.trim() + || first?.albumArtist?.trim() + || first?.artist?.trim() + || ''); let coverArt: string | undefined; let coverQuadIds: (string | null)[] | undefined; diff --git a/src/utils/offline/offlineLocalBrowse.ts b/src/utils/offline/offlineLocalBrowse.ts index 460d8f6f..32fe8845 100644 --- a/src/utils/offline/offlineLocalBrowse.ts +++ b/src/utils/offline/offlineLocalBrowse.ts @@ -63,7 +63,7 @@ export function buildAlbumFromTracks( return { id: albumId, name: first.album ?? albumId, - artist: first.artist ?? first.albumArtist ?? '', + artist: first.albumArtist ?? first.artist ?? '', artistId: first.artistId ?? '', coverArt: resolveTrackCoverArtId(first) ?? albumId, year: first.year ?? undefined,