diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f6eed2..9b960826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -318,6 +318,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Live Search โ€” multi-server local index hits + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#868](https://github.com/Psychotoxical/psysonic/pull/868)** + +* **Live Search** with a local index no longer returns empty or wrong-server hits on multi-server libraries โ€” FTS is scoped to the active server instead of global bm25 across all indexed tracks. +* Local artist/album rows dedupe correctly (one performer no longer fills the whole dropdown); Advanced Search text queries use the same server scope fix. + + + ## [1.46.0] - 2026-05-18 > **๐Ÿ™ Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) โ€” several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src-tauri/crates/psysonic-library/src/advanced_search.rs b/src-tauri/crates/psysonic-library/src/advanced_search.rs index 7f3c3657..73218b61 100644 --- a/src-tauri/crates/psysonic-library/src/advanced_search.rs +++ b/src-tauri/crates/psysonic-library/src/advanced_search.rs @@ -58,6 +58,55 @@ fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 { need.saturating_mul(20).clamp(256, 10_000) } +/// FTS rowid pick scoped to the active server (and optional library folder). +fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> String { + let alias = "t_fts"; + let mut sql = format!( + "SELECT f.rowid FROM track_fts f \ + JOIN track {alias} ON {alias}.rowid = f.rowid \ + WHERE track_fts MATCH ? \ + AND {alias}.server_id = ? \ + AND {alias}.deleted = 0" + ); + if library_scope.is_some() { + sql.push_str(" AND "); + sql.push_str(&library_scope_equals_sql(alias)); + } + sql.push_str(&format!(" ORDER BY bm25(track_fts) LIMIT {pool}")); + sql +} + +fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String { + let alias = "t_fts"; + let mut scope_sql = String::new(); + if library_scope.is_some() { + scope_sql = format!(" AND {}", library_scope_equals_sql(alias)); + } + format!( + "track t INNER JOIN (\ + SELECT f.rowid, bm25(track_fts) AS fts_rank \ + FROM track_fts f \ + JOIN track {alias} ON {alias}.rowid = f.rowid \ + WHERE track_fts MATCH ? \ + AND {alias}.server_id = ? \ + AND {alias}.deleted = 0{scope_sql} \ + ORDER BY fts_rank \ + LIMIT {pool}\ + ) fts_pick ON t.rowid = fts_pick.rowid" + ) +} + +fn scoped_fts_subquery_bind( + server_id: &str, + library_scope: Option<&str>, +) -> Vec { + let mut params = vec![SqlValue::Text(server_id.to_string())]; + if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { + params.push(SqlValue::Text(scope.to_string())); + } + params +} + /// `library_advanced_search` (ยง5.13). Runs only the queries named in /// `entityTypes`; absent entities return empty + zero totals. pub fn run_advanced_search( @@ -194,12 +243,8 @@ fn build_track( if let Some(q) = text.and_then(fts_track_prefix_match_query) { applied.insert("text".to_string()); let pool = fts_candidate_pool_size(limit, offset); - let from = format!( - "track t INNER JOIN (\ - SELECT rowid, bm25(track_fts) AS fts_rank FROM track_fts \ - WHERE track_fts MATCH ? ORDER BY fts_rank LIMIT {pool}\ - ) fts_pick ON t.rowid = fts_pick.rowid" - ); + let scope = trimmed_nonempty(req.library_scope.as_deref()); + let from = scoped_fts_pick_join_sql(pool, scope.as_deref()); let order = order_clause(&req.sort, EntityKind::Track) .unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string()); return query_rows_fts( @@ -207,6 +252,7 @@ fn build_track( &cols, &from, &q, + &scoped_fts_subquery_bind(&req.server_id, scope.as_deref()), &w, &order, limit, @@ -497,18 +543,24 @@ fn build_album_from_fts( applied.insert("text".to_string()); let need = limit.saturating_add(offset) as i64; let pool = (need.saturating_mul(8)).clamp(64, 2_000); + let scope = trimmed_nonempty(req.library_scope.as_deref()); let mut w = WhereBuilder::new(); - w.push_param( + w.push_params( &format!( - "t.rowid IN (SELECT rowid FROM track_fts WHERE track_fts MATCH ? ORDER BY bm25(track_fts) LIMIT {pool})" + "t.rowid IN ({})", + scoped_fts_rowid_subquery_sql(pool, scope.as_deref()) ), - SqlValue::Text(fts.to_string()), + { + let mut p = vec![SqlValue::Text(fts.to_string())]; + p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref())); + 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 != ''"); - if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + if let Some(scope) = scope { let clause = library_scope_equals_sql("t"); w.push_param(&clause, SqlValue::Text(scope)); } @@ -605,18 +657,24 @@ fn build_artist_from_fts( applied.insert("text".to_string()); let need = limit.saturating_add(offset) as i64; let pool = (need.saturating_mul(8)).clamp(64, 2_000); + let scope = trimmed_nonempty(req.library_scope.as_deref()); let mut w = WhereBuilder::new(); - w.push_param( + w.push_params( &format!( - "t.rowid IN (SELECT rowid FROM track_fts WHERE track_fts MATCH ? ORDER BY bm25(track_fts) LIMIT {pool})" + "t.rowid IN ({})", + scoped_fts_rowid_subquery_sql(pool, scope.as_deref()) ), - SqlValue::Text(fts.to_string()), + { + let mut p = vec![SqlValue::Text(fts.to_string())]; + p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref())); + p + }, ); w.push_raw("t.deleted = 0"); w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone())); w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''"); - if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) { + if let Some(scope) = scope { let clause = library_scope_equals_sql("t"); w.push_param(&clause, SqlValue::Text(scope)); } @@ -805,6 +863,10 @@ impl WhereBuilder { self.clauses.push(sql.to_string()); self.params.push(param); } + fn push_params(&mut self, sql: &str, params: Vec) { + self.clauses.push(sql.to_string()); + self.params.extend(params); + } fn where_sql(&self) -> String { self.clauses.join(" AND ") } @@ -853,6 +915,7 @@ fn query_rows_fts( select_cols: &str, from: &str, fts_match: &str, + fts_subquery_params: &[SqlValue], w: &WhereBuilder, order_sql: &str, limit: u32, @@ -866,6 +929,7 @@ where let where_sql = w.where_sql(); store.with_read_conn(|conn| { let mut bind: Vec = vec![SqlValue::Text(fts_match.to_string())]; + bind.extend(fts_subquery_params.iter().cloned()); bind.extend(w.params.iter().cloned()); let total = count_matching_rows(conn, from, &where_sql, &bind, skip_totals)?; diff --git a/src-tauri/crates/psysonic-library/src/live_search.rs b/src-tauri/crates/psysonic-library/src/live_search.rs index 17908476..bb20c623 100644 --- a/src-tauri/crates/psysonic-library/src/live_search.rs +++ b/src-tauri/crates/psysonic-library/src/live_search.rs @@ -5,14 +5,16 @@ use std::collections::{HashMap, HashSet}; -use rusqlite::params; - use crate::dto::{LibraryAlbumDto, LibraryArtistDto, LibraryLiveSearchResponse, LibraryTrackDto}; -use crate::search::{fts_column_prefix_query, fts_query_meets_min_len, library_scope_equals_sql}; +use crate::search::{ + fts_album_prefix_any_token_match_query, fts_artist_prefix_any_token_match_query, + fts_query_meets_min_len, fts_track_prefix_any_token_match_query, library_scope_equals_sql, +}; use crate::store::LibraryStore; -const SONG_FTS_COLUMNS: [&str; 4] = ["title", "artist", "album", "album_artist"]; -const ALBUM_FTS_COLUMNS: [&str; 2] = ["album", "album_artist"]; +const TRACK_FTS_BM25_RANK: &str = "bm25(track_fts, 10.0, 3.0, 5.0, 3.0, 0.0)"; +/// FTS row candidates before GROUP BY dedupe โ€” avoids one artist filling the whole cap. +const LIVE_SEARCH_FTS_CANDIDATE_CAP: i64 = 150; struct LiveHit { track: LibraryTrackDto, @@ -39,6 +41,7 @@ pub fn run_live_search( store.with_read_conn(|conn| { let scope = trimmed_scope(library_scope); + // Songs first โ€” smallest FTS cap; warms the page cache for follow-up queries. let songs = query_songs(conn, query, server_id, scope.as_deref(), song_limit)?; let artists = query_artists(conn, query, server_id, scope.as_deref(), artist_limit)?; let albums = query_albums(conn, query, server_id, scope.as_deref(), album_limit)?; @@ -51,20 +54,44 @@ pub fn run_live_search( }) } -/// Top FTS rowids for one or more column-scoped MATCH strings (deduped, ordered). +/// Top FTS rowids for column-scoped MATCH, scoped to `server_id` (multi-server safe). fn collect_fts_rowids( conn: &rusqlite::Connection, match_queries: &[String], + server_id: &str, + library_scope: Option<&str>, per_query_limit: i64, total_limit: usize, ) -> rusqlite::Result> { - let sql = - "SELECT rowid FROM track_fts WHERE track_fts MATCH ?1 ORDER BY bm25(track_fts) LIMIT ?2"; - let mut stmt = conn.prepare(sql)?; + let scope = trimmed_scope(library_scope); + let mut scope_sql = String::new(); + if scope.is_some() { + scope_sql = format!(" AND {}", library_scope_equals_sql("c")); + } + let sql = format!( + "SELECT f.rowid FROM track_fts f \ + WHERE track_fts MATCH ? \ + AND EXISTS (\ + SELECT 1 FROM track c \ + WHERE c.rowid = f.rowid \ + AND c.server_id = ? \ + AND c.deleted = 0{scope_sql}\ + ) \ + ORDER BY {TRACK_FTS_BM25_RANK} LIMIT ?", + ); + let mut stmt = conn.prepare(&sql)?; let mut seen = HashSet::new(); let mut rowids = Vec::new(); for mq in match_queries { - let rows = stmt.query_map(params![mq, per_query_limit], |r| r.get(0))?; + let mut bind: Vec = vec![ + rusqlite::types::Value::Text(mq.clone()), + rusqlite::types::Value::Text(server_id.to_string()), + ]; + if let Some(ref s) = scope { + bind.push(rusqlite::types::Value::Text(s.clone())); + } + bind.push(rusqlite::types::Value::Integer(per_query_limit)); + let rows = stmt.query_map(rusqlite::params_from_iter(bind.iter()), |r| r.get(0))?; for rowid in rows { let rowid = rowid?; if seen.insert(rowid) { @@ -78,15 +105,6 @@ fn collect_fts_rowids( Ok(rowids) } -fn column_matches(query: &str, columns: &[&str]) -> Result, String> { - columns - .iter() - .map(|col| { - fts_column_prefix_query(col, query).ok_or_else(|| "empty query".to_string()) - }) - .collect() -} - fn trimmed_scope(scope: Option<&str>) -> Option { scope .map(str::trim) @@ -106,25 +124,25 @@ fn append_library_scope( } } -fn query_songs( - conn: &rusqlite::Connection, - query: &str, - server_id: &str, - library_scope: Option<&str>, - limit: u32, -) -> rusqlite::Result> { - let matches = column_matches(query, &SONG_FTS_COLUMNS).map_err(|e| { - rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - e, - ))) - })?; - let per_col = i64::from(limit.max(4)); - let rowids = collect_fts_rowids(conn, &matches, per_col, limit as usize)?; - if rowids.is_empty() { - return Ok(Vec::new()); +fn scoped_exists_sql(library_scope: Option<&str>, extra: &str) -> String { + let mut scope_sql = String::new(); + if library_scope.is_some() { + scope_sql = format!(" AND {}", library_scope_equals_sql("c")); + } + format!( + "EXISTS (\ + SELECT 1 FROM track c \ + WHERE c.rowid = f.rowid \ + AND c.server_id = ? \ + AND c.deleted = 0{extra}{scope_sql}\ + )" + ) +} + +fn push_scope_bind(params: &mut Vec, library_scope: Option<&str>) { + if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) { + params.push(rusqlite::types::Value::Text(scope.to_string())); } - fetch_tracks_by_rowids(conn, &rowids, server_id, library_scope) } fn query_artists( @@ -134,76 +152,75 @@ fn query_artists( library_scope: Option<&str>, limit: u32, ) -> rusqlite::Result> { - let Some(artist_fts) = fts_column_prefix_query("artist", query) else { + let Some(artist_fts) = fts_artist_prefix_any_token_match_query(query) else { return Ok(Vec::new()); }; - let fetch = limit.saturating_mul(3).clamp(limit, 24); - let rowids = collect_fts_rowids(conn, &[artist_fts], i64::from(fetch), fetch as usize)?; - if rowids.is_empty() { - return Ok(Vec::new()); - } - let placeholders = rowid_placeholders(rowids.len()); + let exists = scoped_exists_sql( + library_scope, + " AND c.artist_id IS NOT NULL AND c.artist_id != ''", + ); let sql = format!( - "SELECT t.server_id, t.artist_id, t.artist, t.synced_at, t.rowid \ - FROM track t \ - WHERE t.rowid IN ({placeholders}) \ - AND t.server_id = ? \ + "WITH fts_hits AS (\ + SELECT f.rowid, {TRACK_FTS_BM25_RANK} AS rank \ + FROM track_fts f \ + WHERE track_fts MATCH ? \ + AND {exists} \ + ORDER BY rank \ + LIMIT ?\ + ) \ + SELECT t.server_id, t.artist_id, t.artist, t.synced_at, MIN(h.rank) AS best_rank \ + FROM fts_hits h \ + JOIN track t ON t.rowid = h.rowid \ + WHERE t.server_id = ? \ AND t.deleted = 0 \ AND t.artist_id IS NOT NULL AND t.artist_id != ''" ); - let mut params: Vec = rowids - .iter() - .copied() - .map(rusqlite::types::Value::Integer) - .collect(); - params.push(rusqlite::types::Value::Text(server_id.to_string())); let mut sql = sql; + let mut params: Vec = vec![ + rusqlite::types::Value::Text(artist_fts), + rusqlite::types::Value::Text(server_id.to_string()), + ]; + push_scope_bind(&mut params, library_scope); + params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP)); + params.push(rusqlite::types::Value::Text(server_id.to_string())); append_library_scope(&mut sql, &mut params, library_scope); - let rank: HashMap = rowids - .iter() - .enumerate() - .map(|(i, &rid)| (rid, i)) - .collect(); - let mut ranked: Vec<(usize, LibraryArtistDto)> = Vec::new(); + sql.push_str(" GROUP BY t.artist_id ORDER BY best_rank LIMIT ?"); + params.push(rusqlite::types::Value::Integer(i64::from(limit))); let mut stmt = conn.prepare(&sql)?; - for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok(( - r.get::<_, i64>(4)?, - r.get::<_, String>(0)?, - r.get::<_, String>(1)?, - r.get::<_, Option>(2)?, - r.get::<_, i64>(3)?, - )) - })? { - let (rowid, server_id, artist_id, artist, synced_at) = row?; - let fts_rank = rank.get(&rowid).copied().unwrap_or(usize::MAX); - ranked.push(( - fts_rank, - LibraryArtistDto { - server_id, - id: artist_id, - name: artist.unwrap_or_default(), - album_count: None, - synced_at, - raw_json: serde_json::Value::Null, - }, - )); - } - ranked.sort_by_key(|(r, _)| *r); let mut out = Vec::new(); - let mut seen = HashSet::new(); - for (_, dto) in ranked { - if !seen.insert(dto.id.clone()) { - continue; - } - out.push(dto); - if out.len() >= limit as usize { - break; - } + for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(LibraryArtistDto { + server_id: r.get(0)?, + id: r.get(1)?, + name: r.get::<_, Option>(2)?.unwrap_or_default(), + album_count: None, + synced_at: r.get(3)?, + raw_json: serde_json::Value::Null, + }) + })? { + out.push(row?); } Ok(out) } +fn query_songs( + conn: &rusqlite::Connection, + query: &str, + server_id: &str, + library_scope: Option<&str>, + limit: u32, +) -> rusqlite::Result> { + let Some(song_fts) = fts_track_prefix_any_token_match_query(query) else { + return Ok(Vec::new()); + }; + let per_col = i64::from(limit.max(4)); + let rowids = collect_fts_rowids(conn, &[song_fts], server_id, library_scope, per_col, limit as usize)?; + if rowids.is_empty() { + return Ok(Vec::new()); + } + fetch_tracks_by_rowids(conn, &rowids, server_id, library_scope) +} + fn query_albums( conn: &rusqlite::Connection, query: &str, @@ -211,101 +228,61 @@ fn query_albums( library_scope: Option<&str>, limit: u32, ) -> rusqlite::Result> { - let matches = column_matches(query, &ALBUM_FTS_COLUMNS).map_err(|e| { - rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - e, - ))) - })?; - let fetch = limit.saturating_mul(3).clamp(limit, 24); - let rowids = collect_fts_rowids(conn, &matches, i64::from(fetch), fetch as usize)?; - if rowids.is_empty() { + let Some(album_fts) = fts_album_prefix_any_token_match_query(query) else { return Ok(Vec::new()); - } - let placeholders = rowid_placeholders(rowids.len()); + }; + let exists = scoped_exists_sql( + library_scope, + " AND c.album_id IS NOT NULL AND c.album_id != ''", + ); let sql = format!( - "SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ - t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.rowid \ - FROM track t \ - WHERE t.rowid IN ({placeholders}) \ - AND t.server_id = ? \ + "WITH fts_hits AS (\ + SELECT f.rowid, {TRACK_FTS_BM25_RANK} AS rank \ + FROM track_fts f \ + WHERE track_fts MATCH ? \ + AND {exists} \ + ORDER BY rank \ + LIMIT ?\ + ) \ + SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \ + t.genre, t.cover_art_id, t.starred_at, t.synced_at, MIN(h.rank) AS best_rank \ + FROM fts_hits h \ + JOIN track t ON t.rowid = h.rowid \ + WHERE t.server_id = ? \ AND t.deleted = 0 \ AND t.album_id IS NOT NULL AND t.album_id != ''" ); - let mut params: Vec = rowids - .iter() - .copied() - .map(rusqlite::types::Value::Integer) - .collect(); - params.push(rusqlite::types::Value::Text(server_id.to_string())); let mut sql = sql; + let mut params: Vec = vec![ + rusqlite::types::Value::Text(album_fts), + rusqlite::types::Value::Text(server_id.to_string()), + ]; + push_scope_bind(&mut params, library_scope); + params.push(rusqlite::types::Value::Integer(LIVE_SEARCH_FTS_CANDIDATE_CAP)); + params.push(rusqlite::types::Value::Text(server_id.to_string())); append_library_scope(&mut sql, &mut params, library_scope); - let rank: HashMap = rowids - .iter() - .enumerate() - .map(|(i, &rid)| (rid, i)) - .collect(); - let mut ranked: Vec<(usize, LibraryAlbumDto)> = Vec::new(); + sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?"); + params.push(rusqlite::types::Value::Integer(i64::from(limit))); let mut stmt = conn.prepare(&sql)?; - for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok(( - r.get::<_, i64>(10)?, - r.get::<_, String>(0)?, - r.get::<_, String>(1)?, - r.get::<_, String>(2)?, - r.get::<_, Option>(3)?, - r.get::<_, Option>(4)?, - r.get::<_, Option>(5)?, - r.get::<_, Option>(6)?, - r.get::<_, Option>(7)?, - r.get::<_, Option>(8)?, - r.get::<_, i64>(9)?, - )) - })? { - let ( - rowid, - server_id, - album_id, - album, - artist, - artist_id, - year, - genre, - cover_art_id, - starred_at, - synced_at, - ) = row?; - let fts_rank = rank.get(&rowid).copied().unwrap_or(usize::MAX); - ranked.push(( - fts_rank, - 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: serde_json::Value::Null, - }, - )); - } - ranked.sort_by_key(|(r, _)| *r); let mut out = Vec::new(); - let mut seen = HashSet::new(); - for (_, dto) in ranked { - if !seen.insert(dto.id.clone()) { - continue; - } - out.push(dto); - if out.len() >= limit as usize { - break; - } + for row in 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: serde_json::Value::Null, + }) + })? { + out.push(row?); } Ok(out) } @@ -608,6 +585,112 @@ mod tests { assert_eq!(resp.tracks[0].id, "t1"); } + #[test] + fn live_search_fts_scoped_to_server_not_global_bm25() { + let store = LibraryStore::open_in_memory(); + let mut batch = Vec::new(); + for i in 0..20 { + batch.push(track( + "s_big", + &format!("t{i}"), + "Song", + "Nightblaze", + "Album", + &format!("al{i}"), + "ar_nightblaze", + )); + } + batch.push(track( + "s_small", + "t_nw", + "Ghost Love Score", + "Nightwish", + "Once", + "al_nw", + "ar_nw", + )); + TrackRepository::new(&store) + .upsert_batch(&batch) + .unwrap(); + let resp = run_live_search(&store, "s_small", "night", None, 5, 5, 10).unwrap(); + assert!( + resp.artists.iter().any(|a| a.name == "Nightwish"), + "expected Nightwish on s_small; global bm25 must not crowd out the active server" + ); + } + + #[test] + fn live_search_returns_distinct_artists_not_one_per_many_tracks() { + let store = LibraryStore::open_in_memory(); + let mut batch = Vec::new(); + for i in 0..12 { + batch.push(track( + "s1", + &format!("t_m{i}"), + "Song", + "Metallica", + "Album", + &format!("al_m{i}"), + "ar_meta", + )); + } + for (id, name, artist_id) in [ + ("ar_metal1", "Metallica Tribute", "ar_t1"), + ("ar_metal2", "Metallium", "ar_t2"), + ("ar_metal3", "Metalloid", "ar_t3"), + ] { + batch.push(track( + "s1", + &format!("t_{artist_id}"), + "One", + name, + "Other", + id, + artist_id, + )); + } + TrackRepository::new(&store).upsert_batch(&batch).unwrap(); + let resp = run_live_search(&store, "s1", "metall", None, 5, 5, 10).unwrap(); + assert!( + resp.artists.len() >= 3, + "expected distinct metall* artists, got {} ({:?})", + resp.artists.len(), + resp.artists.iter().map(|a| a.name.as_str()).collect::>() + ); + } + + #[test] + fn live_search_multiword_album_matches_any_token_not_only_first() { + let store = LibraryStore::open_in_memory(); + TrackRepository::new(&store) + .upsert_batch(&[ + track( + "s1", + "t1", + "Intro", + "Artist", + "Supreme Ballads", + "al_supreme", + "ar1", + ), + track( + "s1", + "t2", + "Other", + "Artist", + "Unrelated", + "al2", + "ar1", + ), + ]) + .unwrap(); + let resp = run_live_search(&store, "s1", "love supreme", None, 5, 5, 10).unwrap(); + assert!( + resp.albums.iter().any(|a| a.name == "Supreme Ballads"), + "second token supreme must match album title; AND-all-tokens would miss this album" + ); + } + /// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture` #[test] #[ignore] diff --git a/src-tauri/crates/psysonic-library/src/search.rs b/src-tauri/crates/psysonic-library/src/search.rs index cfd55817..b3c719ff 100644 --- a/src-tauri/crates/psysonic-library/src/search.rs +++ b/src-tauri/crates/psysonic-library/src/search.rs @@ -90,6 +90,21 @@ pub(crate) fn fts_prefix_token_expr(raw: &str) -> Option { fts_token_expr_with(raw, true) } +/// Navidrome-style any-word prefix match (`"a"* OR "b"*`). +pub(crate) fn fts_prefix_token_or_expr(raw: &str) -> Option { + let tokens: Vec = raw + .split_whitespace() + .map(|t| format!("\"{}\"*", t.replace('"', "\"\""))) + .collect(); + if tokens.is_empty() { + None + } else if tokens.len() == 1 { + Some(tokens.into_iter().next().unwrap()) + } else { + Some(tokens.join(" OR ")) + } +} + fn fts_token_expr_with(raw: &str, prefix: bool) -> Option { let tokens: Vec = raw .split_whitespace() @@ -131,6 +146,31 @@ pub(crate) fn fts_album_prefix_match_query(raw: &str) -> Option { }) } +/// 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| { + format!("(album : ({tokens}) OR album_artist : ({tokens}))") + }) +} + +/// Live Search artist match โ€” performer fields only (not album title). +pub(crate) fn fts_artist_prefix_any_token_match_query(raw: &str) -> Option { + fts_prefix_token_or_expr(raw).map(|tokens| { + format!("(artist : ({tokens}) OR album_artist : ({tokens}))") + }) +} + +/// Live Search song match โ€” any query word across display columns. +pub(crate) fn fts_track_prefix_any_token_match_query(raw: &str) -> Option { + fts_prefix_token_or_expr(raw).map(|tokens| { + ["title", "artist", "album", "album_artist"] + .iter() + .map(|col| format!("{col} : ({tokens})")) + .collect::>() + .join(" OR ") + }) +} + /// Song / track entity: match primary display fields (excludes `genre` to cut /// noise and FTS fan-out on large libraries). pub(crate) fn fts_track_match_query(raw: &str) -> Option { @@ -349,6 +389,22 @@ mod tests { ); } + #[test] + fn fts_prefix_token_or_expr_matches_any_word() { + assert_eq!( + fts_prefix_token_or_expr("love supreme").as_deref(), + Some("\"love\"* OR \"supreme\"*") + ); + } + + #[test] + fn fts_album_prefix_any_token_match_query_or_across_album_fields() { + assert_eq!( + fts_album_prefix_any_token_match_query("dark side").as_deref(), + Some("(album : (\"dark\"* OR \"side\"*) OR album_artist : (\"dark\"* OR \"side\"*))") + ); + } + #[test] fn fts_prefix_token_expr_ands_multiword_prefixes() { assert_eq!( diff --git a/src/api/subsonicSearch.ts b/src/api/subsonicSearch.ts index 641e9cf7..3f3f9950 100644 --- a/src/api/subsonicSearch.ts +++ b/src/api/subsonicSearch.ts @@ -22,6 +22,7 @@ export async function search( artistCount?: number; songCount?: number; signal?: AbortSignal; + timeout?: number; }, ): Promise { if (!query.trim()) return { artists: [], albums: [], songs: [] }; @@ -40,7 +41,7 @@ export async function search( songCount: options?.songCount ?? 10, ...libraryFilterParams(), }, - 15000, + options?.timeout ?? 15000, options?.signal, ); const r = data.searchResult3 ?? {}; diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 0790c80a..546f060a 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -7,11 +7,17 @@ import { LIVE_SEARCH_DEBOUNCE_RACE_MS, EMPTY_SEARCH_RESULTS, liveSearchQueryTooShort, + mergeLiveSearchResults, runLocalLiveSearch, runNetworkLiveSearch, } from '../utils/library/liveSearchLocal'; -import { raceSearchSources } from '../utils/library/searchRace'; +import { raceLiveSearch } from '../utils/library/searchRace'; import { libraryIsReady } from '../utils/library/libraryReady'; +import { + emitLiveSearchDebug, + searchHitCounts, + searchResultSamples, +} from '../utils/library/liveSearchDebug'; import { logLibrarySearch, } from '../utils/library/libraryDevLog'; @@ -172,24 +178,57 @@ export default function LiveSearch() { const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId }; if (indexEnabled && serverId) { - const winner = await raceSearchSources( - [ - { - source: 'local', - run: () => runLocalLiveSearch(serverId, q, raceCtx), - }, - { - source: 'network', - run: () => runNetworkLiveSearch(q, abort.signal), - }, - ], + const winner = await raceLiveSearch( + () => runLocalLiveSearch(serverId, q, raceCtx), + () => runNetworkLiveSearch(q, abort.signal), isStale, + meta => { + emitLiveSearchDebug('race_settled', { + query: q, + winner: meta.winner, + localMs: meta.localMs, + networkMs: meta.networkMs, + localHits: meta.localHits, + networkHits: meta.networkHits, + }); + if (isStale()) return; + if (meta.localResult && meta.networkResult) { + const primary = + meta.winner === 'local' ? meta.localResult : meta.networkResult; + const supplement = + meta.winner === 'local' ? meta.networkResult : meta.localResult; + const merged = mergeLiveSearchResults(primary, supplement); + const primaryHits = searchHitCounts(primary); + const mergedHits = searchHitCounts(merged); + if (mergedHits !== primaryHits) { + setResults(merged); + setSearchSource(meta.winner); + emitLiveSearchDebug('race_merged', { + query: q, + winner: meta.winner, + before: primaryHits, + after: mergedHits, + samples: searchResultSamples(merged), + }); + } + } + }, ); if (isStale()) return; if (winner) { setResults(winner.result); setSearchSource(winner.source); setOpen(true); + const samples = searchResultSamples(winner.result); + emitLiveSearchDebug('race_winner', { + query: q, + winner: winner.source, + raceMs: winner.durationMs, + hits: searchHitCounts(winner.result), + samples, + path: 'search_race', + localReady: localReadyRef.current, + }); logLibrarySearch({ at: new Date().toISOString(), query: q, diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 56a485d5..df00b77f 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -129,6 +129,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Local library index: full resync orphan sweep (IS-7) โ€” remove server-deleted tracks after successful re-sync (PR #861)', 'Track enrichment: oximedia BPM/mood analysis, mood-group Advanced Search, queue display, unified playback analysis dispatch (PR #863)', 'Server index-key rebuild follow-up: startup-safe migration orchestration, per-server analysis strategy controls, playback/cache scope hardening, and backup/restore for library databases with blocking progress UX (PR #864)', + 'Live Search: server-scoped local FTS, multi-server hit fix, and local vs search3 race merge (PR #868)', ], }, { diff --git a/src/utils/library/liveSearchDebug.test.ts b/src/utils/library/liveSearchDebug.test.ts new file mode 100644 index 00000000..78bbdeb3 --- /dev/null +++ b/src/utils/library/liveSearchDebug.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { onInvoke } from '@/test/mocks/tauri'; +import { useAuthStore } from '@/store/authStore'; +import { emitLiveSearchDebug } from './liveSearchDebug'; + +describe('emitLiveSearchDebug', () => { + beforeEach(() => { + useAuthStore.setState({ loggingMode: 'normal' }); + }); + + it('forwards JSON to frontend_debug_log in debug mode', () => { + useAuthStore.setState({ loggingMode: 'debug' }); + let captured: unknown; + onInvoke('frontend_debug_log', args => { + captured = args; + return undefined; + }); + emitLiveSearchDebug('race_winner', { query: 'metal', winner: 'network' }); + expect(captured).toEqual({ + scope: 'live-search', + message: JSON.stringify({ + step: 'race_winner', + details: { query: 'metal', winner: 'network' }, + }), + }); + }); + + it('is a no-op when logging mode is not debug', () => { + let invoked = false; + onInvoke('frontend_debug_log', () => { + invoked = true; + return undefined; + }); + emitLiveSearchDebug('race_winner', { query: 'x' }); + expect(invoked).toBe(false); + }); +}); diff --git a/src/utils/library/liveSearchDebug.ts b/src/utils/library/liveSearchDebug.ts new file mode 100644 index 00000000..f8e777a8 --- /dev/null +++ b/src/utils/library/liveSearchDebug.ts @@ -0,0 +1,27 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { SearchResults } from '../../api/subsonicTypes'; +import { useAuthStore } from '../../store/authStore'; + +export function searchHitCounts(result: SearchResults): string { + return `${result.artists.length}/${result.albums.length}/${result.songs.length}`; +} + +export function searchResultSamples(result: SearchResults, max = 2) { + return { + artists: result.artists.slice(0, max).map(a => a.name), + albums: result.albums.slice(0, max).map(a => a.name), + songs: result.songs.slice(0, max).map(s => s.title), + }; +} + +/** + * Settings โ†’ Logging โ†’ **Debug** โ†’ Rust debug log file (`frontend_debug_log`). + * Same transport as normalization / lucky-mix / orbit. + */ +export function emitLiveSearchDebug(step: string, details?: Record): void { + if (useAuthStore.getState().loggingMode !== 'debug') return; + void invoke('frontend_debug_log', { + scope: 'live-search', + message: JSON.stringify({ step, details }), + }).catch(() => {}); +} diff --git a/src/utils/library/liveSearchLocal.test.ts b/src/utils/library/liveSearchLocal.test.ts index 414a5c47..4c84130e 100644 --- a/src/utils/library/liveSearchLocal.test.ts +++ b/src/utils/library/liveSearchLocal.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { onInvoke } from '@/test/mocks/tauri'; +import type { SearchResults } from '../../api/subsonicTypes'; import { useAuthStore } from '@/store/authStore'; import { liveSearchQueryTooShort, + mergeLiveSearchResults, runLocalLiveSearch, } from './liveSearchLocal'; @@ -96,3 +98,22 @@ describe('liveSearchQueryTooShort', () => { expect(liveSearchQueryTooShort('ab')).toBe(false); }); }); + +describe('mergeLiveSearchResults', () => { + it('keeps local order and fills gaps from network', () => { + const local: SearchResults = { + artists: [{ id: 'a1', name: 'Local' }], + albums: [], + songs: [{ id: 's1', title: 'Song', artist: 'A', album: 'Al', albumId: 'al0', duration: 1 }], + }; + const network: SearchResults = { + artists: [{ id: 'a2', name: 'Net' }], + albums: [{ id: 'al1', name: 'Album', artist: 'A', artistId: 'a2', songCount: 1, duration: 100 }], + songs: [{ id: 's2', title: 'Other', artist: 'B', album: 'Bl', albumId: 'al1', duration: 2 }], + }; + const merged = mergeLiveSearchResults(local, network); + expect(merged.artists.map(a => a.id)).toEqual(['a1', 'a2']); + expect(merged.albums.map(a => a.id)).toEqual(['al1']); + expect(merged.songs.map(s => s.id)).toEqual(['s1', 's2']); + }); +}); diff --git a/src/utils/library/liveSearchLocal.ts b/src/utils/library/liveSearchLocal.ts index 609e7c01..dfe65b10 100644 --- a/src/utils/library/liveSearchLocal.ts +++ b/src/utils/library/liveSearchLocal.ts @@ -20,6 +20,15 @@ export const LIVE_SEARCH_DEBOUNCE_NETWORK_MS = 300; /** Debounce when local + network run in parallel. */ export const LIVE_SEARCH_DEBOUNCE_RACE_MS = 200; +/** search3 timeout for Live Search network arm (race must not hang on slow servers). */ +export const LIVE_SEARCH_NETWORK_TIMEOUT_MS = 8000; + +export const LIVE_SEARCH_LIMITS = { + artists: 5, + albums: 5, + songs: 10, +} as const; + /** Local FTS skipped below this length โ€” see `LOCAL_FTS_MIN_QUERY_CHARS` in Rust. */ export const LOCAL_FTS_MIN_QUERY_CHARS = 2; @@ -124,10 +133,44 @@ export async function runNetworkLiveSearch( const q = query.trim(); if (liveSearchQueryTooShort(q)) return null; try { - return await search(q, { signal }); + return await search(q, { + signal, + timeout: LIVE_SEARCH_NETWORK_TIMEOUT_MS, + artistCount: LIVE_SEARCH_LIMITS.artists, + albumCount: LIVE_SEARCH_LIMITS.albums, + songCount: LIVE_SEARCH_LIMITS.songs, + }); } catch (err) { const name = err instanceof Error ? err.name : ''; if (name === 'CanceledError' || name === 'AbortError') return null; throw err; } } + +/** Keep local ordering; fill remaining slots from network when search3 returns more hits. */ +export function mergeLiveSearchResults( + primary: SearchResults, + supplement: SearchResults, + limits: { artists: number; albums: number; songs: number } = LIVE_SEARCH_LIMITS, +): SearchResults { + const mergeSlice = ( + primaryItems: T[], + extraItems: T[], + limit: number, + ): T[] => { + const seen = new Set(primaryItems.map(i => i.id)); + const out = [...primaryItems]; + for (const item of extraItems) { + if (out.length >= limit) break; + if (seen.has(item.id)) continue; + seen.add(item.id); + out.push(item); + } + return out; + }; + return { + artists: mergeSlice(primary.artists, supplement.artists, limits.artists), + albums: mergeSlice(primary.albums, supplement.albums, limits.albums), + songs: mergeSlice(primary.songs, supplement.songs, limits.songs), + }; +} diff --git a/src/utils/library/searchRace.live.test.ts b/src/utils/library/searchRace.live.test.ts new file mode 100644 index 00000000..7b6774b7 --- /dev/null +++ b/src/utils/library/searchRace.live.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { SearchResults } from '../../api/subsonicTypes'; +import { raceLiveSearch, type LiveSearchRaceSettled } from './searchRace'; + +const empty: SearchResults = { artists: [], albums: [], songs: [] }; +const localHits: SearchResults = { + artists: [{ id: 'a1', name: 'Local' }], + albums: [], + songs: [], +}; +const networkHits: SearchResults = { + artists: [{ id: 'a2', name: 'Network' }], + albums: [], + songs: [], +}; + +describe('raceLiveSearch', () => { + it('network wins when it returns hits first', async () => { + const winner = await raceLiveSearch( + () => + new Promise(resolve => { + setTimeout(() => resolve(localHits), 40); + }), + async () => networkHits, + () => false, + ); + expect(winner?.source).toBe('network'); + }); + + it('waits for network when local is empty', async () => { + const winner = await raceLiveSearch( + async () => empty, + async () => networkHits, + () => false, + ); + expect(winner?.source).toBe('network'); + }); + + it('local wins when network is empty and local has hits', async () => { + const winner = await raceLiveSearch( + async () => localHits, + async () => empty, + () => false, + ); + expect(winner?.source).toBe('local'); + }); + + it('waits for local when network is empty', async () => { + const winner = await raceLiveSearch( + () => + new Promise(resolve => { + setTimeout(() => resolve(localHits), 30); + }), + async () => empty, + () => false, + ); + expect(winner?.source).toBe('local'); + }); + + it('does not pick empty local before network returns hits', async () => { + const winner = await raceLiveSearch( + async () => empty, + () => + new Promise(resolve => { + setTimeout(() => resolve(networkHits), 30); + }), + () => false, + ); + expect(winner?.source).toBe('network'); + }); + + it('calls onSettled with both runner timings', async () => { + let settled: LiveSearchRaceSettled | null = null; + await raceLiveSearch( + async () => localHits, + async () => networkHits, + () => false, + meta => { + settled = meta; + }, + ); + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + expect(settled).not.toBeNull(); + expect(settled!.localHits).toBe('1/0/0'); + expect(settled!.networkHits).toBe('1/0/0'); + expect(settled!.localMs).toBeGreaterThanOrEqual(0); + expect(settled!.networkMs).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/src/utils/library/searchRace.ts b/src/utils/library/searchRace.ts index c3060c42..f8a4eee2 100644 --- a/src/utils/library/searchRace.ts +++ b/src/utils/library/searchRace.ts @@ -2,6 +2,8 @@ * Parallel local vs network search โ€” first successful backend wins. */ +import type { SearchResults } from '../../api/subsonicTypes'; + export type SearchRaceSource = 'local' | 'network'; export interface SearchRaceWinner { @@ -70,3 +72,161 @@ export async function raceSearchSources( } }); } + +export function searchResultsHaveHits(results: SearchResults): boolean { + return results.artists.length > 0 || results.albums.length > 0 || results.songs.length > 0; +} + +export interface LiveSearchRaceSettled { + winner: SearchRaceSource; + localMs: number; + networkMs: number; + localHits: string; + networkHits: string; + localResult: SearchResults | null; + networkResult: SearchResults | null; +} + +function hitCounts(r: SearchResults): string { + return `${r.artists.length}/${r.albums.length}/${r.songs.length}`; +} + +function emptySearchResults(): SearchResults { + return { artists: [], albums: [], songs: [] }; +} + +function resultOrEmpty(result: SearchResults | null): SearchResults { + return result ?? emptySearchResults(); +} + +/** + * Live Search race: first backend with hits wins; empty waits for the other. + * `onSettled` fires when both runners finish and includes both payloads for merge. + */ +export async function raceLiveSearch( + localRun: () => Promise, + networkRun: () => Promise, + isStale: () => boolean, + onSettled?: (meta: LiveSearchRaceSettled) => void, +): Promise | null> { + if (isStale()) return null; + + return new Promise((resolve, reject) => { + let settled = false; + let resolvedWinner: SearchRaceSource | null = null; + let localResult: SearchResults | null = null; + let networkResult: SearchResults | null = null; + let localDone = false; + let networkDone = false; + let localMs = 0; + let networkMs = 0; + let raceNotified = false; + const errors: unknown[] = []; + + const notifySettled = () => { + if (raceNotified || !localDone || !networkDone || !onSettled) return; + raceNotified = true; + const local = resultOrEmpty(localResult); + const network = resultOrEmpty(networkResult); + const winner = + resolvedWinner ?? + (searchResultsHaveHits(network) + ? 'network' + : searchResultsHaveHits(local) + ? 'local' + : networkResult + ? 'network' + : 'local'); + onSettled({ + winner, + localMs, + networkMs, + localHits: hitCounts(local), + networkHits: hitCounts(network), + localResult, + networkResult, + }); + }; + + const resolveWinner = (source: SearchRaceSource, result: SearchResults, durationMs: number) => { + settled = true; + resolvedWinner = source; + resolve({ source, result, durationMs }); + }; + + const maybeFinish = () => { + if (settled || isStale()) return; + + if (localDone && localResult && searchResultsHaveHits(localResult)) { + resolveWinner('local', localResult, localMs); + if (networkDone) notifySettled(); + return; + } + if (networkDone && networkResult && searchResultsHaveHits(networkResult)) { + resolveWinner('network', networkResult, networkMs); + if (localDone) notifySettled(); + return; + } + if (!localDone || !networkDone) return; + + settled = true; + if (networkResult && searchResultsHaveHits(networkResult)) { + resolvedWinner = 'network'; + resolve({ source: 'network', result: networkResult, durationMs: networkMs }); + } else if (localResult && searchResultsHaveHits(localResult)) { + resolvedWinner = 'local'; + resolve({ source: 'local', result: localResult, durationMs: localMs }); + } else if (networkResult) { + resolvedWinner = 'network'; + resolve({ source: 'network', result: networkResult, durationMs: networkMs }); + } else if (localResult) { + resolvedWinner = 'local'; + resolve({ source: 'local', result: localResult, durationMs: localMs }); + } else if (errors.length > 0) { + reject(errors[0]); + } else { + resolve(null); + } + notifySettled(); + }; + + const localT0 = performance.now(); + void localRun() + .then(result => { + localMs = Math.round(performance.now() - localT0); + localResult = result; + localDone = true; + maybeFinish(); + notifySettled(); + }) + .catch(err => { + errors.push(err); + localDone = true; + maybeFinish(); + notifySettled(); + }); + + const networkT0 = performance.now(); + void networkRun() + .then(result => { + networkMs = Math.round(performance.now() - networkT0); + networkResult = result; + networkDone = true; + maybeFinish(); + notifySettled(); + }) + .catch(err => { + const name = err instanceof Error ? err.name : ''; + if (name === 'CanceledError' || name === 'AbortError') { + networkDone = true; + maybeFinish(); + notifySettled(); + return; + } + errors.push(err); + networkDone = true; + maybeFinish(); + notifySettled(); + }); + }); +}