feat(artists): album vs track credit mode browse (#1209) (#1232)

This commit is contained in:
cucadmuh
2026-07-04 17:39:58 +03:00
committed by GitHub
parent 77ab95170d
commit bf0e73ce6c
35 changed files with 601 additions and 54 deletions
@@ -15,7 +15,8 @@ use rusqlite::{params, OptionalExtension};
use serde_json::Value;
use crate::dto::{
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto,
ArtistCreditMode, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto,
LibraryArtistDto,
LibraryFilterClause, LibrarySearchTotals, LibrarySortClause, LibraryTrackDto, SortDir,
};
use crate::filter::{self, EntityKind, FilterOp, SqlFragment};
@@ -480,6 +481,45 @@ fn build_album_from_tracks(
)
}
fn album_artist_credit_mode(req: &LibraryAdvancedSearchRequest) -> bool {
!matches!(req.artist_credit_mode, Some(ArtistCreditMode::Track))
}
/// Letter bucket filter on `name_sort` (articles already stripped in column).
fn push_artist_letter_bucket(w: &mut WhereBuilder, bucket: &str, applied: &mut BTreeSet<String>) {
if bucket.is_empty() || bucket.eq_ignore_ascii_case("ALL") {
return;
}
let col = "COALESCE(ar.name_sort, ar.name)";
match bucket {
"#" => {
w.push_raw(&format!("SUBSTR({col}, 1, 1) GLOB '[0-9]'"));
}
"OTHER" => {
w.push_raw(&format!(
"LENGTH({col}) > 0 \
AND SUBSTR({col}, 1, 1) NOT GLOB '[0-9]' \
AND LOWER(SUBSTR({col}, 1, 1)) NOT GLOB '[a-z]'"
));
}
letter if letter.len() == 1 => {
let Some(ch) = letter.chars().next() else {
return;
};
if !ch.is_ascii_alphabetic() {
return;
}
let lower = ch.to_ascii_lowercase().to_string();
w.push_param(
&format!("LOWER(SUBSTR({col}, 1, 1)) = ?"),
SqlValue::Text(lower),
);
}
_ => return,
}
applied.insert("letter".to_string());
}
#[allow(clippy::too_many_arguments)]
fn build_artist(
store: &LibraryStore,
@@ -491,11 +531,9 @@ fn build_artist(
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
// #1209: browse uses a single `artist` table path — no FTS / track fallthrough.
if !scalar_requires_track_derived_entities(scalar) {
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(table);
}
return build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
}
if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) {
return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
@@ -516,6 +554,12 @@ fn build_artist_from_table(
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
let mut w = WhereBuilder::new();
w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone()));
if album_artist_credit_mode(req) {
w.push_raw("ar.album_count IS NOT NULL");
}
if let Some(bucket) = req.artist_letter_bucket.as_deref() {
push_artist_letter_bucket(&mut w, bucket, applied);
}
if let Some(t) = text {
w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
@@ -1461,15 +1505,7 @@ mod tests {
}
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO artist (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, '{}')",
rusqlite::params![server, id, name],
)
})
.unwrap();
insert_artist_with_album_count(store, server, id, name, Some(1));
}
fn req(server: &str, entities: &[EntityKind]) -> LibraryAdvancedSearchRequest {
@@ -1486,9 +1522,29 @@ mod tests {
limit: 50,
offset: 0,
skip_totals: false,
artist_credit_mode: None,
artist_letter_bucket: None,
}
}
fn insert_artist_with_album_count(
store: &LibraryStore,
server: &str,
id: &str,
name: &str,
album_count: Option<i64>,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
VALUES (?1, ?2, ?3, ?4, 1, '{}')",
rusqlite::params![server, id, name, album_count],
)
})
.unwrap();
}
fn clause(field: &str, op: FilterOp, value: Option<Value>, value_to: Option<Value>) -> LibraryFilterClause {
LibraryFilterClause {
field: field.into(),
@@ -1567,8 +1623,79 @@ mod tests {
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_Aurora Nights");
assert_eq!(resp.albums[0].cover_art_id.as_deref(), Some("cv1"));
// Artist rows come from the `artist` table only (#1209) — not track fallthrough.
assert!(resp.artists.is_empty());
}
#[test]
fn artist_credit_album_mode_excludes_backfill_only_rows() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
let mut r = req("s1", &[EntityKind::Artist]);
r.artist_credit_mode = Some(ArtistCreditMode::Album);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar_Aurora Quartet");
assert_eq!(resp.artists[0].id, "ar_va");
}
#[test]
fn artist_credit_track_mode_includes_backfill_rows() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
let mut r = req("s1", &[EntityKind::Artist]);
r.artist_credit_mode = Some(ArtistCreditMode::Track);
r.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.artists.len(), 2);
assert_eq!(resp.artists[0].id, "ar_guest");
assert_eq!(resp.artists[1].id, "ar_va");
}
#[test]
fn artist_credit_album_mode_text_search_uses_artist_table_only() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_va", "Various Artists", Some(12));
insert_artist_with_album_count(&store, "s1", "ar_guest", "Soundtrack Guest", None);
let mut r = req("s1", &[EntityKind::Artist]);
r.query = Some("guest".into());
r.artist_credit_mode = Some(ArtistCreditMode::Album);
let resp = run_advanced_search(&store, &r).unwrap();
assert!(resp.artists.is_empty());
let mut r2 = req("s1", &[EntityKind::Artist]);
r2.query = Some("guest".into());
r2.artist_credit_mode = Some(ArtistCreditMode::Track);
let resp2 = run_advanced_search(&store, &r2).unwrap();
assert_eq!(resp2.artists.len(), 1);
assert_eq!(resp2.artists[0].id, "ar_guest");
}
#[test]
fn artist_letter_bucket_filters_by_name_sort_prefix() {
let store = LibraryStore::open_in_memory();
insert_artist_with_album_count(&store, "s1", "ar_a", "Alpha", Some(1));
insert_artist_with_album_count(&store, "s1", "ar_m", "Mike", Some(1));
store
.with_conn("misc", |c| {
c.execute(
"UPDATE artist SET name_sort = 'alpha' WHERE id = 'ar_a'",
[],
)?;
c.execute(
"UPDATE artist SET name_sort = 'mike' WHERE id = 'ar_m'",
[],
)
})
.unwrap();
let mut r = req("s1", &[EntityKind::Artist]);
r.artist_letter_bucket = Some("M".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar_m");
}
#[test]
@@ -523,6 +523,15 @@ pub enum SortDir {
Desc,
}
/// Artist browse credit semantics when `entity_types` includes `artist` (#1209).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum ArtistCreditMode {
#[default]
Album,
Track,
}
/// `library_advanced_search` request (§5.13.2). `query` is shorthand for an
/// `fts` clause on the text fields; `entityTypes` controls which of the
/// three queries run; `filters` are combined with AND.
@@ -555,6 +564,13 @@ pub struct LibraryAdvancedSearchRequest {
/// When true, skip per-entity COUNT queries (Live Search / small pages).
#[serde(default)]
pub skip_totals: bool,
/// Album-artist vs track-performer browse when querying `artist` (#1209).
/// `None` is treated as [`ArtistCreditMode::Album`].
#[serde(default)]
pub artist_credit_mode: Option<ArtistCreditMode>,
/// AZ / `#` / `OTHER` / omit or `ALL` — letter bucket on `name_sort` (#1209 browse).
#[serde(default)]
pub artist_letter_bucket: Option<String>,
}
/// Per-entity result counts (full match count, not page size).