mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
91e7195e0f
* 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
469 lines
16 KiB
Rust
469 lines
16 KiB
Rust
//! FTS5-backed track search. Skeleton landed in PR-1a — the multi-server +
|
||
//! libraryScope + bm25 ranking shape from spec §5.9 will be filled in by the
|
||
//! sync / search PRs.
|
||
|
||
use rusqlite::params;
|
||
|
||
use crate::store::LibraryStore;
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct TrackHit {
|
||
pub server_id: String,
|
||
pub id: String,
|
||
pub title: String,
|
||
pub artist: Option<String>,
|
||
pub album: String,
|
||
}
|
||
|
||
/// Run a single-server FTS5 match against `track_fts`, returning rows in
|
||
/// bm25 order. `query` is passed straight to FTS5 — callers are expected to
|
||
/// sanitise / quote user input (see §5.13.5: parameterised only).
|
||
pub fn search_tracks(
|
||
store: &LibraryStore,
|
||
server_id: &str,
|
||
query: &str,
|
||
limit: i64,
|
||
) -> Result<Vec<TrackHit>, String> {
|
||
if !fts_query_meets_min_len(query) {
|
||
return Ok(Vec::new());
|
||
}
|
||
let fts = fts_track_match_query(query).ok_or_else(|| "empty query".to_string())?;
|
||
store.with_read_conn(|conn| {
|
||
let mut stmt = conn.prepare(
|
||
r#"
|
||
SELECT t.server_id, t.id, t.title, t.artist, t.album
|
||
FROM track_fts f
|
||
JOIN track t ON t.rowid = f.rowid
|
||
WHERE track_fts MATCH ?1
|
||
AND t.server_id = ?2
|
||
AND t.deleted = 0
|
||
ORDER BY bm25(track_fts)
|
||
LIMIT ?3
|
||
"#,
|
||
)?;
|
||
let rows = stmt
|
||
.query_map(params![fts, server_id, limit], |r| {
|
||
Ok(TrackHit {
|
||
server_id: r.get(0)?,
|
||
id: r.get(1)?,
|
||
title: r.get(2)?,
|
||
artist: r.get(3)?,
|
||
album: r.get(4)?,
|
||
})
|
||
})?
|
||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||
Ok(rows)
|
||
})
|
||
}
|
||
|
||
// ── shared search SQL helpers (Advanced Search §5.13 + cross-server §5.5B) ──
|
||
|
||
/// Hard ceiling on a single search page — keeps the FTS5 p95 budget (§5.9).
|
||
/// Callers clamp their requested `limit` into `1..=PAGE_LIMIT_MAX`.
|
||
pub(crate) const PAGE_LIMIT_MAX: u32 = 500;
|
||
|
||
/// Local FTS is skipped below this length — single-character queries (e.g. Cyrillic
|
||
/// «а», Latin «a») match huge fractions of a large library and bm25+LIMIT can
|
||
/// take tens of seconds (§5.9: no heavy work on every keystroke).
|
||
pub const LOCAL_FTS_MIN_QUERY_CHARS: usize = 2;
|
||
|
||
/// True when `raw` has enough graphemes for a scoped FTS MATCH.
|
||
pub fn fts_query_meets_min_len(raw: &str) -> bool {
|
||
raw.trim().chars().count() >= LOCAL_FTS_MIN_QUERY_CHARS
|
||
}
|
||
|
||
/// Build a safe FTS5 MATCH string: each whitespace token is quoted (and its
|
||
/// internal `"` doubled) so arbitrary user input can't trip FTS5 query
|
||
/// syntax. Tokens are implicitly AND-ed. `None` when the input has no tokens.
|
||
pub(crate) fn fts_query(raw: &str) -> Option<String> {
|
||
let tokens = fts_token_expr(raw)?;
|
||
Some(tokens)
|
||
}
|
||
|
||
/// Token expression only (`"a" "b"`), shared by column-scoped builders.
|
||
pub(crate) fn fts_token_expr(raw: &str) -> Option<String> {
|
||
fts_token_expr_with(raw, false)
|
||
}
|
||
|
||
/// Prefix token expression (`"a"* "b"*`) for Live Search as-you-type matching.
|
||
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()
|
||
.map(|t| {
|
||
let quoted = format!("\"{}\"", t.replace('"', "\"\""));
|
||
if prefix {
|
||
format!("{quoted}*")
|
||
} else {
|
||
quoted
|
||
}
|
||
})
|
||
.collect();
|
||
if tokens.is_empty() {
|
||
None
|
||
} else {
|
||
Some(tokens.join(" "))
|
||
}
|
||
}
|
||
|
||
/// Column-scoped prefix match (`artist : "met"*` → Metallica).
|
||
pub(crate) fn fts_column_prefix_query(column: &str, raw: &str) -> Option<String> {
|
||
fts_prefix_token_expr(raw).map(|tokens| format!("{column} : {tokens}"))
|
||
}
|
||
|
||
/// Prefix variants for Live Search / Advanced Search as-you-type matching.
|
||
pub(crate) fn fts_track_prefix_match_query(raw: &str) -> Option<String> {
|
||
fts_prefix_token_expr(raw).map(|tokens| {
|
||
["title", "artist", "album", "album_artist"]
|
||
.iter()
|
||
.map(|col| format!("{col} : {tokens}"))
|
||
.collect::<Vec<_>>()
|
||
.join(" OR ")
|
||
})
|
||
}
|
||
|
||
pub(crate) fn fts_album_prefix_match_query(raw: &str) -> Option<String> {
|
||
fts_prefix_token_expr(raw).map(|tokens| {
|
||
format!("(album : {tokens} OR album_artist : {tokens})")
|
||
})
|
||
}
|
||
|
||
/// Live Search album match — any query word may hit album or album_artist (Navidrome parity).
|
||
pub(crate) fn fts_album_prefix_any_token_match_query(raw: &str) -> Option<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> {
|
||
fts_token_expr(raw).map(|tokens| {
|
||
["title", "artist", "album", "album_artist"]
|
||
.iter()
|
||
.map(|col| format!("{col} : {tokens}"))
|
||
.collect::<Vec<_>>()
|
||
.join(" OR ")
|
||
})
|
||
}
|
||
|
||
/// Project the `track` hot columns prefixed with `alias` (e.g. `t.title`),
|
||
/// in `repos::row_to_track_row`'s positional order so the Advanced Search /
|
||
/// cross-server builders can reuse the shared row mapper.
|
||
/// Effective library id for scoped search — hot column first, then common
|
||
/// OpenSubsonic / Navidrome keys in `raw_json` (legacy rows may only have JSON).
|
||
pub(crate) fn library_scope_match_sql(table_alias: &str) -> String {
|
||
format!(
|
||
"COALESCE(NULLIF({table_alias}.library_id, ''), \
|
||
CAST(json_extract({table_alias}.raw_json, '$.libraryId') AS TEXT), \
|
||
CAST(json_extract({table_alias}.raw_json, '$.library_id') AS TEXT), \
|
||
CAST(json_extract({table_alias}.raw_json, '$.musicFolderId') AS TEXT))"
|
||
)
|
||
}
|
||
|
||
pub(crate) fn library_scope_equals_sql(table_alias: &str) -> String {
|
||
format!("{} = ?", library_scope_match_sql(table_alias))
|
||
}
|
||
|
||
pub(crate) fn aliased_track_columns(alias: &str) -> String {
|
||
crate::repos::track_columns()
|
||
.split(',')
|
||
.map(|c| format!("{alias}.{}", c.trim()))
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
}
|
||
|
||
/// Same projection as [`aliased_track_columns`], but `bpm` uses analysis fact +
|
||
/// tag dual-storage resolution (§5.13.4) and appends `bpm_source` for UI tooltips.
|
||
pub(crate) fn aliased_track_columns_resolved_bpm(alias: &str) -> String {
|
||
let base = aliased_track_columns_with_resolved_bpm_expr(alias);
|
||
format!("{base}, ({}) AS bpm_source", bpm_source_expr(alias))
|
||
}
|
||
|
||
fn aliased_track_columns_with_resolved_bpm_expr(alias: &str) -> String {
|
||
let bpm_expr = bpm_resolved_expr(alias);
|
||
crate::repos::track_columns()
|
||
.split(',')
|
||
.map(|c| {
|
||
let col = c.trim();
|
||
if col == "bpm" {
|
||
format!("({bpm_expr}) AS bpm")
|
||
} else {
|
||
format!("{alias}.{col}")
|
||
}
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
}
|
||
|
||
/// Oximedia / analysis `track_fact(bpm)` — preferred over hot `track.bpm` tag.
|
||
fn bpm_analysis_fact_subquery(table_alias: &str) -> String {
|
||
format!(
|
||
"(SELECT f.value_int FROM track_fact f \
|
||
WHERE f.server_id = {table_alias}.server_id AND f.track_id = {table_alias}.id \
|
||
AND f.fact_kind = 'bpm' AND f.source_kind = 'analysis' \
|
||
AND f.value_int IS NOT NULL AND f.value_int > 0 \
|
||
ORDER BY f.confidence DESC LIMIT 1)"
|
||
)
|
||
}
|
||
|
||
pub(crate) fn bpm_resolved_expr(table_alias: &str) -> String {
|
||
let analysis = bpm_analysis_fact_subquery(table_alias);
|
||
let tag = format!(
|
||
"CASE WHEN {table_alias}.bpm IS NOT NULL AND {table_alias}.bpm > 0 \
|
||
THEN {table_alias}.bpm END"
|
||
);
|
||
let other_fact = format!(
|
||
"(SELECT f.value_int FROM track_fact f \
|
||
WHERE f.server_id = {table_alias}.server_id AND f.track_id = {table_alias}.id \
|
||
AND f.fact_kind = 'bpm' AND f.source_kind NOT IN ('analysis') \
|
||
AND f.value_int IS NOT NULL AND f.value_int > 0 \
|
||
ORDER BY CASE f.source_kind WHEN 'user' THEN 0 WHEN 'server_tag' THEN 1 ELSE 2 END LIMIT 1)"
|
||
);
|
||
format!("COALESCE({analysis}, {tag}, {other_fact})")
|
||
}
|
||
|
||
/// `'analysis'` when measured fact wins; `'tag'` when hot `track.bpm` is shown.
|
||
pub(crate) fn bpm_source_expr(table_alias: &str) -> String {
|
||
let analysis = bpm_analysis_fact_subquery(table_alias);
|
||
format!(
|
||
"CASE \
|
||
WHEN {analysis} IS NOT NULL THEN 'analysis' \
|
||
WHEN {table_alias}.bpm IS NOT NULL AND {table_alias}.bpm > 0 THEN 'tag' \
|
||
ELSE NULL END"
|
||
)
|
||
}
|
||
|
||
pub(crate) fn track_projection_column_count() -> usize {
|
||
crate::repos::track_columns().split(',').count()
|
||
}
|
||
|
||
/// Map a BPM-resolved Advanced Search row (extra trailing `bpm_source` column).
|
||
pub(crate) fn row_to_track_dto_resolved_bpm(
|
||
row: &rusqlite::Row<'_>,
|
||
) -> rusqlite::Result<crate::dto::LibraryTrackDto> {
|
||
let mut dto = crate::dto::LibraryTrackDto::from_row(&crate::repos::row_to_track_row(row)?);
|
||
dto.bpm_source = row.get(track_projection_column_count()).ok();
|
||
Ok(dto)
|
||
}
|
||
|
||
/// Build a `%…%` LIKE pattern with the LIKE wildcards (`%`, `_`) and the
|
||
/// `\` escape char escaped, for use with `LIKE ? ESCAPE '\'`. Shared by the
|
||
/// Advanced Search album/artist name match and the cross-server fuzzy
|
||
/// title fallback (§5.9).
|
||
pub(crate) fn like_contains(raw: &str) -> String {
|
||
let escaped = raw
|
||
.replace('\\', "\\\\")
|
||
.replace('%', "\\%")
|
||
.replace('_', "\\_");
|
||
format!("%{escaped}%")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::repos::{TrackRepository, TrackRow};
|
||
|
||
fn row(server: &str, id: &str, title: &str, artist: &str, album: &str) -> TrackRow {
|
||
TrackRow {
|
||
server_id: server.into(),
|
||
id: id.into(),
|
||
title: title.into(),
|
||
title_sort: None,
|
||
artist: Some(artist.into()),
|
||
artist_id: None,
|
||
album: album.into(),
|
||
album_id: None,
|
||
album_artist: Some(artist.into()),
|
||
duration_sec: 200,
|
||
track_number: None,
|
||
disc_number: None,
|
||
year: None,
|
||
genre: None,
|
||
suffix: None,
|
||
bit_rate: None,
|
||
size_bytes: None,
|
||
cover_art_id: None,
|
||
starred_at: None,
|
||
user_rating: None,
|
||
play_count: None,
|
||
played_at: None,
|
||
server_path: None,
|
||
library_id: None,
|
||
isrc: None,
|
||
mbid_recording: None,
|
||
bpm: None,
|
||
replay_gain_track_db: None,
|
||
replay_gain_album_db: None,
|
||
content_hash: None,
|
||
server_updated_at: None,
|
||
server_created_at: None,
|
||
deleted: false,
|
||
synced_at: 1,
|
||
raw_json: "{}".into(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn match_finds_track_by_title() {
|
||
let store = LibraryStore::open_in_memory();
|
||
TrackRepository::new(&store)
|
||
.upsert_batch(&[
|
||
row("s1", "t1", "Aurora", "Anna", "Skylines"),
|
||
row("s1", "t2", "Sunset", "Beth", "Skylines"),
|
||
])
|
||
.unwrap();
|
||
let hits = search_tracks(&store, "s1", "aurora", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].id, "t1");
|
||
}
|
||
|
||
#[test]
|
||
fn match_filters_by_server_id() {
|
||
let store = LibraryStore::open_in_memory();
|
||
TrackRepository::new(&store)
|
||
.upsert_batch(&[
|
||
row("s1", "t1", "Aurora", "Anna", "Skylines"),
|
||
row("s2", "t1", "Aurora", "Anna", "Skylines"),
|
||
])
|
||
.unwrap();
|
||
let hits = search_tracks(&store, "s2", "aurora", 10).unwrap();
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].server_id, "s2");
|
||
}
|
||
|
||
#[test]
|
||
fn match_skips_deleted_rows() {
|
||
let store = LibraryStore::open_in_memory();
|
||
let repo = TrackRepository::new(&store);
|
||
repo.upsert_batch(&[row("s1", "t1", "Aurora", "Anna", "Skylines")])
|
||
.unwrap();
|
||
let mut gone = row("s1", "t1", "Aurora", "Anna", "Skylines");
|
||
gone.deleted = true;
|
||
repo.upsert_batch(&[gone]).unwrap();
|
||
let hits = search_tracks(&store, "s1", "aurora", 10).unwrap();
|
||
assert!(hits.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn fts_column_prefix_query_scopes_to_one_column() {
|
||
assert_eq!(
|
||
fts_column_prefix_query("artist", "metal").as_deref(),
|
||
Some("artist : \"metal\"*")
|
||
);
|
||
}
|
||
|
||
#[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!(
|
||
fts_prefix_token_expr("arch enemy").as_deref(),
|
||
Some("\"arch\"* \"enemy\"*")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fts_track_prefix_match_query_or_across_display_columns() {
|
||
let q = fts_track_prefix_match_query("metal").unwrap();
|
||
assert!(q.contains("title : \"metal\"*"));
|
||
assert!(q.contains("artist : \"metal\"*"));
|
||
assert!(!q.contains("genre"));
|
||
}
|
||
|
||
#[test]
|
||
fn fts_album_prefix_match_query_includes_album_artist() {
|
||
assert_eq!(
|
||
fts_album_prefix_match_query("metal").as_deref(),
|
||
Some("(album : \"metal\"* OR album_artist : \"metal\"*)")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fts_track_match_query_or_across_display_columns() {
|
||
let q = fts_track_match_query("manowar").unwrap();
|
||
assert!(q.contains("title : \"manowar\""));
|
||
assert!(q.contains("artist : \"manowar\""));
|
||
assert!(!q.contains("genre"));
|
||
}
|
||
|
||
#[test]
|
||
fn fts_query_meets_min_len_requires_two_graphemes() {
|
||
assert!(!fts_query_meets_min_len("a"));
|
||
assert!(!fts_query_meets_min_len("а"));
|
||
assert!(fts_query_meets_min_len("ab"));
|
||
assert!(fts_query_meets_min_len("ма"));
|
||
}
|
||
|
||
#[test]
|
||
fn fts_query_quotes_tokens_and_doubles_inner_quotes() {
|
||
assert_eq!(fts_query("hello world").as_deref(), Some("\"hello\" \"world\""));
|
||
assert_eq!(fts_query("a\"b").as_deref(), Some("\"a\"\"b\""));
|
||
}
|
||
|
||
#[test]
|
||
fn fts_query_is_none_for_blank_input() {
|
||
assert!(fts_query("").is_none());
|
||
assert!(fts_query(" ").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn aliased_track_columns_prefixes_every_column() {
|
||
let cols = aliased_track_columns("t");
|
||
assert!(cols.starts_with("t.server_id, t.id, t.title"));
|
||
assert!(cols.ends_with("t.raw_json"));
|
||
// One alias per column — count matches the shared column list.
|
||
assert_eq!(cols.matches("t.").count(), crate::repos::track_columns().split(',').count());
|
||
}
|
||
}
|