mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user