diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index ebe4af50..679b9c12 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -22,8 +22,10 @@ use crate::filter::{self, EntityKind, FilterOp, SqlFragment}; use crate::repos; use crate::search::{ aliased_track_columns, aliased_track_columns_resolved_bpm, bpm_resolved_expr, - fts_album_prefix_match_query, fts_album_title_prefix_match_query, fts_column_prefix_query, fts_query_meets_min_len, - fts_track_prefix_match_query, library_scope_filter_sql, like_contains, PAGE_LIMIT_MAX, + fts_album_prefix_any_token_match_query, fts_album_title_prefix_any_token_match_query, + fts_column_prefix_query, fts_query_meets_min_len, fts_track_prefix_match_query, + library_scope_filter_sql, like_any_token_contains_clause, like_contains, like_name_tokens, + PAGE_LIMIT_MAX, }; use crate::store::LibraryStore; @@ -356,17 +358,87 @@ fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result Option { if req.query_album_title_only == Some(true) { - fts_album_title_prefix_match_query(text) + fts_album_title_prefix_any_token_match_query(text) } else { - fts_album_prefix_match_query(text) + fts_album_prefix_any_token_match_query(text) } } -/// Synced `album` rows + LIKE/scope filters — avoids FTS over the full track index. +fn push_album_name_like_any_token( + w: &mut WhereBuilder, + column: &str, + text: &str, + applied: &mut BTreeSet, +) { + let Some((sql, params)) = like_any_token_contains_clause(column, text) else { + return; + }; + w.push_params( + &sql, + params.into_iter().map(SqlValue::Text).collect(), + ); + applied.insert("text".to_string()); +} + +/// `album` row or any child track tag may carry the searchable title. +fn push_album_table_text_match( + w: &mut WhereBuilder, + text: &str, + applied: &mut BTreeSet, +) { + let tokens = like_name_tokens(text); + if tokens.is_empty() { + return; + } + let mut parts = Vec::new(); + let mut params = Vec::new(); + for token in tokens { + let pat = like_contains(&token); + parts.push( + "(a.name COLLATE NOCASE LIKE ? ESCAPE '\\' \ + OR EXISTS (SELECT 1 FROM track t_mt \ + WHERE t_mt.server_id = a.server_id AND t_mt.album_id = a.id \ + AND t_mt.deleted = 0 \ + AND t_mt.album COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(), + ); + params.push(SqlValue::Text(pat.clone())); + params.push(SqlValue::Text(pat)); + } + w.push_params(&format!("({})", parts.join(" OR ")), params); + applied.insert("text".to_string()); +} + +/// Track group match: hot `t.album` tag or synced `album.name`. +fn push_track_group_text_match( + w: &mut WhereBuilder, + text: &str, + applied: &mut BTreeSet, +) { + let tokens = like_name_tokens(text); + if tokens.is_empty() { + return; + } + let mut parts = Vec::new(); + let mut params = Vec::new(); + for token in tokens { + let pat = like_contains(&token); + parts.push( + "(t.album COLLATE NOCASE LIKE ? ESCAPE '\\' \ + OR EXISTS (SELECT 1 FROM album a_mt \ + WHERE a_mt.server_id = t.server_id AND a_mt.id = t.album_id \ + AND a_mt.name COLLATE NOCASE LIKE ? ESCAPE '\\'))".to_string(), + ); + params.push(SqlValue::Text(pat.clone())); + params.push(SqlValue::Text(pat)); + } + w.push_params(&format!("({})", parts.join(" OR ")), params); + applied.insert("text".to_string()); +} + +/// Synced `album` rows + scope filters — plain browse only (no free-text query). fn try_build_album_from_table( store: &LibraryStore, req: &LibraryAdvancedSearchRequest, - text: Option<&str>, scalar: &[&LibraryFilterClause], limit: u32, offset: u32, @@ -381,16 +453,113 @@ fn try_build_album_from_table( if !crate::album_browse::album_table_usable(store, &req.server_id)? { return Ok(None); } - let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?; - if text.is_some() { - return Ok(Some(table)); - } + let table = build_album_from_table(store, req, None, scalar, limit, offset, skip_totals, applied)?; if !table.0.is_empty() || table.1 > 0 { return Ok(Some(table)); } Ok(None) } +fn album_text_hit_key(a: &LibraryAlbumDto) -> (String, String) { + (a.server_id.clone(), a.id.clone()) +} + +/// Prefer synced `album` rows; fill gaps from FTS / track-derived groups. +fn merge_album_text_hits( + table: Vec, + fts: Vec, + tracks: Vec, +) -> Vec { + let mut by_key: std::collections::HashMap<(String, String), LibraryAlbumDto> = + std::collections::HashMap::new(); + for a in tracks { + by_key.entry(album_text_hit_key(&a)).or_insert(a); + } + for a in fts { + by_key.entry(album_text_hit_key(&a)).or_insert(a); + } + for a in table { + by_key.insert(album_text_hit_key(&a), a); + } + let mut out: Vec = by_key.into_values().collect(); + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + out +} + +/// All Albums text search — union `album` LIKE, track FTS, and track GROUP BY +/// (substring + prefix). The `album`-table fast path alone misses titles that +/// only appear on track rows (Navidrome parity / Live Search). +#[allow(clippy::too_many_arguments)] +fn build_album_text_search( + store: &LibraryStore, + req: &LibraryAdvancedSearchRequest, + text: &str, + scalar: &[&LibraryFilterClause], + limit: u32, + offset: u32, + skip_totals: bool, + applied: &mut BTreeSet, +) -> Result<(Vec, u32), String> { + applied.insert("text".to_string()); + let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX); + let scope_ids = effective_library_scope_ids(req); + let mut scratch = BTreeSet::new(); + + let mut table = Vec::new(); + // Match `list_albums`: multi-folder scope uses track GROUP BY, not album+EXISTS. + if scope_ids.len() <= 1 + && !scalar_requires_lossless_track_grouping(scalar) + && !scalar_requires_track_derived_entities(scalar) + && crate::album_browse::album_table_usable(store, &req.server_id)? + { + table = build_album_from_table( + store, + req, + Some(text), + scalar, + fetch, + 0, + true, + &mut scratch, + )? + .0; + } + + let mut fts = Vec::new(); + let mut tracks = Vec::new(); + if server_has_indexed_tracks(store, &req.server_id)? { + if fts_query_meets_min_len(text) { + if let Some(q) = fts_album_text_match_query(req, text) { + fts = build_album_from_fts( + store, req, &q, scalar, fetch, 0, true, &mut scratch, + )? + .0; + } + } + tracks = build_album_from_tracks( + store, + req, + Some(text), + scalar, + fetch, + 0, + true, + &mut scratch, + true, + )? + .0; + } + + let merged = merge_album_text_hits(table, fts, tracks); + let total = merged.len() as u32; + let page = merged + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + Ok((page, if skip_totals { 0 } else { total })) +} + #[allow(clippy::too_many_arguments)] fn build_album( store: &LibraryStore, @@ -412,17 +581,19 @@ fn build_album( if req.starred_only == Some(true) { return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied); } + if let Some(t) = text { + return build_album_text_search( + store, req, t, scalar, limit, offset, skip_totals, applied, + ); + } if let Some(table) = try_build_album_from_table( - store, req, text, scalar, limit, offset, skip_totals, applied, + store, req, scalar, limit, offset, skip_totals, applied, )? { return Ok(table); } if server_has_indexed_tracks(store, &req.server_id)? { - if let Some(q) = text.and_then(|t| fts_album_text_match_query(req, t)) { - return build_album_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied); - } return build_album_from_tracks( - store, req, text, scalar, limit, offset, skip_totals, applied, false, + store, req, None, scalar, limit, offset, skip_totals, applied, false, ); } if !scalar_requires_track_derived_entities(scalar) { @@ -455,8 +626,7 @@ fn build_album_from_table( w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone())); push_album_table_library_scope(&mut w, "a", &effective_library_scope_ids(req)); if let Some(t) = text { - w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); - applied.insert("text".to_string()); + push_album_table_text_match(&mut w, t, applied); } for c in scalar { if let Some(frag) = resolve_clause(c, EntityKind::Album)? { @@ -519,8 +689,7 @@ fn build_album_from_tracks( } push_library_scope_where(&mut w, "t", &effective_library_scope_ids(req)); if let Some(t) = text { - w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t))); - applied.insert("text".to_string()); + push_track_group_text_match(&mut w, t, applied); } for c in scalar { if let Some(frag) = resolve_clause(c, EntityKind::Track)? { @@ -539,7 +708,9 @@ fn build_album_from_tracks( applied, ); - let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \ + let select = "t.server_id, t.album_id, \ + MAX(COALESCE((SELECT a2.name FROM album a2 WHERE a2.server_id = t.server_id AND a2.id = t.album_id), 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)"; let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| { @@ -682,107 +853,94 @@ fn build_album_from_fts( applied: &mut BTreeSet, ) -> Result<(Vec, u32), String> { applied.insert("text".to_string()); - let need = limit.saturating_add(offset) as i64; - let pool = (need.saturating_mul(8)).clamp(64, 2_000); + let fetch = limit.saturating_add(offset).clamp(1, PAGE_LIMIT_MAX); + let pool = fts_candidate_pool_size(fetch, 0); let scope_ids = effective_library_scope_ids(req); - let mut w = WhereBuilder::new(); - w.push_params( - &format!( - "t.rowid IN ({})", - scoped_fts_rowid_subquery_sql(pool, &scope_ids) - ), - { - let mut p = vec![SqlValue::Text(fts.to_string())]; - p.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids)); - p - }, - ); - w.push_raw("t.deleted = 0"); - w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); - w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''"); - push_library_scope_where(&mut w, "t", &scope_ids); + let mut extra = WhereBuilder::new(); + extra.push_raw("t.deleted = 0"); + extra.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); + extra.push_raw("t.album_id IS NOT NULL AND t.album_id != ''"); + push_library_scope_where(&mut extra, "t", &scope_ids); for c in scalar { if let Some(frag) = resolve_clause(c, EntityKind::Track)? { applied.insert(c.field.clone()); - w.push(frag); + extra.push(frag); } } if req.starred_only == Some(true) { - w.push_raw("t.starred_at IS NOT NULL"); + extra.push_raw("t.starred_at IS NOT NULL"); applied.insert("starred".to_string()); } push_album_id_allowlist( - &mut w, + &mut extra, "t.album_id", req.restrict_album_ids.as_deref(), applied, ); + let extra_sql = extra.where_sql(); - let where_sql = w.where_sql(); + let scope_tail = if let (Some(clause), _) = library_scope_filter_sql("t_fts", &scope_ids) { + format!(" AND {clause}") + } else { + String::new() + }; store.with_read_conn(|conn| { 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, \ + COALESCE(a.name, t.album), t.artist, t.artist_id, t.year, \ t.genre, t.cover_art_id, t.starred_at, t.synced_at \ - FROM track t \ - WHERE {where_sql}" + FROM (\ + SELECT f.rowid, bm25(track_fts) AS fts_rank \ + FROM track_fts f \ + JOIN track t_fts ON t_fts.rowid = f.rowid \ + WHERE track_fts MATCH ? \ + AND t_fts.server_id = ? \ + AND t_fts.deleted = 0{scope_tail} \ + ORDER BY fts_rank \ + LIMIT {pool}\ + ) fts_pick \ + JOIN track t ON t.rowid = fts_pick.rowid \ + LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id \ + WHERE {extra_sql} \ + GROUP BY t.album_id \ + ORDER BY MIN(fts_pick.fts_rank) \ + LIMIT ? OFFSET ?", ); - let params = w.params.clone(); + let mut params: Vec = Vec::new(); + params.push(SqlValue::Text(fts.to_string())); + params.extend(scoped_fts_subquery_bind(&req.server_id, &scope_ids)); + params.extend(extra.params); + params.push(SqlValue::Integer(fetch as i64)); + params.push(SqlValue::Integer(offset as i64)); + let mut stmt = conn.prepare(&sql)?; - let rows: Vec = - stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok(( - r.get(0)?, - r.get(1)?, - r.get(2)?, - r.get(3)?, - r.get(4)?, - r.get(5)?, - r.get(6)?, - r.get(7)?, - r.get(8)?, - r.get(9)?, - )) + let albums: Vec = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(LibraryAlbumDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get(2)?, + artist: r.get(3)?, + artist_id: r.get(4)?, + song_count: None, + duration_sec: None, + year: r.get(5)?, + genre: r.get(6)?, + cover_art_id: r.get(7)?, + starred_at: r.get(8)?, + synced_at: r.get(9)?, + raw_json: Value::Null, + }) })? .collect::>>()?; - let mut seen = HashSet::new(); - let mut deduped: Vec = Vec::new(); - for (server_id, album_id, album, artist, artist_id, year, genre, cover_art_id, starred_at, synced_at) in rows { - if !seen.insert(album_id.clone()) { - continue; - } - deduped.push(LibraryAlbumDto { - server_id, - id: album_id, - name: album, - artist, - artist_id, - song_count: None, - duration_sec: None, - year, - genre, - cover_art_id, - starred_at, - synced_at, - raw_json: Value::Null, - }); - if deduped.len() >= need as usize { - break; - } - } - let total = if skip_totals { 0 } else { - deduped.len() as u32 + albums.len() as u32 }; - let page = deduped - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); - Ok((page, total)) + Ok((albums, total)) }) } @@ -1466,6 +1624,17 @@ mod tests { .unwrap(); } + fn mark_album_catalog_row(store: &LibraryStore, server: &str, id: &str) { + store + .with_conn("misc", |c| { + c.execute( + "UPDATE album SET song_count = 1 WHERE server_id = ?1 AND id = ?2", + rusqlite::params![server, id], + ) + }) + .unwrap(); + } + fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) { store .with_conn("misc", |c| { @@ -1547,6 +1716,8 @@ mod tests { let store = LibraryStore::open_in_memory(); insert_album(&store, "s1", "al1", "Aurora Nights", None, None); insert_album(&store, "s1", "al2", "Other", None, None); + mark_album_catalog_row(&store, "s1", "al1"); + mark_album_catalog_row(&store, "s1", "al2"); insert_artist(&store, "s1", "ar1", "Aurora Quartet"); let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]); r.query = Some("aurora".into()); @@ -1557,6 +1728,169 @@ mod tests { assert_eq!(resp.artists[0].id, "ar1"); } + #[test] + fn album_title_search_matches_any_query_word_via_like() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None); + insert_album(&store, "s1", "al_other", "Wish You Were Here", None, None); + mark_album_catalog_row(&store, "s1", "al_moon"); + mark_album_catalog_row(&store, "s1", "al_other"); + let mut r = req("s1", &[EntityKind::Album]); + r.query = Some("moon side".into()); + r.query_album_title_only = Some(true); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al_moon"); + } + + #[test] + fn album_title_search_matches_later_word_only() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al_moon", "The Dark Side of the Moon", None, None); + mark_album_catalog_row(&store, "s1", "al_moon"); + let mut r = req("s1", &[EntityKind::Album]); + r.query = Some("moon".into()); + r.query_album_title_only = Some(true); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al_moon"); + } + + #[test] + fn album_title_search_matches_synced_name_when_track_tag_differs() { + let store = LibraryStore::open_in_memory(); + insert_album( + &store, + "s1", + "al_kerrang", + "Kerrang! Metallica Master of Puppets Revisited", + None, + None, + ); + mark_album_catalog_row(&store, "s1", "al_kerrang"); + let mut tr = track("s1", "t1", "A", "Various", "Master of Puppets Revisited"); + tr.album_id = Some("al_kerrang".into()); + TrackRepository::new(&store).upsert_batch(&[tr]).unwrap(); + let mut r = req("s1", &[EntityKind::Album]); + r.query = Some("metallica".into()); + r.query_album_title_only = Some(true); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al_kerrang"); + } + + fn track_with_lib( + server: &str, + id: &str, + album_id: &str, + album: &str, + library_id: Option<&str>, + ) -> TrackRow { + let mut t = track(server, id, "A", "art-1", album); + t.album_id = Some(album_id.into()); + t.library_id = library_id.map(str::to_string); + t + } + + #[test] + fn album_title_text_search_respects_single_library_scope() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track_with_lib("s1", "t1", "al-in", "Metallica", Some("lib-a")), + track_with_lib("s1", "t2", "al-out", "Metallica Covers", Some("lib-b")), + ]) + .unwrap(); + insert_album(&store, "s1", "al-in", "Metallica", None, None); + insert_album(&store, "s1", "al-out", "Metallica Covers", None, None); + mark_album_catalog_row(&store, "s1", "al-in"); + mark_album_catalog_row(&store, "s1", "al-out"); + let mut r = req("s1", &[EntityKind::Album]); + r.query = Some("metallica".into()); + r.query_album_title_only = Some(true); + r.library_scope_ids = Some(vec!["lib-a".into()]); + let resp = run_advanced_search(&store, &r).unwrap(); + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "al-in"); + } + + #[test] + fn album_title_text_search_unions_multi_library_scope() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track_with_lib("s1", "t1", "al-a", "Alpha Metallica", Some("lib-a")), + track_with_lib("s1", "t2", "al-b", "Beta Metallica", Some("lib-b")), + track_with_lib("s1", "t3", "al-c", "Gamma Other", Some("lib-c")), + ]) + .unwrap(); + insert_album(&store, "s1", "al-a", "Alpha Metallica", None, None); + insert_album(&store, "s1", "al-b", "Beta Metallica", None, None); + mark_album_catalog_row(&store, "s1", "al-a"); + mark_album_catalog_row(&store, "s1", "al-b"); + let mut r = req("s1", &[EntityKind::Album]); + r.query = Some("metallica".into()); + r.query_album_title_only = Some(true); + r.library_scope_ids = Some(vec!["lib-a".into(), "lib-b".into()]); + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids.len(), 2, "expected {ids:?}"); + assert!(ids.contains(&"al-a")); + assert!(ids.contains(&"al-b")); + } + + #[test] + fn album_title_text_search_unions_sparse_table_and_track_catalog() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al_self", "Metallica", None, None); + store + .with_conn("misc", |c| { + c.execute( + "UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'", + [], + ) + }) + .unwrap(); + let mut plays = track("s1", "t1", "A", "Apocalyptica", "Plays Metallica Vol. 2"); + plays.album_id = Some("al_plays".into()); + let mut blacklist = track("s1", "t2", "B", "Various", "The Metallica Blacklist"); + blacklist.album_id = Some("al_black".into()); + let mut other = track("s1", "t3", "C", "Pink Floyd", "Wish You Were Here"); + other.album_id = Some("al_wish".into()); + TrackRepository::new(&store) + .upsert_batch(&[plays, blacklist, other]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Album]); + r.query = Some("metallica".into()); + r.query_album_title_only = Some(true); + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids.len(), 3); + assert!(ids.contains(&"al_self")); + assert!(ids.contains(&"al_plays")); + assert!(ids.contains(&"al_black")); + } + + #[test] + fn album_title_search_fts_matches_any_word_on_track_catalog() { + let store = LibraryStore::open_in_memory(); + let mut moon = track("s1", "t1", "Breathe", "Pink Floyd", "The Dark Side of the Moon"); + moon.album_id = Some("al_moon".into()); + let mut other = track("s1", "t2", "Shine", "Pink Floyd", "Wish You Were Here"); + other.album_id = Some("al_wish".into()); + TrackRepository::new(&store) + .upsert_batch(&[moon, other]) + .unwrap(); + let mut r = req("s1", &[EntityKind::Album]); + r.query = Some("wish moon".into()); + r.query_album_title_only = Some(true); + let resp = run_advanced_search(&store, &r).unwrap(); + let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&"al_moon")); + assert!(ids.contains(&"al_wish")); + } + #[test] fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() { let store = LibraryStore::open_in_memory(); diff --git a/src-tauri/crates/psysonic-library/src/search.rs b/src-tauri/crates/psysonic-library/src/search.rs index c4528d7d..ffca06f4 100644 --- a/src-tauri/crates/psysonic-library/src/search.rs +++ b/src-tauri/crates/psysonic-library/src/search.rs @@ -171,6 +171,11 @@ pub(crate) fn fts_album_title_prefix_match_query(raw: &str) -> Option { fts_prefix_token_expr(raw).map(|tokens| format!("album : {tokens}")) } +/// All Albums title search — any query word may prefix-match the album column. +pub(crate) fn fts_album_title_prefix_any_token_match_query(raw: &str) -> Option { + fts_prefix_token_or_expr(raw).map(|tokens| format!("album : ({tokens})")) +} + /// Live Search album match — any query word may hit album or album_artist (Navidrome parity). pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option { fts_prefix_token_or_expr(raw).map(|tokens| { @@ -349,6 +354,36 @@ pub(crate) fn like_contains(raw: &str) -> String { format!("%{escaped}%") } +/// Whitespace-split tokens for substring LIKE (any non-empty segment). +pub(crate) fn like_name_tokens(raw: &str) -> Vec { + raw.split_whitespace() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_string) + .collect() +} + +/// `(col LIKE ? OR …)` — any query word may match as a case-insensitive substring. +pub(crate) fn like_any_token_contains_clause(column: &str, raw: &str) -> Option<(String, Vec)> { + let tokens = like_name_tokens(raw); + if tokens.is_empty() { + return None; + } + let col = format!("{column} COLLATE NOCASE"); + if tokens.len() == 1 { + return Some(( + format!("{col} LIKE ? ESCAPE '\\'"), + vec![like_contains(&tokens[0])], + )); + } + let parts: Vec = tokens + .iter() + .map(|_| format!("{col} LIKE ? ESCAPE '\\'")) + .collect(); + let params: Vec = tokens.iter().map(|t| like_contains(t)).collect(); + Some((format!("({})", parts.join(" OR ")), params)) +} + #[cfg(test)] mod tests { use super::*; @@ -491,6 +526,23 @@ mod tests { ); } + #[test] + fn fts_album_title_prefix_any_token_match_query_or_words() { + assert_eq!( + fts_album_title_prefix_any_token_match_query("dark side").as_deref(), + Some("album : (\"dark\"* OR \"side\"*)") + ); + } + + #[test] + fn like_any_token_contains_clause_ors_words() { + let (sql, params) = like_any_token_contains_clause("a.name", "dark side").unwrap(); + assert!(sql.contains(" OR ")); + assert_eq!(params.len(), 2); + assert_eq!(params[0], "%dark%"); + assert_eq!(params[1], "%side%"); + } + #[test] fn fts_track_match_query_or_across_display_columns() { let q = fts_track_match_query("manowar").unwrap(); diff --git a/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs b/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs index 94280d85..6371e957 100644 --- a/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/server_cluster/advanced_search.rs @@ -68,7 +68,15 @@ pub fn run_cluster_advanced_search( } let merged_tracks = merge_tracks_by_cluster_key(store, all_tracks)?; - let merged_albums = merge_albums_by_album_key(store, all_albums)?; + let merged_albums = if req + .query + .as_ref() + .is_some_and(|q| !q.trim().is_empty()) + { + dedupe_album_search_hits(all_albums) + } else { + merge_albums_by_album_key(store, all_albums)? + }; let merged_artists = merge_artists_by_artist_key(store, all_artists)?; let totals = if req.skip_totals { @@ -146,6 +154,19 @@ fn merge_tracks_by_cluster_key( Ok(out) } +/// Text search — keep distinct `(server_id, album_id)` rows; do not collapse +/// same-server albums that share an `album_key` (tribute / variant titles). +fn dedupe_album_search_hits(albums: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for album in albums { + if seen.insert((album.server_id.clone(), album.id.clone())) { + out.push(album); + } + } + out +} + fn merge_albums_by_album_key( store: &LibraryStore, albums: Vec, @@ -368,6 +389,139 @@ mod tests { assert_eq!(resp.totals.artists, 0); } + fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str) { + store + .with_conn("misc", |c| { + c.execute( + "INSERT INTO album (server_id, id, name, synced_at, raw_json) \ + VALUES (?1, ?2, ?3, 1, '{}')", + rusqlite::params![server, id, name], + ) + }) + .unwrap(); + } + + #[test] + fn cluster_text_search_respects_per_member_library_scope() { + let store = LibraryStore::open_in_memory(); + let mut in_scope = track("s1", "t1", "Band", "art-1", "Metallica", "alb-in"); + in_scope.library_id = Some("lib-a".into()); + let mut out_scope = track("s1", "t2", "Band", "art-2", "Metallica Tribute", "alb-out"); + out_scope.library_id = Some("lib-b".into()); + TrackRepository::new(&store) + .upsert_batch(&[in_scope, out_scope]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + + let mut scopes = HashMap::new(); + scopes.insert("s1".into(), vec!["lib-a".into()]); + + let resp = run_cluster_advanced_search( + &store, + LibraryClusterAdvancedSearchRequest { + servers_ordered: vec!["s1".into()], + query: Some("metallica".into()), + entity_types: vec![EntityKind::Album], + filters: Vec::new(), + starred_only: None, + restrict_album_ids: None, + restrict_album_scopes: HashMap::new(), + query_album_title_only: Some(true), + sort: Vec::new(), + limit: 50, + offset: 0, + skip_totals: false, + library_scopes: scopes, + }, + ) + .unwrap(); + + assert_eq!(resp.albums.len(), 1); + assert_eq!(resp.albums[0].id, "alb-in"); + } + + #[test] + fn cluster_metallica_text_search_unions_table_and_track_catalog() { + let store = LibraryStore::open_in_memory(); + insert_album(&store, "s1", "al_self", "Metallica"); + store + .with_conn("misc", |c| { + c.execute( + "UPDATE album SET song_count = 10 WHERE server_id = 's1' AND id = 'al_self'", + [], + ) + }) + .unwrap(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Apocalyptica", "art-1", "Plays Metallica Vol. 2", "al_plays"), + track("s1", "t2", "Various", "art-2", "The Metallica Blacklist", "al_black"), + track("s1", "t3", "Pink Floyd", "art-3", "Wish You Were Here", "al_wish"), + ]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + + let resp = run_cluster_advanced_search( + &store, + LibraryClusterAdvancedSearchRequest { + servers_ordered: vec!["s1".into()], + query: Some("metallica".into()), + entity_types: vec![EntityKind::Album], + filters: Vec::new(), + starred_only: None, + restrict_album_ids: None, + restrict_album_scopes: HashMap::new(), + query_album_title_only: Some(true), + sort: Vec::new(), + limit: 50, + offset: 0, + skip_totals: false, + library_scopes: HashMap::new(), + }, + ) + .unwrap(); + + let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids.len(), 3, "expected {ids:?}"); + assert!(ids.contains(&"al_self")); + assert!(ids.contains(&"al_plays")); + assert!(ids.contains(&"al_black")); + } + + #[test] + fn text_search_keeps_distinct_same_server_albums_with_shared_album_key() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track("s1", "t1", "Band", "art-1", "Metallica", "alb-1"), + track("s1", "t2", "Band", "art-2", "Plays Metallica Vol. 2", "alb-2"), + ]) + .unwrap(); + rebuild_all_cluster_keys(&store).unwrap(); + + let resp = run_cluster_advanced_search( + &store, + LibraryClusterAdvancedSearchRequest { + servers_ordered: vec!["s1".into()], + query: Some("metallica".into()), + entity_types: vec![EntityKind::Album], + filters: Vec::new(), + starred_only: None, + restrict_album_ids: None, + restrict_album_scopes: HashMap::new(), + query_album_title_only: Some(true), + sort: Vec::new(), + limit: 50, + offset: 0, + skip_totals: false, + library_scopes: HashMap::new(), + }, + ) + .unwrap(); + + assert_eq!(resp.albums.len(), 2); + } + #[test] fn merges_albums_by_album_key_with_priority() { let store = LibraryStore::open_in_memory(); diff --git a/src/hooks/useBrowseAlbumTextSearch.test.ts b/src/hooks/useBrowseAlbumTextSearch.test.ts index 261fbc3b..d8064abe 100644 --- a/src/hooks/useBrowseAlbumTextSearch.test.ts +++ b/src/hooks/useBrowseAlbumTextSearch.test.ts @@ -3,18 +3,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const runLocalBrowseAlbums = vi.fn(); const runNetworkBrowseAlbums = vi.fn(); -const raceBrowseWithLocalFallback = vi.fn(); const isClusterMultiLibraryScopeBrowse = vi.fn(); +const isClusterMode = vi.fn(); vi.mock('../utils/library/browseTextSearch', () => ({ BROWSE_TEXT_DEBOUNCE_RACE_MS: 0, BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 0, - browseRaceCountsAlbums: vi.fn(), - raceBrowseWithLocalFallback: (...args: unknown[]) => raceBrowseWithLocalFallback(...args), runLocalBrowseAlbums: (...args: unknown[]) => runLocalBrowseAlbums(...args), runNetworkBrowseAlbums: (...args: unknown[]) => runNetworkBrowseAlbums(...args), })); +vi.mock('../utils/serverCluster/clusterScope', () => ({ + isClusterMode: () => isClusterMode(), +})); + vi.mock('../utils/serverCluster/clusterLibraryScopes', () => ({ isClusterMultiLibraryScopeBrowse: () => isClusterMultiLibraryScopeBrowse(), })); @@ -24,16 +26,13 @@ import { useBrowseAlbumTextSearch } from './useBrowseAlbumTextSearch'; beforeEach(() => { vi.clearAllMocks(); isClusterMultiLibraryScopeBrowse.mockReturnValue(false); + isClusterMode.mockReturnValue(false); runLocalBrowseAlbums.mockResolvedValue([ { id: 'al-1', name: 'Local', artist: 'A', artistId: 'a', songCount: 1, duration: 1 }, ]); runNetworkBrowseAlbums.mockResolvedValue([ { id: 'net-1', name: 'Net', artist: 'B', artistId: 'b', songCount: 1, duration: 1 }, ]); - raceBrowseWithLocalFallback.mockResolvedValue({ - source: 'network', - result: [{ id: 'net-1', name: 'Net', artist: 'B', artistId: 'b', songCount: 1, duration: 1 }], - }); }); describe('useBrowseAlbumTextSearch', () => { @@ -50,20 +49,51 @@ describe('useBrowseAlbumTextSearch', () => { }); expect(runLocalBrowseAlbums).toHaveBeenCalledWith('srv-a', 'beatles', undefined, false, true); - expect(raceBrowseWithLocalFallback).not.toHaveBeenCalled(); expect(runNetworkBrowseAlbums).not.toHaveBeenCalled(); expect(result.current.textSearchAlbums).toHaveLength(1); expect(result.current.textSearchAlbums![0].id).toBe('al-1'); }); - it('races local and network when cluster multi-library scope is inactive', async () => { + it('uses local index only in cluster mode', async () => { + isClusterMode.mockReturnValue(true); + const { result } = renderHook(() => useBrowseAlbumTextSearch('beatles', true, 'srv-a'), ); await waitFor(() => { expect(result.current.textSearchLoading).toBe(false); - expect(raceBrowseWithLocalFallback).toHaveBeenCalled(); + expect(runLocalBrowseAlbums).toHaveBeenCalled(); + }); + + expect(runNetworkBrowseAlbums).not.toHaveBeenCalled(); + }); + + it('prefers local index when enabled and falls back to network only when local is unavailable', async () => { + const { result } = renderHook(() => + useBrowseAlbumTextSearch('beatles', true, 'srv-a'), + ); + + await waitFor(() => { + expect(result.current.textSearchLoading).toBe(false); + expect(runLocalBrowseAlbums).toHaveBeenCalled(); + }); + + expect(runLocalBrowseAlbums).toHaveBeenCalledWith('srv-a', 'beatles', undefined, false, false); + expect(runNetworkBrowseAlbums).not.toHaveBeenCalled(); + expect(result.current.textSearchAlbums![0].id).toBe('al-1'); + }); + + it('falls back to network when the local index cannot serve the query', async () => { + runLocalBrowseAlbums.mockResolvedValueOnce(null); + + const { result } = renderHook(() => + useBrowseAlbumTextSearch('beatles', true, 'srv-a'), + ); + + await waitFor(() => { + expect(result.current.textSearchLoading).toBe(false); + expect(runNetworkBrowseAlbums).toHaveBeenCalled(); }); expect(result.current.textSearchAlbums![0].id).toBe('net-1'); diff --git a/src/hooks/useBrowseAlbumTextSearch.ts b/src/hooks/useBrowseAlbumTextSearch.ts index c9452152..400c5705 100644 --- a/src/hooks/useBrowseAlbumTextSearch.ts +++ b/src/hooks/useBrowseAlbumTextSearch.ts @@ -3,12 +3,11 @@ import { useEffect, useRef, useState } from 'react'; import { BROWSE_TEXT_DEBOUNCE_NETWORK_MS, BROWSE_TEXT_DEBOUNCE_RACE_MS, - browseRaceCountsAlbums, - raceBrowseWithLocalFallback, runLocalBrowseAlbums, runNetworkBrowseAlbums, } from '../utils/library/browseTextSearch'; import { isClusterMultiLibraryScopeBrowse } from '../utils/serverCluster/clusterLibraryScopes'; +import { isClusterMode } from '../utils/serverCluster/clusterScope'; /** * Debounced album title search with local-vs-network race when the @@ -53,33 +52,24 @@ export function useBrowseAlbumTextSearch( return; } - if (isClusterMultiLibraryScopeBrowse()) { - const albums = await runLocalBrowseAlbums( - serverId, - q, - undefined, - losslessOnly, - true, - ); - if (isStale()) return; - setTextSearchAlbums(albums ?? []); + const skipReadyCheck = isClusterMode() || isClusterMultiLibraryScopeBrowse(); + const localAlbums = await runLocalBrowseAlbums( + serverId, + q, + undefined, + losslessOnly, + skipReadyCheck, + ); + if (isStale()) return; + if (localAlbums != null) { + setTextSearchAlbums(localAlbums); setTextSearchLoading(false); return; } - const outcome = await raceBrowseWithLocalFallback( - isStale, - () => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly), - () => runNetworkBrowseAlbums(q), - { - surface: 'albums_browse', - query: q, - indexEnabled, - counts: browseRaceCountsAlbums, - }, - ); + const networkAlbums = await runNetworkBrowseAlbums(q); if (isStale()) return; - setTextSearchAlbums(outcome?.result ?? null); + setTextSearchAlbums(networkAlbums); setTextSearchLoading(false); })(); }, [debouncedFilter, indexEnabled, serverId, losslessOnly, musicLibraryFilterVersion]); diff --git a/src/utils/library/advancedSearchLocal.test.ts b/src/utils/library/advancedSearchLocal.test.ts index d9920ea4..243e8740 100644 --- a/src/utils/library/advancedSearchLocal.test.ts +++ b/src/utils/library/advancedSearchLocal.test.ts @@ -10,6 +10,7 @@ import { trackToSong, tryRunLocalAdvancedSearch, } from './advancedSearchLocal'; +import { invalidateLibraryReadyCache } from './libraryReady'; import * as albumBrowseNetwork from './albumBrowseNetwork'; const opts = (over: Partial[1]> = {}) => ({ @@ -38,6 +39,8 @@ const ready = () => describe('runLocalAdvancedSearch', () => { beforeEach(() => { useLibraryIndexStore.setState({ masterEnabled: true }); + invalidateLibraryReadyCache(); + useAuthStore.setState({ activeClusterId: null, clusters: [] }); }); it('returns null (→ network fallback) when the index is not ready', async () => { @@ -52,6 +55,30 @@ describe('runLocalAdvancedSearch', () => { expect(res).toBeNull(); }); + it('uses single-server advanced search when cluster scope narrows to one member', async () => { + useAuthStore.setState({ + activeClusterId: 'cluster-1', + clusters: [{ id: 'cluster-1', name: 'C', serverIds: ['s1', 's2'] }], + musicLibraryFilterByServer: { s1: ['lib7'] }, + }); + ready(); + let captured: unknown; + onInvoke('library_advanced_search', (args) => { + captured = args; + return { + artists: [], + albums: [], + tracks: [], + totals: { artists: 0, albums: 0, tracks: 0 }, + source: 'local', + }; + }); + await runLocalAdvancedSearch('s1', opts({ query: 'metallica', resultType: 'albums' }), 100); + expect(captured).toMatchObject({ + request: { serverId: expect.any(String), libraryScopeIds: ['lib7'] }, + }); + }); + it('passes libraryScope from the sidebar music library filter', async () => { useAuthStore.setState({ musicLibraryFilterByServer: { s1: ['lib7'] } }); ready(); diff --git a/src/utils/library/advancedSearchLocal.ts b/src/utils/library/advancedSearchLocal.ts index daad5885..2332dbcf 100644 --- a/src/utils/library/advancedSearchLocal.ts +++ b/src/utils/library/advancedSearchLocal.ts @@ -37,7 +37,8 @@ import { isLosslessSuffix } from './losslessFormats'; import { albumIsCompilation } from './albumCompilation'; import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment'; import { clusterAdvancedSearchLocal } from './clusterAdvancedSearchLocal'; -import { isClusterMode } from '../serverCluster/clusterScope'; +import { narrowedClusterMemberIds } from '../serverCluster/clusterAlbumBrowseMembers'; +import { getActiveClusterMemberIds, isClusterMode } from '../serverCluster/clusterScope'; export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100; @@ -324,6 +325,20 @@ export async function runLocalAdvancedSearch( ); const run = async () => { if (isClusterMode()) { + // Mirror All Albums browse: one narrowed member → per-server SQL scope + // (`libraryScopeIds` on that member), not cluster merge without scopes. + const narrowed = narrowedClusterMemberIds(getActiveClusterMemberIds()); + if (narrowed.length === 1) { + const scopedReq = buildRequest( + narrowed[0]!, + opts, + entityTypesFor(opts.resultType), + songsLimit, + 0, + skipTotals, + ); + return libraryAdvancedSearch(scopedReq); + } return await clusterAdvancedSearchLocal({ query: req.query, entityTypes: req.entityTypes, diff --git a/src/utils/library/albumBrowseFilters.ts b/src/utils/library/albumBrowseFilters.ts index 73e143ce..325763cf 100644 --- a/src/utils/library/albumBrowseFilters.ts +++ b/src/utils/library/albumBrowseFilters.ts @@ -130,9 +130,16 @@ export function filterAlbumsByNameTextQuery( albums: SubsonicAlbum[], query: string, ): SubsonicAlbum[] { - const needle = query.trim().toLowerCase(); - if (!needle) return albums; - return albums.filter(a => a.name.toLowerCase().includes(needle)); + const tokens = query + .trim() + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + if (tokens.length === 0) return albums; + return albums.filter(a => { + const name = a.name.toLowerCase(); + return tokens.some(token => name.includes(token)); + }); } export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] { diff --git a/src/utils/library/albumBrowseLoad.test.ts b/src/utils/library/albumBrowseLoad.test.ts index 24b20617..375a48db 100644 --- a/src/utils/library/albumBrowseLoad.test.ts +++ b/src/utils/library/albumBrowseLoad.test.ts @@ -168,9 +168,10 @@ describe('filterAlbumsByNameTextQuery', () => { { id: '3', name: 'Random Title', artist: 'Abbey Road Band', artistId: 'b', songCount: 1, duration: 1 }, ]; - it('matches album title only, not artist name', () => { + it('matches any query word in album title only, not artist name', () => { expect(filterAlbumsByNameTextQuery(albums, 'abbey').map(a => a.id)).toEqual(['1']); expect(filterAlbumsByNameTextQuery(albums, 'beatles').map(a => a.id)).toEqual(['2']); + expect(filterAlbumsByNameTextQuery(albums, 'road abbey').map(a => a.id)).toEqual(['1']); }); });