fix(library): All Albums compilation filter matches VA album artist (#1026)

* fix(library): All Albums compilation filter matches VA album artist

Local index SQL and track-grouped browse missed compilations tagged via
Various Artists on album_artist; genre-only browse also ignored combined
filters. Extend predicates on both sides and route genre+filter to advanced search.

* docs(changelog): All Albums compilation filter fix (PR #1026)
This commit is contained in:
cucadmuh
2026-06-08 01:19:24 +03:00
committed by GitHub
parent e527cfe67f
commit 32832246c0
6 changed files with 155 additions and 21 deletions
@@ -460,8 +460,8 @@ fn build_album_from_tracks(
);
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at)";
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at)";
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
});
@@ -868,10 +868,10 @@ fn resolve_clause(
}));
}
("compilation", EntityKind::Album) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Album);
}
("compilation", EntityKind::Track) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Track);
}
("compilation", _) => return Ok(None),
// `text` is handled by the entity builder (FTS / LIKE), never here.
@@ -1156,19 +1156,21 @@ fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
}
fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let track_artist: Option<String> = r.get(3)?;
let album_artist: Option<String> = r.get(5)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist: crate::album_compilation_filter::pick_album_group_artist(track_artist, album_artist),
artist_id: r.get(4)?,
song_count: Some(r.get(5)?),
duration_sec: Some(r.get(6)?),
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
song_count: Some(r.get(6)?),
duration_sec: Some(r.get(7)?),
year: r.get(8)?,
genre: r.get(9)?,
cover_art_id: r.get(10)?,
starred_at: r.get(11)?,
synced_at: r.get(12)?,
raw_json: Value::Null,
})
}
@@ -1264,9 +1266,21 @@ fn compilation_filter_fragment(
field: &str,
op: FilterOp,
value: Option<&Value>,
table_alias: &str,
kind: EntityKind,
) -> Result<Option<SqlFragment>, String> {
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
let comp_sql = match kind {
EntityKind::Album => crate::album_compilation_filter::compilation_predicate_sql(
"a",
Some("a.artist"),
None,
),
EntityKind::Track => crate::album_compilation_filter::compilation_predicate_sql(
"t",
Some("t.artist"),
Some("t.album_artist"),
),
_ => crate::album_compilation_filter::compilation_raw_json_sql("t"),
};
match op {
FilterOp::IsTrue => Ok(Some(SqlFragment {
sql: comp_sql,
@@ -2032,6 +2046,27 @@ mod tests {
assert_eq!(resp.albums[0].id, "al_comp");
}
#[test]
fn compilation_filter_matches_va_album_artist_on_track_groups() {
let store = LibraryStore::open_in_memory();
let mut comp = track("s1", "t_comp", "Hit", "Alice", "Comp Album");
comp.album_id = Some("al_comp".into());
comp.album_artist = Some("Various Artists".into());
comp.raw_json = "{}".into();
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
reg.album_id = Some("al_reg".into());
reg.raw_json = "{}".into();
TrackRepository::new(&store)
.upsert_batch(&[comp, reg])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_comp");
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
}
#[test]
fn compilation_filter_on_track_grouped_album_browse() {
let store = LibraryStore::open_in_memory();
@@ -1,5 +1,6 @@
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
//! `isCompilation`, or `releaseTypes` containing `Compilation`), plus the same
//! "Various Artists" heuristics the web UI uses when structured flags are absent.
/// SQL predicate on any row with a `raw_json` column (album or track).
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
@@ -17,6 +18,49 @@ pub fn compilation_raw_json_sql(table_alias: &str) -> String {
)
}
fn various_artists_like_sql(column: &str) -> String {
format!(
"lower(trim(coalesce({column}, ''))) LIKE '%various artists%'",
column = column
)
}
/// Full compilation predicate for browse filters — JSON flags plus VA artist labels.
pub fn compilation_predicate_sql(
table_alias: &str,
artist_column: Option<&str>,
album_artist_column: Option<&str>,
) -> String {
let mut parts = vec![compilation_raw_json_sql(table_alias)];
parts.push(format!(
"lower(trim(coalesce(json_extract({a}.raw_json, '$.displayArtist'), ''))) LIKE '%various artists%'",
a = table_alias
));
if let Some(col) = artist_column {
parts.push(various_artists_like_sql(col));
}
if let Some(col) = album_artist_column {
parts.push(various_artists_like_sql(col));
}
format!("({})", parts.join(" OR "))
}
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.
pub fn pick_album_group_artist(
track_artist: Option<String>,
album_artist: Option<String>,
) -> Option<String> {
let aa = album_artist.as_deref().unwrap_or("").trim();
if various_artists_label(aa) {
return Some(aa.to_string());
}
track_artist
}
#[cfg(test)]
mod tests {
use super::*;
@@ -27,4 +71,24 @@ mod tests {
assert!(sql.contains("$.compilation"));
assert!(sql.contains("$.releaseTypes"));
}
#[test]
fn predicate_includes_artist_columns() {
let sql = compilation_predicate_sql("t", Some("t.artist"), Some("t.album_artist"));
assert!(sql.contains("t.artist"));
assert!(sql.contains("t.album_artist"));
assert!(sql.contains("$.displayArtist"));
}
#[test]
fn pick_album_group_artist_prefers_va_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("Alice".into()), Some("Bob".into())),
Some("Alice".to_string())
);
}
}