mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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
|
## [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.
|
> **🙏 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.
|
||||||
|
|||||||
@@ -58,6 +58,55 @@ fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 {
|
|||||||
need.saturating_mul(20).clamp(256, 10_000)
|
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
|
/// `library_advanced_search` (§5.13). Runs only the queries named in
|
||||||
/// `entityTypes`; absent entities return empty + zero totals.
|
/// `entityTypes`; absent entities return empty + zero totals.
|
||||||
pub fn run_advanced_search(
|
pub fn run_advanced_search(
|
||||||
@@ -194,12 +243,8 @@ fn build_track(
|
|||||||
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
|
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
|
||||||
applied.insert("text".to_string());
|
applied.insert("text".to_string());
|
||||||
let pool = fts_candidate_pool_size(limit, offset);
|
let pool = fts_candidate_pool_size(limit, offset);
|
||||||
let from = format!(
|
let scope = trimmed_nonempty(req.library_scope.as_deref());
|
||||||
"track t INNER JOIN (\
|
let from = scoped_fts_pick_join_sql(pool, scope.as_deref());
|
||||||
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 order = order_clause(&req.sort, EntityKind::Track)
|
let order = order_clause(&req.sort, EntityKind::Track)
|
||||||
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
|
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
|
||||||
return query_rows_fts(
|
return query_rows_fts(
|
||||||
@@ -207,6 +252,7 @@ fn build_track(
|
|||||||
&cols,
|
&cols,
|
||||||
&from,
|
&from,
|
||||||
&q,
|
&q,
|
||||||
|
&scoped_fts_subquery_bind(&req.server_id, scope.as_deref()),
|
||||||
&w,
|
&w,
|
||||||
&order,
|
&order,
|
||||||
limit,
|
limit,
|
||||||
@@ -497,18 +543,24 @@ fn build_album_from_fts(
|
|||||||
applied.insert("text".to_string());
|
applied.insert("text".to_string());
|
||||||
let need = limit.saturating_add(offset) as i64;
|
let need = limit.saturating_add(offset) as i64;
|
||||||
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
|
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
|
||||||
|
let scope = trimmed_nonempty(req.library_scope.as_deref());
|
||||||
|
|
||||||
let mut w = WhereBuilder::new();
|
let mut w = WhereBuilder::new();
|
||||||
w.push_param(
|
w.push_params(
|
||||||
&format!(
|
&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_raw("t.deleted = 0");
|
||||||
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
|
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 != ''");
|
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");
|
let clause = library_scope_equals_sql("t");
|
||||||
w.push_param(&clause, SqlValue::Text(scope));
|
w.push_param(&clause, SqlValue::Text(scope));
|
||||||
}
|
}
|
||||||
@@ -605,18 +657,24 @@ fn build_artist_from_fts(
|
|||||||
applied.insert("text".to_string());
|
applied.insert("text".to_string());
|
||||||
let need = limit.saturating_add(offset) as i64;
|
let need = limit.saturating_add(offset) as i64;
|
||||||
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
|
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
|
||||||
|
let scope = trimmed_nonempty(req.library_scope.as_deref());
|
||||||
|
|
||||||
let mut w = WhereBuilder::new();
|
let mut w = WhereBuilder::new();
|
||||||
w.push_param(
|
w.push_params(
|
||||||
&format!(
|
&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_raw("t.deleted = 0");
|
||||||
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
|
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 != ''");
|
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");
|
let clause = library_scope_equals_sql("t");
|
||||||
w.push_param(&clause, SqlValue::Text(scope));
|
w.push_param(&clause, SqlValue::Text(scope));
|
||||||
}
|
}
|
||||||
@@ -805,6 +863,10 @@ impl WhereBuilder {
|
|||||||
self.clauses.push(sql.to_string());
|
self.clauses.push(sql.to_string());
|
||||||
self.params.push(param);
|
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 {
|
fn where_sql(&self) -> String {
|
||||||
self.clauses.join(" AND ")
|
self.clauses.join(" AND ")
|
||||||
}
|
}
|
||||||
@@ -853,6 +915,7 @@ fn query_rows_fts<T, F>(
|
|||||||
select_cols: &str,
|
select_cols: &str,
|
||||||
from: &str,
|
from: &str,
|
||||||
fts_match: &str,
|
fts_match: &str,
|
||||||
|
fts_subquery_params: &[SqlValue],
|
||||||
w: &WhereBuilder,
|
w: &WhereBuilder,
|
||||||
order_sql: &str,
|
order_sql: &str,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
@@ -866,6 +929,7 @@ where
|
|||||||
let where_sql = w.where_sql();
|
let where_sql = w.where_sql();
|
||||||
store.with_read_conn(|conn| {
|
store.with_read_conn(|conn| {
|
||||||
let mut bind: Vec<SqlValue> = vec![SqlValue::Text(fts_match.to_string())];
|
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());
|
bind.extend(w.params.iter().cloned());
|
||||||
|
|
||||||
let total = count_matching_rows(conn, from, &where_sql, &bind, skip_totals)?;
|
let total = count_matching_rows(conn, from, &where_sql, &bind, skip_totals)?;
|
||||||
|
|||||||
@@ -5,14 +5,16 @@
|
|||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use rusqlite::params;
|
|
||||||
|
|
||||||
use crate::dto::{LibraryAlbumDto, LibraryArtistDto, LibraryLiveSearchResponse, LibraryTrackDto};
|
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;
|
use crate::store::LibraryStore;
|
||||||
|
|
||||||
const SONG_FTS_COLUMNS: [&str; 4] = ["title", "artist", "album", "album_artist"];
|
const TRACK_FTS_BM25_RANK: &str = "bm25(track_fts, 10.0, 3.0, 5.0, 3.0, 0.0)";
|
||||||
const ALBUM_FTS_COLUMNS: [&str; 2] = ["album", "album_artist"];
|
/// FTS row candidates before GROUP BY dedupe — avoids one artist filling the whole cap.
|
||||||
|
const LIVE_SEARCH_FTS_CANDIDATE_CAP: i64 = 150;
|
||||||
|
|
||||||
struct LiveHit {
|
struct LiveHit {
|
||||||
track: LibraryTrackDto,
|
track: LibraryTrackDto,
|
||||||
@@ -39,6 +41,7 @@ pub fn run_live_search(
|
|||||||
|
|
||||||
store.with_read_conn(|conn| {
|
store.with_read_conn(|conn| {
|
||||||
let scope = trimmed_scope(library_scope);
|
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 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 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)?;
|
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(
|
fn collect_fts_rowids(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
match_queries: &[String],
|
match_queries: &[String],
|
||||||
|
server_id: &str,
|
||||||
|
library_scope: Option<&str>,
|
||||||
per_query_limit: i64,
|
per_query_limit: i64,
|
||||||
total_limit: usize,
|
total_limit: usize,
|
||||||
) -> rusqlite::Result<Vec<i64>> {
|
) -> rusqlite::Result<Vec<i64>> {
|
||||||
let sql =
|
let scope = trimmed_scope(library_scope);
|
||||||
"SELECT rowid FROM track_fts WHERE track_fts MATCH ?1 ORDER BY bm25(track_fts) LIMIT ?2";
|
let mut scope_sql = String::new();
|
||||||
let mut stmt = conn.prepare(sql)?;
|
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 seen = HashSet::new();
|
||||||
let mut rowids = Vec::new();
|
let mut rowids = Vec::new();
|
||||||
for mq in match_queries {
|
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 {
|
for rowid in rows {
|
||||||
let rowid = rowid?;
|
let rowid = rowid?;
|
||||||
if seen.insert(rowid) {
|
if seen.insert(rowid) {
|
||||||
@@ -78,15 +105,6 @@ fn collect_fts_rowids(
|
|||||||
Ok(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> {
|
fn trimmed_scope(scope: Option<&str>) -> Option<String> {
|
||||||
scope
|
scope
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
@@ -106,25 +124,25 @@ fn append_library_scope(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn query_songs(
|
fn scoped_exists_sql(library_scope: Option<&str>, extra: &str) -> String {
|
||||||
conn: &rusqlite::Connection,
|
let mut scope_sql = String::new();
|
||||||
query: &str,
|
if library_scope.is_some() {
|
||||||
server_id: &str,
|
scope_sql = format!(" AND {}", library_scope_equals_sql("c"));
|
||||||
library_scope: Option<&str>,
|
}
|
||||||
limit: u32,
|
format!(
|
||||||
) -> rusqlite::Result<Vec<LibraryTrackDto>> {
|
"EXISTS (\
|
||||||
let matches = column_matches(query, &SONG_FTS_COLUMNS).map_err(|e| {
|
SELECT 1 FROM track c \
|
||||||
rusqlite::Error::ToSqlConversionFailure(Box::new(std::io::Error::new(
|
WHERE c.rowid = f.rowid \
|
||||||
std::io::ErrorKind::InvalidInput,
|
AND c.server_id = ? \
|
||||||
e,
|
AND c.deleted = 0{extra}{scope_sql}\
|
||||||
)))
|
)"
|
||||||
})?;
|
)
|
||||||
let per_col = i64::from(limit.max(4));
|
}
|
||||||
let rowids = collect_fts_rowids(conn, &matches, per_col, limit as usize)?;
|
|
||||||
if rowids.is_empty() {
|
fn push_scope_bind(params: &mut Vec<rusqlite::types::Value>, library_scope: Option<&str>) {
|
||||||
return Ok(Vec::new());
|
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(
|
fn query_artists(
|
||||||
@@ -134,76 +152,75 @@ fn query_artists(
|
|||||||
library_scope: Option<&str>,
|
library_scope: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> rusqlite::Result<Vec<LibraryArtistDto>> {
|
) -> 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());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
let fetch = limit.saturating_mul(3).clamp(limit, 24);
|
let exists = scoped_exists_sql(
|
||||||
let rowids = collect_fts_rowids(conn, &[artist_fts], i64::from(fetch), fetch as usize)?;
|
library_scope,
|
||||||
if rowids.is_empty() {
|
" AND c.artist_id IS NOT NULL AND c.artist_id != ''",
|
||||||
return Ok(Vec::new());
|
);
|
||||||
}
|
|
||||||
let placeholders = rowid_placeholders(rowids.len());
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT t.server_id, t.artist_id, t.artist, t.synced_at, t.rowid \
|
"WITH fts_hits AS (\
|
||||||
FROM track t \
|
SELECT f.rowid, {TRACK_FTS_BM25_RANK} AS rank \
|
||||||
WHERE t.rowid IN ({placeholders}) \
|
FROM track_fts f \
|
||||||
AND t.server_id = ? \
|
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.deleted = 0 \
|
||||||
AND t.artist_id IS NOT NULL AND t.artist_id != ''"
|
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 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);
|
append_library_scope(&mut sql, &mut params, library_scope);
|
||||||
let rank: HashMap<i64, usize> = rowids
|
sql.push_str(" GROUP BY t.artist_id ORDER BY best_rank LIMIT ?");
|
||||||
.iter()
|
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
|
||||||
.enumerate()
|
|
||||||
.map(|(i, &rid)| (rid, i))
|
|
||||||
.collect();
|
|
||||||
let mut ranked: Vec<(usize, LibraryArtistDto)> = Vec::new();
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
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 out = Vec::new();
|
||||||
let mut seen = HashSet::new();
|
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||||
for (_, dto) in ranked {
|
Ok(LibraryArtistDto {
|
||||||
if !seen.insert(dto.id.clone()) {
|
server_id: r.get(0)?,
|
||||||
continue;
|
id: r.get(1)?,
|
||||||
}
|
name: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||||
out.push(dto);
|
album_count: None,
|
||||||
if out.len() >= limit as usize {
|
synced_at: r.get(3)?,
|
||||||
break;
|
raw_json: serde_json::Value::Null,
|
||||||
}
|
})
|
||||||
|
})? {
|
||||||
|
out.push(row?);
|
||||||
}
|
}
|
||||||
Ok(out)
|
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(
|
fn query_albums(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
query: &str,
|
query: &str,
|
||||||
@@ -211,101 +228,61 @@ fn query_albums(
|
|||||||
library_scope: Option<&str>,
|
library_scope: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
) -> rusqlite::Result<Vec<LibraryAlbumDto>> {
|
) -> rusqlite::Result<Vec<LibraryAlbumDto>> {
|
||||||
let matches = column_matches(query, &ALBUM_FTS_COLUMNS).map_err(|e| {
|
let Some(album_fts) = fts_album_prefix_any_token_match_query(query) else {
|
||||||
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() {
|
|
||||||
return Ok(Vec::new());
|
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!(
|
let sql = format!(
|
||||||
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
|
"WITH fts_hits AS (\
|
||||||
t.genre, t.cover_art_id, t.starred_at, t.synced_at, t.rowid \
|
SELECT f.rowid, {TRACK_FTS_BM25_RANK} AS rank \
|
||||||
FROM track t \
|
FROM track_fts f \
|
||||||
WHERE t.rowid IN ({placeholders}) \
|
WHERE track_fts MATCH ? \
|
||||||
AND t.server_id = ? \
|
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.deleted = 0 \
|
||||||
AND t.album_id IS NOT NULL AND t.album_id != ''"
|
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 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);
|
append_library_scope(&mut sql, &mut params, library_scope);
|
||||||
let rank: HashMap<i64, usize> = rowids
|
sql.push_str(" GROUP BY t.album_id ORDER BY best_rank LIMIT ?");
|
||||||
.iter()
|
params.push(rusqlite::types::Value::Integer(i64::from(limit)));
|
||||||
.enumerate()
|
|
||||||
.map(|(i, &rid)| (rid, i))
|
|
||||||
.collect();
|
|
||||||
let mut ranked: Vec<(usize, LibraryAlbumDto)> = Vec::new();
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
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 out = Vec::new();
|
||||||
let mut seen = HashSet::new();
|
for row in stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||||
for (_, dto) in ranked {
|
Ok(LibraryAlbumDto {
|
||||||
if !seen.insert(dto.id.clone()) {
|
server_id: r.get(0)?,
|
||||||
continue;
|
id: r.get(1)?,
|
||||||
}
|
name: r.get(2)?,
|
||||||
out.push(dto);
|
artist: r.get(3)?,
|
||||||
if out.len() >= limit as usize {
|
artist_id: r.get(4)?,
|
||||||
break;
|
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)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -608,6 +585,112 @@ mod tests {
|
|||||||
assert_eq!(resp.tracks[0].id, "t1");
|
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`
|
/// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture`
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ pub(crate) fn fts_prefix_token_expr(raw: &str) -> Option<String> {
|
|||||||
fts_token_expr_with(raw, true)
|
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> {
|
fn fts_token_expr_with(raw: &str, prefix: bool) -> Option<String> {
|
||||||
let tokens: Vec<String> = raw
|
let tokens: Vec<String> = raw
|
||||||
.split_whitespace()
|
.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
|
/// Song / track entity: match primary display fields (excludes `genre` to cut
|
||||||
/// noise and FTS fan-out on large libraries).
|
/// noise and FTS fan-out on large libraries).
|
||||||
pub(crate) fn fts_track_match_query(raw: &str) -> Option<String> {
|
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]
|
#[test]
|
||||||
fn fts_prefix_token_expr_ands_multiword_prefixes() {
|
fn fts_prefix_token_expr_ands_multiword_prefixes() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function search(
|
|||||||
artistCount?: number;
|
artistCount?: number;
|
||||||
songCount?: number;
|
songCount?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
timeout?: number;
|
||||||
},
|
},
|
||||||
): Promise<SearchResults> {
|
): Promise<SearchResults> {
|
||||||
if (!query.trim()) return { artists: [], albums: [], songs: [] };
|
if (!query.trim()) return { artists: [], albums: [], songs: [] };
|
||||||
@@ -40,7 +41,7 @@ export async function search(
|
|||||||
songCount: options?.songCount ?? 10,
|
songCount: options?.songCount ?? 10,
|
||||||
...libraryFilterParams(),
|
...libraryFilterParams(),
|
||||||
},
|
},
|
||||||
15000,
|
options?.timeout ?? 15000,
|
||||||
options?.signal,
|
options?.signal,
|
||||||
);
|
);
|
||||||
const r = data.searchResult3 ?? {};
|
const r = data.searchResult3 ?? {};
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import {
|
|||||||
LIVE_SEARCH_DEBOUNCE_RACE_MS,
|
LIVE_SEARCH_DEBOUNCE_RACE_MS,
|
||||||
EMPTY_SEARCH_RESULTS,
|
EMPTY_SEARCH_RESULTS,
|
||||||
liveSearchQueryTooShort,
|
liveSearchQueryTooShort,
|
||||||
|
mergeLiveSearchResults,
|
||||||
runLocalLiveSearch,
|
runLocalLiveSearch,
|
||||||
runNetworkLiveSearch,
|
runNetworkLiveSearch,
|
||||||
} from '../utils/library/liveSearchLocal';
|
} from '../utils/library/liveSearchLocal';
|
||||||
import { raceSearchSources } from '../utils/library/searchRace';
|
import { raceLiveSearch } from '../utils/library/searchRace';
|
||||||
import { libraryIsReady } from '../utils/library/libraryReady';
|
import { libraryIsReady } from '../utils/library/libraryReady';
|
||||||
|
import {
|
||||||
|
emitLiveSearchDebug,
|
||||||
|
searchHitCounts,
|
||||||
|
searchResultSamples,
|
||||||
|
} from '../utils/library/liveSearchDebug';
|
||||||
import {
|
import {
|
||||||
logLibrarySearch,
|
logLibrarySearch,
|
||||||
} from '../utils/library/libraryDevLog';
|
} from '../utils/library/libraryDevLog';
|
||||||
@@ -172,24 +178,57 @@ export default function LiveSearch() {
|
|||||||
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
|
const raceCtx = { epoch: gen, isStale, suppressLog: indexEnabled && !!serverId };
|
||||||
|
|
||||||
if (indexEnabled && serverId) {
|
if (indexEnabled && serverId) {
|
||||||
const winner = await raceSearchSources(
|
const winner = await raceLiveSearch(
|
||||||
[
|
() => runLocalLiveSearch(serverId, q, raceCtx),
|
||||||
{
|
() => runNetworkLiveSearch(q, abort.signal),
|
||||||
source: 'local',
|
|
||||||
run: () => runLocalLiveSearch(serverId, q, raceCtx),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: 'network',
|
|
||||||
run: () => runNetworkLiveSearch(q, abort.signal),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
isStale,
|
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 (isStale()) return;
|
||||||
if (winner) {
|
if (winner) {
|
||||||
setResults(winner.result);
|
setResults(winner.result);
|
||||||
setSearchSource(winner.source);
|
setSearchSource(winner.source);
|
||||||
setOpen(true);
|
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({
|
logLibrarySearch({
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
query: q,
|
query: q,
|
||||||
|
|||||||
@@ -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)',
|
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>): void {
|
||||||
|
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
||||||
|
void invoke('frontend_debug_log', {
|
||||||
|
scope: 'live-search',
|
||||||
|
message: JSON.stringify({ step, details }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { onInvoke } from '@/test/mocks/tauri';
|
import { onInvoke } from '@/test/mocks/tauri';
|
||||||
|
import type { SearchResults } from '../../api/subsonicTypes';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import {
|
import {
|
||||||
liveSearchQueryTooShort,
|
liveSearchQueryTooShort,
|
||||||
|
mergeLiveSearchResults,
|
||||||
runLocalLiveSearch,
|
runLocalLiveSearch,
|
||||||
} from './liveSearchLocal';
|
} from './liveSearchLocal';
|
||||||
|
|
||||||
@@ -96,3 +98,22 @@ describe('liveSearchQueryTooShort', () => {
|
|||||||
expect(liveSearchQueryTooShort('ab')).toBe(false);
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ export const LIVE_SEARCH_DEBOUNCE_NETWORK_MS = 300;
|
|||||||
/** Debounce when local + network run in parallel. */
|
/** Debounce when local + network run in parallel. */
|
||||||
export const LIVE_SEARCH_DEBOUNCE_RACE_MS = 200;
|
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. */
|
/** Local FTS skipped below this length — see `LOCAL_FTS_MIN_QUERY_CHARS` in Rust. */
|
||||||
export const LOCAL_FTS_MIN_QUERY_CHARS = 2;
|
export const LOCAL_FTS_MIN_QUERY_CHARS = 2;
|
||||||
|
|
||||||
@@ -124,10 +133,44 @@ export async function runNetworkLiveSearch(
|
|||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (liveSearchQueryTooShort(q)) return null;
|
if (liveSearchQueryTooShort(q)) return null;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
const name = err instanceof Error ? err.name : '';
|
const name = err instanceof Error ? err.name : '';
|
||||||
if (name === 'CanceledError' || name === 'AbortError') return null;
|
if (name === 'CanceledError' || name === 'AbortError') return null;
|
||||||
throw err;
|
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 = <T extends { id: string }>(
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<SearchResults | null>(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<SearchResults | null>(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<SearchResults | null>(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<void>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* Parallel local vs network search — first successful backend wins.
|
* Parallel local vs network search — first successful backend wins.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { SearchResults } from '../../api/subsonicTypes';
|
||||||
|
|
||||||
export type SearchRaceSource = 'local' | 'network';
|
export type SearchRaceSource = 'local' | 'network';
|
||||||
|
|
||||||
export interface SearchRaceWinner<T> {
|
export interface SearchRaceWinner<T> {
|
||||||
@@ -70,3 +72,161 @@ export async function raceSearchSources<T>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<SearchResults | null>,
|
||||||
|
networkRun: () => Promise<SearchResults | null>,
|
||||||
|
isStale: () => boolean,
|
||||||
|
onSettled?: (meta: LiveSearchRaceSettled) => void,
|
||||||
|
): Promise<SearchRaceWinner<SearchResults> | 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user