fix(library): scoped live search FTS, race, and multi-server advanced search (#868)

* fix(library): scoped live search FTS, race, and multi-server advanced search

Scope track_fts live search to active server_id so multi-server libraries
no longer show empty or wrong-server hits. Match Navidrome-style any-word
prefix matching and GROUP BY artist/album dedupe on track_fts only.

Frontend: parallel local vs search3 race (empty waits, 8s network timeout),
merge supplemental hits after both settle, debug via Settings → Logging →
Debug (frontend_debug_log). Advanced Search FTS subqueries use the same
server scope fix.

* docs: CHANGELOG and credits for PR #868 live search fix
This commit is contained in:
cucadmuh
2026-05-25 04:20:02 +03:00
committed by GitHub
parent bc85065316
commit 91e7195e0f
13 changed files with 839 additions and 207 deletions
@@ -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<SqlValue> {
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<SqlValue>) {
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<T, F>(
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<SqlValue> = 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)?;
@@ -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<Vec<i64>> {
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<rusqlite::types::Value> = 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<Vec<String>, 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<String> {
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<Vec<LibraryTrackDto>> {
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<rusqlite::types::Value>, 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<Vec<LibraryArtistDto>> {
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<rusqlite::types::Value> = 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<rusqlite::types::Value> = 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<i64, usize> = 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<String>>(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<String>>(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<Vec<LibraryTrackDto>> {
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<Vec<LibraryAlbumDto>> {
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<rusqlite::types::Value> = 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<rusqlite::types::Value> = 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<i64, usize> = 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<String>>(3)?,
r.get::<_, Option<String>>(4)?,
r.get::<_, Option<i64>>(5)?,
r.get::<_, Option<String>>(6)?,
r.get::<_, Option<String>>(7)?,
r.get::<_, Option<i64>>(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::<Vec<_>>()
);
}
#[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]
@@ -90,6 +90,21 @@ pub(crate) fn fts_prefix_token_expr(raw: &str) -> Option<String> {
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<String> {
let tokens: Vec<String> = 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<String> {
let tokens: Vec<String> = raw
.split_whitespace()
@@ -131,6 +146,31 @@ pub(crate) fn fts_album_prefix_match_query(raw: &str) -> Option<String> {
})
}
/// 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<String> {
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<String> {
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<String> {
fts_prefix_token_or_expr(raw).map(|tokens| {
["title", "artist", "album", "album_artist"]
.iter()
.map(|col| format!("{col} : ({tokens})"))
.collect::<Vec<_>>()
.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<String> {
@@ -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!(