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