mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
* fix(library): show album artist in album grids (#1056) Prefer album-artist tags over track artist when building album rows from the local index, and align grid cards with OpenSubsonic displayArtist. * fix(library): align album artist in FTS, search, and offline paths (#1056) Apply album-artist preference in FTS album dedupe and live search, fix offline pin hydration order, and use albumArtistDisplayName in remaining cheap UI/export/download call sites. * docs(changelog): album artist grid fix for compilations (PR #1057) * fix(library): parity guard and live-search album artist helper (#1056) Align SQL ELSE branch with pick_album_group_artist trimming, add parity test, and use albumArtistDisplayName in LiveSearch and MobileSearchOverlay.
This commit is contained in:
@@ -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]
|
## [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)
|
> **🙏 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)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type AlbumBrowseTrackRow = (
|
|||||||
String,
|
String,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
Option<i64>,
|
Option<i64>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
@@ -648,8 +649,8 @@ fn build_album_from_fts(
|
|||||||
let where_sql = w.where_sql();
|
let where_sql = w.where_sql();
|
||||||
store.with_read_conn(|conn| {
|
store.with_read_conn(|conn| {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
|
"SELECT t.server_id, t.album_id, t.album, t.artist, t.album_artist, t.artist_id, \
|
||||||
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
|
t.year, t.genre, t.cover_art_id, t.starred_at, t.synced_at \
|
||||||
FROM track t \
|
FROM track t \
|
||||||
WHERE {where_sql}"
|
WHERE {where_sql}"
|
||||||
);
|
);
|
||||||
@@ -668,13 +669,27 @@ fn build_album_from_fts(
|
|||||||
r.get(7)?,
|
r.get(7)?,
|
||||||
r.get(8)?,
|
r.get(8)?,
|
||||||
r.get(9)?,
|
r.get(9)?,
|
||||||
|
r.get(10)?,
|
||||||
))
|
))
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
let mut deduped: Vec<LibraryAlbumDto> = Vec::new();
|
let mut deduped: Vec<LibraryAlbumDto> = 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()) {
|
if !seen.insert(album_id.clone()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -682,7 +697,10 @@ fn build_album_from_fts(
|
|||||||
server_id,
|
server_id,
|
||||||
id: album_id,
|
id: album_id,
|
||||||
name: album,
|
name: album,
|
||||||
artist,
|
artist: crate::album_compilation_filter::pick_album_group_artist(
|
||||||
|
track_artist,
|
||||||
|
album_artist,
|
||||||
|
),
|
||||||
artist_id,
|
artist_id,
|
||||||
song_count: None,
|
song_count: None,
|
||||||
duration_sec: None,
|
duration_sec: None,
|
||||||
@@ -2067,6 +2085,24 @@ mod tests {
|
|||||||
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
|
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]
|
#[test]
|
||||||
fn compilation_filter_on_track_grouped_album_browse() {
|
fn compilation_filter_on_track_grouped_album_browse() {
|
||||||
let store = LibraryStore::open_in_memory();
|
let store = LibraryStore::open_in_memory();
|
||||||
|
|||||||
@@ -49,16 +49,30 @@ pub fn various_artists_label(s: &str) -> bool {
|
|||||||
s.trim().to_ascii_lowercase().contains("various artists")
|
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(
|
pub fn pick_album_group_artist(
|
||||||
track_artist: Option<String>,
|
track_artist: Option<String>,
|
||||||
album_artist: Option<String>,
|
album_artist: Option<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let aa = album_artist.as_deref().unwrap_or("").trim();
|
let aa = album_artist.as_deref().unwrap_or("").trim();
|
||||||
if various_artists_label(aa) {
|
if !aa.is_empty() {
|
||||||
return Some(aa.to_string());
|
return Some(aa.to_string());
|
||||||
}
|
}
|
||||||
track_artist
|
track_artist.filter(|s| !s.trim().is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -81,14 +95,70 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pick_album_group_artist_prefers_va_album_artist() {
|
fn pick_album_group_artist_prefers_nonempty_album_artist() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
|
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
|
||||||
Some("Various Artists".to_string())
|
Some("Various Artists".to_string())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_album_group_artist(Some("Groove Armada".into()), Some("Underworld".into())),
|
||||||
|
Some("Underworld".to_string())
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
|
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<String> = 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,12 +81,13 @@ pub fn get_artist_lossless_browse(
|
|||||||
}
|
}
|
||||||
let album_where_sql = album_where.join(" AND ");
|
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!(
|
let albums_sql = format!(
|
||||||
"SELECT \
|
"SELECT \
|
||||||
la.server_id, \
|
la.server_id, \
|
||||||
la.album_id, \
|
la.album_id, \
|
||||||
COALESCE(a.name, la.album_name), \
|
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.artist_id, la.artist_id), \
|
||||||
COALESCE(a.song_count, la.track_count), \
|
COALESCE(a.song_count, la.track_count), \
|
||||||
COALESCE(a.duration_sec, la.duration_sec), \
|
COALESCE(a.duration_sec, la.duration_sec), \
|
||||||
@@ -102,6 +103,7 @@ pub fn get_artist_lossless_browse(
|
|||||||
t.album_id, \
|
t.album_id, \
|
||||||
MAX(t.album) AS album_name, \
|
MAX(t.album) AS album_name, \
|
||||||
MAX(t.artist) AS artist, \
|
MAX(t.artist) AS artist, \
|
||||||
|
MAX(t.album_artist) AS album_artist, \
|
||||||
MAX(t.artist_id) AS artist_id, \
|
MAX(t.artist_id) AS artist_id, \
|
||||||
MAX(t.year) AS year, \
|
MAX(t.year) AS year, \
|
||||||
MAX(t.genre) AS genre, \
|
MAX(t.genre) AS genre, \
|
||||||
|
|||||||
@@ -19,19 +19,20 @@ fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn genre_album_order_sql(sort: &[LibrarySortClause]) -> String {
|
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<String> = Vec::new();
|
let mut keys: Vec<String> = Vec::new();
|
||||||
for s in sort {
|
for s in sort {
|
||||||
let col = match s.field.as_str() {
|
let col = match s.field.as_str() {
|
||||||
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE",
|
"name" => "COALESCE(a.name, la.album_name) COLLATE NOCASE".to_string(),
|
||||||
"artist" => "COALESCE(a.artist, la.artist) COLLATE NOCASE",
|
"artist" => format!("COALESCE(a.artist, {la_artist}) COLLATE NOCASE"),
|
||||||
"year" => "COALESCE(a.year, la.year)",
|
"year" => "COALESCE(a.year, la.year)".to_string(),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
let dir = match s.dir {
|
let dir = match s.dir {
|
||||||
SortDir::Asc => "ASC",
|
SortDir::Asc => "ASC",
|
||||||
SortDir::Desc => "DESC",
|
SortDir::Desc => "DESC",
|
||||||
};
|
};
|
||||||
keys.push(format!("{col} {dir}"));
|
keys.push(format!("{col} {dir}", col = col));
|
||||||
}
|
}
|
||||||
if keys.is_empty() {
|
if keys.is_empty() {
|
||||||
keys.push("COALESCE(a.name, la.album_name) COLLATE NOCASE ASC".to_string());
|
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 where_sql = where_clauses.join(" AND ");
|
||||||
|
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT \
|
"SELECT \
|
||||||
la.server_id, \
|
la.server_id, \
|
||||||
la.album_id, \
|
la.album_id, \
|
||||||
COALESCE(a.name, la.album_name), \
|
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.artist_id, la.artist_id), \
|
||||||
COALESCE(a.song_count, la.track_count), \
|
COALESCE(a.song_count, la.track_count), \
|
||||||
COALESCE(a.duration_sec, la.duration_sec), \
|
COALESCE(a.duration_sec, la.duration_sec), \
|
||||||
@@ -144,6 +146,7 @@ pub fn list_albums_by_genre(
|
|||||||
t.album_id, \
|
t.album_id, \
|
||||||
MAX(t.album) AS album_name, \
|
MAX(t.album) AS album_name, \
|
||||||
MAX(t.artist) AS artist, \
|
MAX(t.artist) AS artist, \
|
||||||
|
MAX(t.album_artist) AS album_artist, \
|
||||||
MAX(t.artist_id) AS artist_id, \
|
MAX(t.artist_id) AS artist_id, \
|
||||||
MAX(t.year) AS year, \
|
MAX(t.year) AS year, \
|
||||||
MAX(t.genre) AS genre, \
|
MAX(t.genre) AS genre, \
|
||||||
|
|||||||
@@ -244,8 +244,9 @@ fn query_albums(
|
|||||||
ORDER BY rank \
|
ORDER BY rank \
|
||||||
LIMIT ?\
|
LIMIT ?\
|
||||||
) \
|
) \
|
||||||
SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
|
SELECT t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.album_artist), \
|
||||||
t.genre, t.cover_art_id, t.starred_at, t.synced_at, MIN(h.rank) AS best_rank \
|
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 \
|
FROM fts_hits h \
|
||||||
JOIN track t ON t.rowid = h.rowid \
|
JOIN track t ON t.rowid = h.rowid \
|
||||||
WHERE t.server_id = ? \
|
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::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP));
|
||||||
params.push(rusqlite::types::Value::Text(server_id.to_string()));
|
params.push(rusqlite::types::Value::Text(server_id.to_string()));
|
||||||
append_library_scope(&mut sql, &mut params, library_scope);
|
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)));
|
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||||
|
let track_artist: Option<String> = r.get(3)?;
|
||||||
|
let album_artist: Option<String> = r.get(4)?;
|
||||||
Ok(LibraryAlbumDto {
|
Ok(LibraryAlbumDto {
|
||||||
server_id: r.get(0)?,
|
server_id: r.get(0)?,
|
||||||
id: r.get(1)?,
|
id: r.get(1)?,
|
||||||
name: r.get(2)?,
|
name: r.get(2)?,
|
||||||
artist: r.get(3)?,
|
artist: crate::album_compilation_filter::pick_album_group_artist(
|
||||||
artist_id: r.get(4)?,
|
track_artist,
|
||||||
|
album_artist,
|
||||||
|
),
|
||||||
|
artist_id: r.get(5)?,
|
||||||
song_count: None,
|
song_count: None,
|
||||||
duration_sec: None,
|
duration_sec: None,
|
||||||
year: r.get(5)?,
|
year: r.get(6)?,
|
||||||
genre: r.get(6)?,
|
genre: r.get(7)?,
|
||||||
cover_art_id: r.get(7)?,
|
cover_art_id: r.get(8)?,
|
||||||
starred_at: r.get(8)?,
|
starred_at: r.get(9)?,
|
||||||
synced_at: r.get(9)?,
|
synced_at: r.get(10)?,
|
||||||
raw_json: serde_json::Value::Null,
|
raw_json: serde_json::Value::Null,
|
||||||
})
|
})
|
||||||
})? {
|
})? {
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ pub fn list_lossless_albums(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let where_sql = where_clauses.join(" AND ");
|
let where_sql = where_clauses.join(" AND ");
|
||||||
|
let la_artist = crate::album_compilation_filter::sql_track_group_display_artist("la");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT \
|
"SELECT \
|
||||||
la.server_id, \
|
la.server_id, \
|
||||||
la.album_id, \
|
la.album_id, \
|
||||||
COALESCE(a.name, la.album_name), \
|
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.artist_id, la.artist_id), \
|
||||||
COALESCE(a.song_count, la.track_count), \
|
COALESCE(a.song_count, la.track_count), \
|
||||||
COALESCE(a.duration_sec, la.duration_sec), \
|
COALESCE(a.duration_sec, la.duration_sec), \
|
||||||
@@ -65,6 +66,7 @@ pub fn list_lossless_albums(
|
|||||||
t.album_id, \
|
t.album_id, \
|
||||||
MAX(t.album) AS album_name, \
|
MAX(t.album) AS album_name, \
|
||||||
MAX(t.artist) AS artist, \
|
MAX(t.artist) AS artist, \
|
||||||
|
MAX(t.album_artist) AS album_artist, \
|
||||||
MAX(t.artist_id) AS artist_id, \
|
MAX(t.artist_id) AS artist_id, \
|
||||||
MAX(t.year) AS year, \
|
MAX(t.year) AS year, \
|
||||||
MAX(t.genre) AS genre, \
|
MAX(t.genre) AS genre, \
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { useLongPressAction } from '../hooks/useLongPressAction';
|
|||||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
||||||
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ function AlbumCard({
|
|||||||
}, [coverRef, 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]);
|
||||||
|
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
|
||||||
|
|
||||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||||
@@ -105,7 +106,7 @@ function AlbumCard({
|
|||||||
onClick={e => handleClick({ shiftKey: e.shiftKey })}
|
onClick={e => handleClick({ shiftKey: e.shiftKey })}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`${album.name} von ${album.artist}`}
|
aria-label={`${album.name} von ${artistLabel}`}
|
||||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -213,7 +214,7 @@ function AlbumCard({
|
|||||||
<p className="album-card-artist truncate">
|
<p className="album-card-artist truncate">
|
||||||
<OpenArtistRefInline
|
<OpenArtistRefInline
|
||||||
refs={artistRefs}
|
refs={artistRefs}
|
||||||
fallbackName={album.artist}
|
fallbackName={artistLabel}
|
||||||
onGoArtist={id => navigate(`/artist/${id}`)}
|
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||||
as="none"
|
as="none"
|
||||||
linkTag="span"
|
linkTag="span"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useLongPressAction } from '../hooks/useLongPressAction';
|
|||||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||||
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
||||||
import AlbumRow from './AlbumRow';
|
import AlbumRow from './AlbumRow';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
|
|
||||||
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
|
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
|
||||||
const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:';
|
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 imgSrc = coverImgSrc(coverHandle.src);
|
||||||
const bgResolved = coverHandle.src;
|
const bgResolved = coverHandle.src;
|
||||||
|
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
|
||||||
const handleOpen = () => navigate(`/album/${album.id}`);
|
const handleOpen = () => navigate(`/album/${album.id}`);
|
||||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -620,7 +622,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
|||||||
className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
|
className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
|
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
|
||||||
aria-label={`${album.name} – ${album.artist}`}
|
aria-label={`${album.name} – ${artistLabel}`}
|
||||||
>
|
>
|
||||||
{!disableArtwork && bgResolved && (
|
{!disableArtwork && bgResolved && (
|
||||||
<div
|
<div
|
||||||
@@ -683,7 +685,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
|||||||
{t('home.similarTo', { artist: anchor })}
|
{t('home.similarTo', { artist: anchor })}
|
||||||
</div>
|
</div>
|
||||||
<div className="because-card-title">{album.name}</div>
|
<div className="because-card-title">{album.name}</div>
|
||||||
<div className="because-card-artist">{album.artist}</div>
|
<div className="because-card-artist">{artistLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
{album.releaseTypes && album.releaseTypes[0] ? (
|
{album.releaseTypes && album.releaseTypes[0] ? (
|
||||||
<div className="because-card-pills">
|
<div className="because-card-pills">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
|||||||
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
|
|
||||||
const INTERVAL_MS = 10000;
|
const INTERVAL_MS = 10000;
|
||||||
const HERO_ALBUM_COUNT = 8;
|
const HERO_ALBUM_COUNT = 8;
|
||||||
@@ -266,6 +267,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
}, [albums.length, startTimer]);
|
}, [albums.length, startTimer]);
|
||||||
|
|
||||||
const album = albums[activeIdx] ?? null;
|
const album = albums[activeIdx] ?? null;
|
||||||
|
const heroArtistLabel = useMemo(
|
||||||
|
() => (album ? albumArtistDisplayName(album) : ''),
|
||||||
|
[album],
|
||||||
|
);
|
||||||
|
|
||||||
// Lazily fetch format label for the currently-visible album (cached by id)
|
// Lazily fetch format label for the currently-visible album (cached by id)
|
||||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||||
@@ -335,7 +340,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
|||||||
<div className="hero-text">
|
<div className="hero-text">
|
||||||
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
|
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
|
||||||
<h2 className="hero-title">{album.name}</h2>
|
<h2 className="hero-title">{album.name}</h2>
|
||||||
<p className="hero-artist">{album.artist}</p>
|
<p className="hero-artist">{heroArtistLabel}</p>
|
||||||
<div className="hero-meta">
|
<div className="hero-meta">
|
||||||
{album.year && <span className="badge">{album.year}</span>}
|
{album.year && <span className="badge">{album.year}</span>}
|
||||||
{album.genre && <span className="badge">{album.genre}</span>}
|
{album.genre && <span className="badge">{album.genre}</span>}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { usePlayerStore } from '../store/playerStore';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
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 { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
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 type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
||||||
@@ -775,7 +776,7 @@ export default function LiveSearch() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="search-result-name">{a.name}</div>
|
<div className="search-result-name">{a.name}</div>
|
||||||
<div className="search-result-sub">{a.artist}</div>
|
<div className="search-result-sub">{albumArtistDisplayName(a)}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
|
|||||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||||
import { albumCoverRefForSong } from '../cover/ref';
|
import { albumCoverRefForSong } from '../cover/ref';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
import { useShareSearch } from '../hooks/useShareSearch';
|
import { useShareSearch } from '../hooks/useShareSearch';
|
||||||
import ShareSearchResults from './search/ShareSearchResults';
|
import ShareSearchResults from './search/ShareSearchResults';
|
||||||
import {
|
import {
|
||||||
@@ -383,7 +384,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
)}
|
)}
|
||||||
<div className="mobile-search-item-info">
|
<div className="mobile-search-item-info">
|
||||||
<span className="mobile-search-item-title">{a.name}</span>
|
<span className="mobile-search-item-title">{a.name}</span>
|
||||||
<span className="mobile-search-item-sub">{a.artist}</span>
|
<span className="mobile-search-item-sub">{albumArtistDisplayName(a)}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
import { useSelectionStore } from '../store/selectionStore';
|
import { useSelectionStore } from '../store/selectionStore';
|
||||||
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
|
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 { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
@@ -295,7 +295,7 @@ const handleShuffleAll = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isOfflinePinComplete(album.album.id, serverId, songs.map(s => s.id))) return;
|
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]);
|
}, [album, downloadAlbum, serverId, effectiveSongs, losslessOnly, resolvedOfflineStatus]);
|
||||||
|
|
||||||
const handleRemoveOffline = () => {
|
const handleRemoveOffline = () => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
|||||||
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
||||||
import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore';
|
import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore';
|
||||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds';
|
import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds';
|
||||||
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
|
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
|
||||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||||
@@ -281,7 +282,7 @@ export default function Albums() {
|
|||||||
try {
|
try {
|
||||||
const detail = await resolveAlbum(serverId, album.id);
|
const detail = await resolveAlbum(serverId, album.id);
|
||||||
if (!detail) throw new Error('album unavailable');
|
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++;
|
queued++;
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
|||||||
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
import SortDropdown from '../components/SortDropdown';
|
import SortDropdown from '../components/SortDropdown';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
import {
|
import {
|
||||||
albumBrowseSortForServer,
|
albumBrowseSortForServer,
|
||||||
useAlbumBrowseSessionStore,
|
useAlbumBrowseSessionStore,
|
||||||
@@ -252,7 +253,7 @@ export default function LosslessAlbums() {
|
|||||||
try {
|
try {
|
||||||
const detail = await resolveAlbum(serverId, album.id);
|
const detail = await resolveAlbum(serverId, album.id);
|
||||||
if (!detail) throw new Error('album unavailable');
|
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++;
|
queued++;
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
|||||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||||
import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay';
|
import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ export default function MostPlayed() {
|
|||||||
className="mp-album-artist truncate track-artist-link"
|
className="mp-album-artist truncate track-artist-link"
|
||||||
onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }}
|
onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }}
|
||||||
>
|
>
|
||||||
{album.artist}
|
{albumArtistDisplayName(album)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mp-album-actions">
|
<div className="mp-album-actions">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
|||||||
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
||||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||||
import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters';
|
import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters';
|
||||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||||
@@ -160,7 +161,7 @@ export default function NewReleases() {
|
|||||||
try {
|
try {
|
||||||
const detail = await resolveAlbum(serverId, album.id);
|
const detail = await resolveAlbum(serverId, album.id);
|
||||||
if (!detail) throw new Error('album unavailable');
|
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++;
|
queued++;
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hook
|
|||||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||||
|
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||||
|
|
||||||
const ALBUM_COUNT = 30;
|
const ALBUM_COUNT = 30;
|
||||||
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
/** 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 {
|
try {
|
||||||
const detail = await resolveAlbum(serverId, album.id);
|
const detail = await resolveAlbum(serverId, album.id);
|
||||||
if (!detail) throw new Error('album unavailable');
|
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++;
|
queued++;
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { deriveAlbumArtistRefs, deriveAlbumHeaderArtistRefs } from './deriveAlbumHeaderArtistRefs';
|
import {
|
||||||
|
albumArtistDisplayName,
|
||||||
|
deriveAlbumArtistRefs,
|
||||||
|
deriveAlbumHeaderArtistRefs,
|
||||||
|
} from './deriveAlbumHeaderArtistRefs';
|
||||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
import { makeSubsonicSong } from '@/test/helpers/factories';
|
import { makeSubsonicSong } from '@/test/helpers/factories';
|
||||||
|
|
||||||
@@ -37,6 +41,16 @@ describe('deriveAlbumArtistRefs', () => {
|
|||||||
};
|
};
|
||||||
expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'a1', name: 'Solo' }]);
|
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', () => {
|
describe('deriveAlbumHeaderArtistRefs', () => {
|
||||||
|
|||||||
@@ -13,11 +13,21 @@ function nonEmpty(refs: SubsonicOpenArtistRef[]): refs is SubsonicOpenArtistRef[
|
|||||||
export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] {
|
export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] {
|
||||||
const albumArtists = coerceOpenArtistRefs(album.artists);
|
const albumArtists = coerceOpenArtistRefs(album.artists);
|
||||||
if (nonEmpty(albumArtists)) return albumArtists;
|
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();
|
const id = album.artistId?.trim();
|
||||||
return id ? [{ id, name }] : [{ name }];
|
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.
|
* OpenSubsonic album credits for the album-detail header.
|
||||||
* Prefer the album's `artists` array, then any child song's `albumArtists`
|
* Prefer the album's `artists` array, then any child song's `albumArtists`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getAlbumList } from '../../api/subsonicLibrary';
|
|||||||
import { coverArtRef } from '../../cover/ref';
|
import { coverArtRef } from '../../cover/ref';
|
||||||
import { loadCoverBlobForExport } from '../../cover/integrations/export';
|
import { loadCoverBlobForExport } from '../../cover/integrations/export';
|
||||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
|
import { albumArtistDisplayName } from '../album/deriveAlbumHeaderArtistRefs';
|
||||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
import { downloadDir, join } from '@tauri-apps/api/path';
|
import { downloadDir, join } from '@tauri-apps/api/path';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
@@ -190,7 +191,7 @@ async function renderPage(
|
|||||||
const lineY = coverY + COVER_SIZE / 2 + 6;
|
const lineY = coverY + COVER_SIZE / 2 + 6;
|
||||||
const sep = ' — ';
|
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 artistW = ctx.measureText(artistClamp).width;
|
||||||
const sepW = ctx.measureText(sep).width;
|
const sepW = ctx.measureText(sep).width;
|
||||||
const remaining = TEXT_W - artistW - sepW;
|
const remaining = TEXT_W - artistW - sepW;
|
||||||
|
|||||||
@@ -276,7 +276,10 @@ export async function hydrateOfflineLibraryCards(
|
|||||||
? (group.pinSource.displayName ?? first?.artist ?? '')
|
? (group.pinSource.displayName ?? first?.artist ?? '')
|
||||||
: pinKind === 'playlist'
|
: pinKind === 'playlist'
|
||||||
? ''
|
? ''
|
||||||
: (first?.artist ?? first?.albumArtist ?? '');
|
: (pinnedMeta?.artist?.trim()
|
||||||
|
|| first?.albumArtist?.trim()
|
||||||
|
|| first?.artist?.trim()
|
||||||
|
|| '');
|
||||||
|
|
||||||
let coverArt: string | undefined;
|
let coverArt: string | undefined;
|
||||||
let coverQuadIds: (string | null)[] | undefined;
|
let coverQuadIds: (string | null)[] | undefined;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function buildAlbumFromTracks(
|
|||||||
return {
|
return {
|
||||||
id: albumId,
|
id: albumId,
|
||||||
name: first.album ?? albumId,
|
name: first.album ?? albumId,
|
||||||
artist: first.artist ?? first.albumArtist ?? '',
|
artist: first.albumArtist ?? first.artist ?? '',
|
||||||
artistId: first.artistId ?? '',
|
artistId: first.artistId ?? '',
|
||||||
coverArt: resolveTrackCoverArtId(first) ?? albumId,
|
coverArt: resolveTrackCoverArtId(first) ?? albumId,
|
||||||
year: first.year ?? undefined,
|
year: first.year ?? undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user