mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
@@ -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>,
|
||||
/// A–Z / `#` / `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).
|
||||
|
||||
Reference in New Issue
Block a user