Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-library/src/advanced_search.rs
T
cucadmuh 4ac373a65b feat(search): scoped live search on browse pages (#938)
* feat(artists): scoped live search badge replaces page filter

Move Artists browse text search into the header Live Search with a page
scope badge (Users icon), field-local undo, and double-click/backspace
to clear scope. Block the live-search dropdown while scoped so results
only filter the Artists grid; mobile overlay follows the same rules.

* fix(artists): plain grid for scoped search fixes broken card layout

Route the browse grid through VirtualCardGrid, switch to non-virtual CSS
grid when live search filters the catalog, reset scroll on filter changes,
and skip content-visibility on plain tiles to avoid blank/black cards.

* fix(search): scope badge double-Backspace and single clear control

Require two Backspaces on an empty scoped field after prior text input;
one Backspace still clears the badge when the field was never filled.
Move live-search clear/advanced controls inside the field pill, drop the
extra outer clear button, and use type=text to avoid native search clears.

* fix(search): drop duplicate outer live-search clear button

Keep the native in-field clear on type=search and the original pill layout;
remove only the extra × control outside the search border. Reset dropdown
state when the query is cleared via the native control.

* refactor(search): generic scoped browse query helper, drop dead code

Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an
expectedScope argument; wire Artists via useScopedBrowseSearchQuery.
Remove unused liveSearchScoped dropdown helper (scoped mode blocks it).

* feat(search): ghost scope badge and single-click badge remove

After clearing the artists scope on /artists, show a faded ghost chip to
restore page-only search while keeping the global search placeholder.
Active badge removes on one click; tooltips and styles updated.

* feat(search): scoped live search for All Albums and New Releases

Wire albums and newReleases scope badges with debounced album title search
(local index title-only FTS + filtered search3). Plain grid, scroll reset,
and session query restore on album grid browse pages.

* fix(browse): preserve scroll restore after album/artist detail back

Only reset in-page scroll when filter resetKey changes, not when
isScrollRestorePending clears after session restore.

* feat(search): scoped live search for Tracks browse

Wire /tracks to header live search with wide title/artist/album FTS,
hide hero and discovery rails while search is active, and remove the
inline search field from the browse list.

* fix(search): clear header query when leaving scoped browse pages

Prevent global live search from firing on album/detail routes after a
scoped browse query; browse session stashes still restore on back.

* fix(tracks): restore scroll after back from detail during scoped search

Hold stashed song results across fetchSongPage churn, defer leave-stash
teardown past AppShell scroll reset, restore tracks scroll after the list
is ready, and save scroll snapshot when opening artist from song context menu.

* fix(tracks): hide discovery headings during scoped search

Hide the page subtitle and "Browse all tracks" section title when
tracks search is active, matching hero/rails chrome behavior.

* feat(search): scoped live search for Composers browse

Wire /composers to header live search with composers scope badge,
session stash, scroll restore, and plain grid/list during text filter.
Remove the in-page filter input; add i18n and navigation helpers.

* docs: CHANGELOG and credits for scoped browse live search (PR #938)
2026-06-01 13:04:36 +03:00

2187 lines
80 KiB
Rust

//! Advanced Search SQL builder (spec §5.13). PR-5d ships the backend only —
//! the `SearchBrowsePage.tsx` UI wiring stays PR-7 (F2). Cross-server search
//! (§5.5B) lives in the sibling `cross_server` module.
//!
//! The builder turns a `LibraryAdvancedSearchRequest` into one parameterised
//! query per requested entity (track / album / artist), each sharing a WHERE
//! built from the `FilterFieldRegistry` resolution in `filter.rs`. Only
//! builder-supplied column expressions ever reach the SQL string; every value
//! is bound (§5.13.5: parameterised only).
use std::collections::{BTreeSet, HashSet};
use rusqlite::types::Value as SqlValue;
use rusqlite::{params, OptionalExtension};
use serde_json::Value;
use crate::dto::{
LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryAlbumDto, LibraryArtistDto,
LibraryFilterClause, LibrarySearchTotals, LibrarySortClause, LibraryTrackDto, SortDir,
};
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_equals_sql, like_contains, PAGE_LIMIT_MAX,
};
use crate::store::LibraryStore;
/// `bpm` dual-storage resolution (§5.13.4): prefer analysis `track_fact(bpm)`,
/// then hot `track.bpm` tag, then other fact sources.
fn bpm_resolved_sql() -> String {
bpm_resolved_expr("t")
}
const ALBUM_COLUMNS: &str = "a.server_id, a.id, a.name, a.artist, a.artist_id, \
a.song_count, a.duration_sec, \
COALESCE(a.year, (SELECT MAX(t.year) FROM track t \
WHERE t.server_id = a.server_id AND t.album_id = a.id AND t.deleted = 0)), \
a.genre, a.cover_art_id, a.starred_at, a.synced_at, a.raw_json";
const ARTIST_COLUMNS: &str = "ar.server_id, ar.id, ar.name, ar.album_count, \
ar.synced_at, ar.raw_json";
/// Flat track projection used when browsing albums in advanced search.
type AlbumBrowseTrackRow = (
String,
String,
String,
Option<String>,
Option<String>,
Option<i64>,
Option<String>,
Option<String>,
Option<i64>,
i64,
);
fn fts_candidate_pool_size(limit: u32, offset: u32) -> i64 {
let need = limit.saturating_add(offset) as i64;
need.saturating_mul(20).clamp(256, 10_000)
}
/// FTS rowid pick scoped to the active server (and optional library folder).
fn scoped_fts_rowid_subquery_sql(pool: i64, library_scope: Option<&str>) -> String {
let alias = "t_fts";
let mut sql = format!(
"SELECT f.rowid FROM track_fts f \
JOIN track {alias} ON {alias}.rowid = f.rowid \
WHERE track_fts MATCH ? \
AND {alias}.server_id = ? \
AND {alias}.deleted = 0"
);
if library_scope.is_some() {
sql.push_str(" AND ");
sql.push_str(&library_scope_equals_sql(alias));
}
sql.push_str(&format!(" ORDER BY bm25(track_fts) LIMIT {pool}"));
sql
}
fn scoped_fts_pick_join_sql(pool: i64, library_scope: Option<&str>) -> String {
let alias = "t_fts";
let mut scope_sql = String::new();
if library_scope.is_some() {
scope_sql = format!(" AND {}", library_scope_equals_sql(alias));
}
format!(
"track t INNER JOIN (\
SELECT f.rowid, bm25(track_fts) AS fts_rank \
FROM track_fts f \
JOIN track {alias} ON {alias}.rowid = f.rowid \
WHERE track_fts MATCH ? \
AND {alias}.server_id = ? \
AND {alias}.deleted = 0{scope_sql} \
ORDER BY fts_rank \
LIMIT {pool}\
) fts_pick ON t.rowid = fts_pick.rowid"
)
}
fn scoped_fts_subquery_bind(
server_id: &str,
library_scope: Option<&str>,
) -> Vec<SqlValue> {
let mut params = vec![SqlValue::Text(server_id.to_string())];
if let Some(scope) = library_scope.filter(|s| !s.trim().is_empty()) {
params.push(SqlValue::Text(scope.to_string()));
}
params
}
/// `library_advanced_search` (§5.13). Runs only the queries named in
/// `entityTypes`; absent entities return empty + zero totals.
pub fn run_advanced_search(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
) -> Result<LibraryAdvancedSearchResponse, String> {
// `query` shorthand → text input; a `text` filter clause is an alias for
// the same thing. Everything else is a scalar filter.
let mut text_input: Option<String> = trimmed_nonempty(req.query.as_deref());
let mut scalar: Vec<&LibraryFilterClause> = Vec::new();
for c in &req.filters {
if c.field == "text" {
if text_input.is_none() {
if let Some(Value::String(s)) = &c.value {
text_input = trimmed_nonempty(Some(s));
}
}
} else {
scalar.push(c);
}
}
// Up-front validation: an unknown field or an op the registry doesn't
// declare is an error regardless of entity routing (§5.13.5).
for c in &scalar {
let field = filter::lookup(&c.field)
.ok_or_else(|| filter::FilterError::UnknownField(c.field.clone()).to_string())?;
if !field.ops.contains(&c.op) {
return Err(filter::FilterError::UnsupportedOp {
field: c.field.clone(),
op: c.op.as_str(),
}
.to_string());
}
}
if text_input
.as_deref()
.is_some_and(|t| !fts_query_meets_min_len(t))
{
return Ok(LibraryAdvancedSearchResponse {
artists: Vec::new(),
albums: Vec::new(),
tracks: Vec::new(),
totals: LibrarySearchTotals {
artists: 0,
albums: 0,
tracks: 0,
},
applied_filters: Vec::new(),
source: "local".to_string(),
});
}
let limit = req.limit.clamp(1, PAGE_LIMIT_MAX);
let offset = req.offset;
let skip_totals = req.skip_totals;
let text = text_input.as_deref();
let want = |k: EntityKind| req.entity_types.contains(&k);
let mut applied: BTreeSet<String> = BTreeSet::new();
let (artists, artists_total) = if want(EntityKind::Artist) {
build_artist(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)?
} else {
(Vec::new(), 0)
};
let (albums, albums_total) = if want(EntityKind::Album) {
build_album(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)?
} else {
(Vec::new(), 0)
};
let (tracks, tracks_total) = if want(EntityKind::Track) {
build_track(store, req, text, &scalar, limit, offset, skip_totals, &mut applied)?
} else {
(Vec::new(), 0)
};
Ok(LibraryAdvancedSearchResponse {
artists,
albums,
tracks,
totals: LibrarySearchTotals {
artists: artists_total,
albums: albums_total,
tracks: tracks_total,
},
applied_filters: applied.into_iter().collect(),
source: "local".to_string(),
})
}
// ── per-entity builders ────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
fn build_track(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: Option<&str>,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryTrackDto>, u32), String> {
let mut w = WhereBuilder::new();
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
w.push(frag);
}
}
if req.starred_only == Some(true) {
w.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
let bpm_resolved = scalar.iter().any(|c| c.field == "bpm");
let cols = if bpm_resolved {
aliased_track_columns_resolved_bpm("t")
} else {
aliased_track_columns("t")
};
let map_track = if bpm_resolved {
map_track_row_resolved_bpm
} else {
map_track_row_default
};
if let Some(q) = text.and_then(fts_track_prefix_match_query) {
applied.insert("text".to_string());
let pool = fts_candidate_pool_size(limit, offset);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let from = scoped_fts_pick_join_sql(pool, scope.as_deref());
let order = order_clause(&req.sort, EntityKind::Track)
.unwrap_or_else(|| "ORDER BY fts_pick.fts_rank".to_string());
return query_rows_fts(
store,
&cols,
&from,
&q,
&scoped_fts_subquery_bind(&req.server_id, scope.as_deref()),
&w,
&order,
limit,
offset,
skip_totals,
map_track,
);
}
let order = order_clause(&req.sort, EntityKind::Track)
.unwrap_or_else(|| "ORDER BY t.title COLLATE NOCASE ASC, t.id ASC".to_string());
query_rows(
store,
&cols,
"track t",
&w,
&order,
limit,
offset,
skip_totals,
map_track,
)
}
fn map_track_row_default(row: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryTrackDto> {
repos::row_to_track_row(row).map(|r| LibraryTrackDto::from_row(&r))
}
fn map_track_row_resolved_bpm(row: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryTrackDto> {
crate::search::row_to_track_dto_resolved_bpm(row)
}
/// Sync is track-first; the `album` table is often empty or holds only
/// patch-on-use stubs. Normal browse must not treat a handful of album rows
/// as the full catalog.
fn server_has_indexed_tracks(store: &LibraryStore, server_id: &str) -> Result<bool, String> {
store
.with_read_conn(|conn| {
conn.query_row(
"SELECT 1 FROM track WHERE server_id = ?1 AND deleted = 0 LIMIT 1",
params![server_id],
|_| Ok(()),
)
.optional()
.map(|r| r.is_some())
})
.map_err(|e| e.to_string())
}
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)
} else {
fts_album_prefix_match_query(text)
}
}
#[allow(clippy::too_many_arguments)]
fn build_album(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: Option<&str>,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
if scalar_requires_lossless_track_grouping(scalar) {
return build_album_from_tracks(
store, req, text, scalar, limit, offset, skip_totals, applied, true,
);
}
// Album browse favorites: album-level stars only (`a.starred_at`), not
// track-derived groups with `t.starred_at`.
if req.starred_only == Some(true) {
return build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied);
}
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,
);
}
if !scalar_requires_track_derived_entities(scalar) {
let table = build_album_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(table);
}
}
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);
}
build_album_from_tracks(
store, req, text, scalar, limit, offset, skip_totals, applied, false,
)
}
#[allow(clippy::too_many_arguments)]
fn build_album_from_table(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: Option<&str>,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
// `album` has no `library_id` / `deleted` columns, so `libraryScope` is
// a track-only filter (P20) and does not narrow album-table results.
let mut w = WhereBuilder::new();
w.push_param("a.server_id = ?", SqlValue::Text(req.server_id.clone()));
if let Some(t) = text {
w.push_param("a.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Album)? {
applied.insert(c.field.clone());
w.push(frag);
}
}
if req.starred_only == Some(true) {
w.push_raw("a.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
push_album_id_allowlist(
&mut w,
"a.id",
req.restrict_album_ids.as_deref(),
applied,
);
let order = order_clause(&req.sort, EntityKind::Album)
.unwrap_or_else(|| "ORDER BY a.name COLLATE NOCASE ASC, a.id ASC".to_string());
query_rows(
store,
ALBUM_COLUMNS,
"album a",
&w,
&order,
limit,
offset,
skip_totals,
map_album,
)
}
/// Album rows derived from synced tracks when the dedicated `album` table
/// has no matching rows (N1 / S1 ingest only writes tracks today).
#[allow(clippy::too_many_arguments)]
fn build_album_from_tracks(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: Option<&str>,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
include_album_table_rows: bool,
) -> Result<(Vec<LibraryAlbumDto>, u32), String> {
let mut w = WhereBuilder::new();
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
if !include_album_table_rows {
// Skip track groups only when the album table has a full row (synced
// metadata). Patch-on-use stubs omit `song_count` and must not hide the
// track-derived catalog entry.
w.push_raw(
"NOT EXISTS (SELECT 1 FROM album a WHERE a.server_id = t.server_id \
AND a.id = t.album_id AND a.song_count IS NOT NULL)",
);
}
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
if let Some(t) = text {
w.push_param("t.album LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
w.push(frag);
}
}
if req.starred_only == Some(true) {
w.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
push_album_id_allowlist(
&mut w,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let select = "t.server_id, t.album_id, MAX(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(|| {
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
});
query_grouped_rows(
store,
select,
"track t",
&w,
"GROUP BY t.album_id",
&order,
limit,
offset,
skip_totals,
map_album_from_tracks,
)
}
#[allow(clippy::too_many_arguments)]
fn build_artist(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: Option<&str>,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
if !scalar_requires_track_derived_entities(scalar) {
let table = build_artist_from_table(store, req, text, scalar, limit, offset, skip_totals, applied)?;
if !table.0.is_empty() || table.1 > 0 {
return Ok(table);
}
}
if let Some(q) = text.and_then(|t| fts_column_prefix_query("artist", t)) {
return build_artist_from_fts(store, req, &q, scalar, limit, offset, skip_totals, applied);
}
build_artist_from_tracks(store, req, text, scalar, limit, offset, skip_totals, applied)
}
#[allow(clippy::too_many_arguments)]
fn build_artist_from_table(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: Option<&str>,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
let mut w = WhereBuilder::new();
w.push_param("ar.server_id = ?", SqlValue::Text(req.server_id.clone()));
if let Some(t) = text {
w.push_param("ar.name LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Artist)? {
applied.insert(c.field.clone());
w.push(frag);
}
}
let order = order_clause(&req.sort, EntityKind::Artist)
.unwrap_or_else(|| "ORDER BY ar.name COLLATE NOCASE ASC, ar.id ASC".to_string());
query_rows(
store,
ARTIST_COLUMNS,
"artist ar",
&w,
&order,
limit,
offset,
skip_totals,
map_artist,
)
}
#[allow(clippy::too_many_arguments)]
fn build_artist_from_tracks(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
text: Option<&str>,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryArtistDto>, u32), String> {
let mut w = WhereBuilder::new();
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''");
w.push_raw(
"NOT EXISTS (SELECT 1 FROM artist ar WHERE ar.server_id = t.server_id AND ar.id = t.artist_id)",
);
if let Some(scope) = trimmed_nonempty(req.library_scope.as_deref()) {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
if let Some(t) = text {
w.push_param("t.artist LIKE ? ESCAPE '\\'", SqlValue::Text(like_contains(t)));
applied.insert("text".to_string());
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
w.push(frag);
}
}
let select = "t.server_id, t.artist_id, MAX(t.artist), COUNT(DISTINCT t.album_id), MAX(t.synced_at)";
let order = order_clause(&req.sort, EntityKind::Artist).unwrap_or_else(|| {
"ORDER BY MAX(t.artist) COLLATE NOCASE ASC, t.artist_id ASC".to_string()
});
query_grouped_rows(
store,
select,
"track t",
&w,
"GROUP BY t.artist_id",
&order,
limit,
offset,
skip_totals,
map_artist_from_tracks,
)
}
/// Text search for albums when the `album` table is empty — one FTS pass +
/// in-memory dedupe by `album_id` (same strategy as live search / §5.9).
#[allow(clippy::too_many_arguments)]
fn build_album_from_fts(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
fts: &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 need = limit.saturating_add(offset) as i64;
let pool = (need.saturating_mul(8)).clamp(64, 2_000);
let scope = trimmed_nonempty(req.library_scope.as_deref());
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.album_id IS NOT NULL AND t.album_id != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
w.push(frag);
}
}
if req.starred_only == Some(true) {
w.push_raw("t.starred_at IS NOT NULL");
applied.insert("starred".to_string());
}
push_album_id_allowlist(
&mut w,
"t.album_id",
req.restrict_album_ids.as_deref(),
applied,
);
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.album_id, t.album, t.artist, t.artist_id, t.year, \
t.genre, t.cover_art_id, t.starred_at, t.synced_at \
FROM track t \
WHERE {where_sql}"
);
let params = w.params.clone();
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)?,
))
})?
.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
};
let page = deduped
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, total))
})
}
/// Text search for artists when the `artist` table is empty — FTS + dedupe.
#[allow(clippy::too_many_arguments)]
fn build_artist_from_fts(
store: &LibraryStore,
req: &LibraryAdvancedSearchRequest,
fts: &str,
scalar: &[&LibraryFilterClause],
limit: u32,
offset: u32,
skip_totals: bool,
applied: &mut BTreeSet<String>,
) -> Result<(Vec<LibraryArtistDto>, 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 scope = trimmed_nonempty(req.library_scope.as_deref());
let mut w = WhereBuilder::new();
w.push_params(
&format!(
"t.rowid IN ({})",
scoped_fts_rowid_subquery_sql(pool, scope.as_deref())
),
{
let mut p = vec![SqlValue::Text(fts.to_string())];
p.extend(scoped_fts_subquery_bind(&req.server_id, scope.as_deref()));
p
},
);
w.push_raw("t.deleted = 0");
w.push_param("t.server_id = ?", SqlValue::Text(req.server_id.clone()));
w.push_raw("t.artist_id IS NOT NULL AND t.artist_id != ''");
if let Some(scope) = scope {
let clause = library_scope_equals_sql("t");
w.push_param(&clause, SqlValue::Text(scope));
}
for c in scalar {
if let Some(frag) = resolve_clause(c, EntityKind::Track)? {
applied.insert(c.field.clone());
w.push(frag);
}
}
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let sql = format!(
"SELECT t.server_id, t.artist_id, t.artist, t.synced_at \
FROM track t \
WHERE {where_sql}"
);
let params = w.params.clone();
let mut stmt = conn.prepare(&sql)?;
let rows: Vec<(String, String, Option<String>, i64)> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen = HashSet::new();
let mut deduped: Vec<LibraryArtistDto> = Vec::new();
for (server_id, artist_id, artist, synced_at) in rows {
if !seen.insert(artist_id.clone()) {
continue;
}
deduped.push(LibraryArtistDto {
server_id,
id: artist_id,
name: artist.unwrap_or_default(),
album_count: None,
synced_at,
raw_json: Value::Null,
});
if deduped.len() >= need as usize {
break;
}
}
let total = if skip_totals {
0
} else {
deduped.len() as u32
};
let page = deduped
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect();
Ok((page, total))
})
}
// ── clause resolution ──────────────────────────────────────────────────
/// Track-only filters that require joining through `track` (mood enrichment facts).
/// Other track-only fields (e.g. `bpm`) are skipped silently on album/artist queries.
fn scalar_requires_track_derived_entities(scalar: &[&LibraryFilterClause]) -> bool {
scalar
.iter()
.any(|c| matches!(c.field.as_str(), "mood_group" | "mood_tag"))
}
/// Lossless is defined on track `suffix`; year/genre filters must apply to the
/// same track rows, not stale `album` table metadata.
fn scalar_requires_lossless_track_grouping(scalar: &[&LibraryFilterClause]) -> bool {
scalar.iter().any(|c| c.field == "lossless")
}
/// Resolve one scalar clause to a WHERE fragment for `entity`. `Ok(None)`
/// means the field is known but doesn't route to this entity (§5.13.3 skip).
fn resolve_clause(
c: &LibraryFilterClause,
entity: EntityKind,
) -> Result<Option<SqlFragment>, String> {
let applies = filter::validate_for_entity(&c.field, c.op, entity).map_err(|e| e.to_string())?;
if !applies {
return Ok(None);
}
if c.field == "bpm" && entity == EntityKind::Track {
let col = bpm_resolved_sql();
let value = json_to_opt_i64(&c.field, c.value.as_ref())?;
let value_to = json_to_opt_i64(&c.field, c.value_to.as_ref())?;
return filter::compare_fragment(&c.field, &col, c.op, value, value_to)
.map(Some)
.map_err(|e| e.to_string());
}
let col = match (c.field.as_str(), entity) {
("genre", EntityKind::Track) => "t.genre",
("genre", EntityKind::Album) => "a.genre",
("year", EntityKind::Track) => "t.year",
("year", EntityKind::Album) => "a.year",
("starred", EntityKind::Track) => "t.starred_at",
("starred", EntityKind::Album) => "a.starred_at",
// `artist` has no `starred_at` column — favorites use the network list.
("starred", EntityKind::Artist) => return Ok(None),
("mood_group" | "mood_tag", EntityKind::Track) => {
return crate::advanced_search_mood::resolve_mood_clause(c);
}
("lossless", EntityKind::Track) => {
return Ok(Some(SqlFragment {
sql: crate::lossless_formats::track_is_lossless_sql("t"),
params: vec![],
}));
}
("lossless", EntityKind::Album) => {
return Ok(Some(SqlFragment {
sql: crate::lossless_formats::album_has_lossless_track_sql("a"),
params: vec![],
}));
}
("lossless", EntityKind::Artist) => {
return Ok(Some(SqlFragment {
sql: crate::lossless_formats::artist_has_lossless_track_sql("ar"),
params: vec![],
}));
}
("compilation", EntityKind::Album) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
}
("compilation", EntityKind::Track) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
}
("compilation", _) => return Ok(None),
// `text` is handled by the entity builder (FTS / LIKE), never here.
("text", _) => return Ok(None),
// Registered but no v1 SQL builder (user_rating / suffix / bit_rate).
_ => return Err(filter::FilterError::NotQueryable(c.field.clone()).to_string()),
};
if c.field == "genre" {
let v = json_to_text(&c.field, c.value.as_ref())?;
return Ok(Some(SqlFragment {
sql: format!("{col} = ? COLLATE NOCASE"),
params: vec![v],
}));
}
if c.field == "starred" {
return filter::compare_fragment(&c.field, col, FilterOp::IsTrue, None, None)
.map(Some)
.map_err(|e| e.to_string());
}
// Numeric fields: year / bpm.
let value = json_to_opt_i64(&c.field, c.value.as_ref())?;
let value_to = json_to_opt_i64(&c.field, c.value_to.as_ref())?;
filter::compare_fragment(&c.field, col, c.op, value, value_to)
.map(Some)
.map_err(|e| e.to_string())
}
// ── query execution ────────────────────────────────────────────────────
/// Cap full-table FTS counts — exact totals on 100k+ hits are not worth
/// blocking the UI for tens of seconds (§5.9 p95 budget).
const FTS_MATCH_COUNT_CAP: i64 = 10_001;
fn count_matching_rows(
conn: &rusqlite::Connection,
from: &str,
where_sql: &str,
params: &[SqlValue],
skip_totals: bool,
) -> Result<u32, rusqlite::Error> {
if skip_totals {
return Ok(0);
}
if from.contains("track_fts") {
let mut bound: Vec<SqlValue> = params.to_vec();
bound.push(SqlValue::Integer(FTS_MATCH_COUNT_CAP));
let count_sql = format!(
"SELECT COUNT(*) FROM (SELECT 1 FROM {from} WHERE {where_sql} LIMIT ?)"
);
let n: i64 = conn.query_row(
&count_sql,
rusqlite::params_from_iter(bound.iter()),
|r| r.get(0),
)?;
return Ok(n.max(0) as u32);
}
let count_sql = format!("SELECT COUNT(*) FROM {from} WHERE {where_sql}");
let n: i64 = conn.query_row(
&count_sql,
rusqlite::params_from_iter(params.iter()),
|r| r.get(0),
)?;
Ok(n.max(0) as u32)
}
/// Restrict album browse to an explicit id set (server favorites ∩ local filters).
fn push_album_id_allowlist(
w: &mut WhereBuilder,
column: &str,
ids: Option<&[String]>,
applied: &mut BTreeSet<String>,
) {
let Some(ids) = ids else {
return;
};
applied.insert("albumIds".to_string());
if ids.is_empty() {
w.push_raw("1 = 0");
return;
}
let placeholders = std::iter::repeat_n("?", ids.len()).collect::<Vec<_>>().join(", ");
let sql = format!("{column} IN ({placeholders})");
let params = ids
.iter()
.map(|id| SqlValue::Text(id.clone()))
.collect();
w.push_params(&sql, params);
}
/// Accumulates `AND`-joined WHERE clauses and their positional params in
/// lockstep so anonymous `?` placeholders bind left-to-right.
struct WhereBuilder {
clauses: Vec<String>,
params: Vec<SqlValue>,
}
impl WhereBuilder {
fn new() -> Self {
Self {
clauses: Vec::new(),
params: Vec::new(),
}
}
fn push(&mut self, frag: SqlFragment) {
self.clauses.push(frag.sql);
self.params.extend(frag.params);
}
fn push_raw(&mut self, sql: &str) {
self.clauses.push(sql.to_string());
}
fn push_param(&mut self, sql: &str, param: SqlValue) {
self.clauses.push(sql.to_string());
self.params.push(param);
}
fn push_params(&mut self, sql: &str, params: Vec<SqlValue>) {
self.clauses.push(sql.to_string());
self.params.extend(params);
}
fn where_sql(&self) -> String {
self.clauses.join(" AND ")
}
}
/// Run the COUNT (full match total) + the paged SELECT in one connection
/// borrow. Both share `where`'s params; the page appends `LIMIT ? OFFSET ?`.
#[allow(clippy::too_many_arguments)]
fn query_rows<T, F>(
store: &LibraryStore,
select_cols: &str,
from: &str,
w: &WhereBuilder,
order_sql: &str,
limit: u32,
offset: u32,
skip_totals: bool,
map: F,
) -> Result<(Vec<T>, u32), String>
where
F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
{
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let total = count_matching_rows(conn, from, &where_sql, &w.params, skip_totals)?;
let page_sql = format!(
"SELECT {select_cols} FROM {from} WHERE {where_sql} {order_sql} LIMIT ? OFFSET ?"
);
let mut page_params: Vec<SqlValue> = w.params.clone();
page_params.push(SqlValue::Integer(limit as i64));
page_params.push(SqlValue::Integer(offset as i64));
let mut stmt = conn.prepare(&page_sql)?;
let collected: rusqlite::Result<Vec<T>> = stmt
.query_map(rusqlite::params_from_iter(page_params.iter()), |r| map(r))?
.collect();
let rows = collected?;
Ok((rows, total))
})
}
/// Track search with FTS rowid prefilter — MATCH param is bound first (subquery in `from`).
#[allow(clippy::too_many_arguments)]
fn query_rows_fts<T, F>(
store: &LibraryStore,
select_cols: &str,
from: &str,
fts_match: &str,
fts_subquery_params: &[SqlValue],
w: &WhereBuilder,
order_sql: &str,
limit: u32,
offset: u32,
skip_totals: bool,
map: F,
) -> Result<(Vec<T>, u32), String>
where
F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
{
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let mut bind: Vec<SqlValue> = vec![SqlValue::Text(fts_match.to_string())];
bind.extend(fts_subquery_params.iter().cloned());
bind.extend(w.params.iter().cloned());
let total = count_matching_rows(conn, from, &where_sql, &bind, skip_totals)?;
let page_sql = format!(
"SELECT {select_cols} FROM {from} WHERE {where_sql} {order_sql} LIMIT ? OFFSET ?"
);
bind.push(SqlValue::Integer(limit as i64));
bind.push(SqlValue::Integer(offset as i64));
let mut stmt = conn.prepare(&page_sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(bind.iter()), |r| map(r))?
.collect::<rusqlite::Result<Vec<T>>>()?;
Ok((rows, total))
})
}
/// Grouped SELECT (album/artist rows derived from `track`). Skips COUNT when
/// `skip_totals` — Live Search only needs the first page.
#[allow(clippy::too_many_arguments)]
fn query_grouped_rows<T, F>(
store: &LibraryStore,
select_cols: &str,
from: &str,
w: &WhereBuilder,
group_sql: &str,
order_sql: &str,
limit: u32,
offset: u32,
skip_totals: bool,
map: F,
) -> Result<(Vec<T>, u32), String>
where
F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result<T>,
{
let where_sql = w.where_sql();
store.with_read_conn(|conn| {
let total = if skip_totals {
0u32
} else {
// Grouped browse totals must count distinct groups (album/artist rows),
// not raw track rows matching the WHERE clause.
let count_sql = format!(
"SELECT COUNT(*) FROM (SELECT 1 FROM {from} WHERE {where_sql} {group_sql})"
);
let n: i64 = conn.query_row(
&count_sql,
rusqlite::params_from_iter(w.params.iter()),
|r| r.get(0),
)?;
n.max(0) as u32
};
let page_sql = format!(
"SELECT {select_cols} FROM {from} WHERE {where_sql} {group_sql} {order_sql} LIMIT ? OFFSET ?"
);
let mut page_params: Vec<SqlValue> = w.params.clone();
page_params.push(SqlValue::Integer(limit as i64));
page_params.push(SqlValue::Integer(offset as i64));
let mut stmt = conn.prepare(&page_sql)?;
let collected: rusqlite::Result<Vec<T>> = stmt
.query_map(rusqlite::params_from_iter(page_params.iter()), |r| map(r))?
.collect();
let rows = collected?;
Ok((rows, total))
})
}
// ── row mappers ────────────────────────────────────────────────────────
fn map_album(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
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: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: parse_raw_json(raw),
})
}
fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
let raw: Option<String> = r.get(5)?;
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: r.get(3)?,
synced_at: r.get(4)?,
raw_json: parse_raw_json(raw),
})
}
fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
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: Some(r.get(5)?),
duration_sec: Some(r.get(6)?),
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: Value::Null,
})
}
fn map_artist_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
Ok(LibraryArtistDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
album_count: Some(r.get(3)?),
synced_at: r.get(4)?,
raw_json: Value::Null,
})
}
fn parse_raw_json(raw: Option<String>) -> Value {
raw.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null)
}
// ── small helpers ──────────────────────────────────────────────────────
fn trimmed_nonempty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
}
fn order_clause(sort: &[LibrarySortClause], entity: EntityKind) -> Option<String> {
let mut keys: Vec<String> = Vec::new();
for s in sort {
if let Some(col) = sort_column(&s.field, entity) {
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
}
if keys.is_empty() {
None
} else {
Some(format!("ORDER BY {}", keys.join(", ")))
}
}
/// Sort for album rows aggregated from `track t` (`GROUP BY t.album_id`).
/// Must not reference `album a` — that alias is absent in this query shape.
fn album_order_from_track_groups(sort: &[LibrarySortClause]) -> Option<String> {
let mut keys: Vec<String> = Vec::new();
for s in sort {
let col = match s.field.as_str() {
"name" => "MAX(t.album) COLLATE NOCASE",
"artist" => "MAX(t.artist) COLLATE NOCASE",
"year" => "MAX(t.year)",
"random" => "RANDOM()",
_ => continue,
};
let dir = match s.dir {
SortDir::Asc => "ASC",
SortDir::Desc => "DESC",
};
keys.push(format!("{col} {dir}"));
}
if keys.is_empty() {
None
} else {
Some(format!("ORDER BY {}", keys.join(", ")))
}
}
/// Allowlist of sortable fields per entity → trusted column expression.
/// Unknown sort fields are ignored (fall back to the default order).
fn sort_column(field: &str, entity: EntityKind) -> Option<&'static str> {
match (field, entity) {
("title", EntityKind::Track) => Some("t.title COLLATE NOCASE"),
("year", EntityKind::Track) => Some("t.year"),
("duration", EntityKind::Track) => Some("t.duration_sec"),
("artist", EntityKind::Track) => Some("t.artist COLLATE NOCASE"),
("album", EntityKind::Track) => Some("t.album COLLATE NOCASE"),
("track_number", EntityKind::Track) => Some("t.track_number"),
("play_count", EntityKind::Track) => Some("t.play_count"),
("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"),
("year", EntityKind::Album) => Some("a.year"),
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
("name", EntityKind::Artist) => Some("ar.name COLLATE NOCASE"),
// SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample,
// no index scan needed beyond the row-id range. Direction is ignored.
("random", _) => Some("RANDOM()"),
_ => None,
}
}
fn compilation_filter_fragment(
field: &str,
op: FilterOp,
value: Option<&Value>,
table_alias: &str,
) -> Result<Option<SqlFragment>, String> {
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
match op {
FilterOp::IsTrue => Ok(Some(SqlFragment {
sql: comp_sql,
params: vec![],
})),
FilterOp::Eq => {
let want_comp = json_to_bool(field, value)?;
let sql = if want_comp {
comp_sql
} else {
format!("NOT ({comp_sql})")
};
Ok(Some(SqlFragment { sql, params: vec![] }))
}
_ => Err(filter::FilterError::UnsupportedOp {
field: field.to_string(),
op: op.as_str(),
}
.to_string()),
}
}
fn json_to_bool(field: &str, v: Option<&Value>) -> Result<bool, String> {
match v {
Some(Value::Bool(b)) => Ok(*b),
Some(Value::Number(n)) => Ok(n.as_i64() == Some(1)),
Some(Value::String(s)) => Ok(matches!(s.as_str(), "1" | "true" | "TRUE")),
_ => Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "expected boolean".to_string(),
}
.to_string()),
}
}
fn json_to_text(field: &str, v: Option<&Value>) -> Result<SqlValue, String> {
match v {
Some(Value::String(s)) => Ok(SqlValue::Text(s.clone())),
_ => Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "expected a string value".to_string(),
}
.to_string()),
}
}
fn json_to_opt_i64(field: &str, v: Option<&Value>) -> Result<Option<SqlValue>, String> {
match v {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(n)) => n
.as_i64()
.map(|i| Some(SqlValue::Integer(i)))
.ok_or_else(|| {
filter::FilterError::BadValue {
field: field.to_string(),
detail: "expected an integer value".to_string(),
}
.to_string()
}),
_ => Err(filter::FilterError::BadValue {
field: field.to_string(),
detail: "expected a numeric value".to_string(),
}
.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::SortDir;
use crate::repos::{TrackRepository, TrackRow};
use serde_json::json;
// ── fixtures ───────────────────────────────────────────────────────
fn track(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: Some(format!("ar_{artist}")),
album: album.into(),
album_id: Some(format!("al_{album}")),
album_artist: Some(artist.into()),
duration_sec: 200,
track_number: Some(1),
disc_number: Some(1),
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(),
}
}
fn insert_album(store: &LibraryStore, server: &str, id: &str, name: &str, year: Option<i64>, genre: Option<&str>) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, year, genre, synced_at, raw_json) \
VALUES (?1, ?2, ?3, ?4, ?5, 1, '{}')",
rusqlite::params![server, id, name, year, genre],
)
})
.unwrap();
}
fn insert_artist(store: &LibraryStore, server: &str, id: &str, name: &str) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO artist (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, '{}')",
rusqlite::params![server, id, name],
)
})
.unwrap();
}
fn req(server: &str, entities: &[EntityKind]) -> LibraryAdvancedSearchRequest {
LibraryAdvancedSearchRequest {
server_id: server.into(),
library_scope: None,
query: None,
entity_types: entities.to_vec(),
filters: Vec::new(),
starred_only: None,
restrict_album_ids: None,
query_album_title_only: None,
sort: Vec::new(),
limit: 50,
offset: 0,
skip_totals: false,
}
}
fn clause(field: &str, op: FilterOp, value: Option<Value>, value_to: Option<Value>) -> LibraryFilterClause {
LibraryFilterClause {
field: field.into(),
op,
value,
value_to,
}
}
// ── text / FTS ─────────────────────────────────────────────────────
#[test]
fn text_prefix_query_matches_partial_artist_name() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Enter Sandman", "Metallica", "Metallica"),
track("s1", "t2", "Other", "Other Artist", "Other Album"),
])
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.query = Some("metal".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].artist.as_deref(), Some("Metallica"));
}
#[test]
fn text_query_matches_track_via_fts() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "Aurora", "Anna", "Skylines"),
track("s1", "t2", "Sunset", "Beth", "Skylines"),
])
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.query = Some("aurora".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
assert_eq!(resp.totals.tracks, 1);
assert!(resp.applied_filters.contains(&"text".to_string()));
assert_eq!(resp.source, "local");
}
#[test]
fn text_query_matches_album_and_artist_via_like() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Aurora Nights", None, None);
insert_album(&store, "s1", "al2", "Other", None, None);
insert_artist(&store, "s1", "ar1", "Aurora Quartet");
let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]);
r.query = Some("aurora".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al1");
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar1");
}
#[test]
fn text_query_derives_album_and_artist_from_tracks_when_tables_empty() {
let store = LibraryStore::open_in_memory();
let mut t1 = track("s1", "t1", "Song One", "Aurora Quartet", "Aurora Nights");
t1.cover_art_id = Some("cv1".into());
TrackRepository::new(&store)
.upsert_batch(&[
t1,
track("s1", "t2", "Song Two", "Other Artist", "Other Album"),
])
.unwrap();
let mut r = req("s1", &[EntityKind::Album, EntityKind::Artist]);
r.query = Some("aurora".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_Aurora Nights");
assert_eq!(resp.albums[0].cover_art_id.as_deref(), Some("cv1"));
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar_Aurora Quartet");
}
#[test]
fn special_chars_in_query_do_not_crash_fts() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Hello World", "A", "B")])
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
// Each of these is a raw FTS5 syntax error if passed unescaped; the
// builder must quote them into safe terms so the call returns Ok.
for q in ["\"", "AND", "foo*", "a OR b", "((", "near/"] {
r.query = Some(q.to_string());
assert!(
run_advanced_search(&store, &r).is_ok(),
"query `{q}` must not raise an FTS syntax error"
);
}
}
#[test]
fn quoted_token_query_still_matches_clean_terms() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Hello World", "A", "B")])
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
// Multi-token query AND-s its terms — both present → one hit.
r.query = Some("hello world".into());
assert_eq!(run_advanced_search(&store, &r).unwrap().tracks.len(), 1);
}
// ── genre / year / starred ─────────────────────────────────────────
#[test]
fn genre_filter_is_case_insensitive() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.genre = Some("Ambient".into());
let mut b = track("s1", "t2", "B", "X", "Alb");
b.genre = Some("Techno".into());
TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("genre", FilterOp::Eq, Some(json!("ambient")), None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
assert!(resp.applied_filters.contains(&"genre".to_string()));
}
#[test]
fn grouped_album_totals_count_distinct_albums_not_tracks() {
let store = LibraryStore::open_in_memory();
let mut rows: Vec<TrackRow> = Vec::new();
for i in 0..6 {
let mut t = track("s1", &format!("t{i}"), &format!("Song {i}"), "X", "Alb One");
t.genre = Some("Rock".into());
rows.push(t);
}
for i in 6..10 {
let mut t = track("s1", &format!("t{i}"), &format!("Song {i}"), "Y", "Alb Two");
t.genre = Some("Rock".into());
rows.push(t);
}
TrackRepository::new(&store).upsert_batch(&rows).unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("genre", FilterOp::Eq, Some(json!("rock")), None)];
r.limit = 1;
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1, "page is capped by limit");
assert_eq!(
resp.totals.albums, 2,
"total must be distinct album groups, not matching track rows"
);
assert_eq!(resp.totals.tracks, 0);
}
#[test]
fn year_between_is_inclusive() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.year = Some(2000);
let mut b = track("s1", "t2", "B", "X", "Alb");
b.year = Some(2010);
let mut c = track("s1", "t3", "C", "X", "Alb");
c.year = Some(2011);
TrackRepository::new(&store).upsert_batch(&[a, b, c]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("year", FilterOp::Between, Some(json!(2000)), Some(json!(2010)))];
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["t1", "t2"]);
}
#[test]
fn year_only_branch_runs_without_fts() {
// Genre/year-only (no query) must not require an FTS join (§5.13.7).
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.year = Some(1999);
TrackRepository::new(&store).upsert_batch(&[a]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("year", FilterOp::Gte, Some(json!(1999)), None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert!(!resp.applied_filters.contains(&"text".to_string()));
}
#[test]
fn starred_only_filters_tracks() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.starred_at = Some(123);
let b = track("s1", "t2", "B", "X", "Alb");
TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.starred_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
}
#[test]
fn normal_album_browse_uses_track_catalog_when_album_table_is_sparse() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_stub", "Starred Stub", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET starred_at = 100 WHERE server_id = 's1' AND id = 'al_stub'",
[],
)
})
.unwrap();
let mut a = track("s1", "t1", "A", "X", "Album A");
a.album_id = Some("al_a".into());
let mut b = track("s1", "t2", "B", "Y", "Album B");
b.album_id = Some("al_b".into());
TrackRepository::new(&store)
.upsert_batch(&[a, b])
.unwrap();
let r = req("s1", &[EntityKind::Album]);
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.albums.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"al_a"));
assert!(ids.contains(&"al_b"));
assert!(!ids.contains(&"al_stub"));
}
#[test]
fn starred_only_album_entity_uses_album_star_not_track_star() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_star", "Starred Album", None, None);
store
.with_conn("misc", |c| {
c.execute(
"UPDATE album SET starred_at = 100 WHERE server_id = 's1' AND id = 'al_star'",
[],
)
})
.unwrap();
let mut track_star = track("s1", "t1", "T", "X", "TrackStar Alb");
track_star.album_id = Some("al_track_only".into());
track_star.starred_at = Some(200);
TrackRepository::new(&store)
.upsert_batch(&[track_star])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.starred_only = Some(true);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_star");
}
// ── bpm dual storage ───────────────────────────────────────────────
#[test]
fn bpm_filter_matches_hot_column() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.bpm = Some(125);
let mut b = track("s1", "t2", "B", "X", "Alb");
b.bpm = Some(90);
TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
}
#[test]
fn bpm_filter_falls_back_to_track_fact() {
let store = LibraryStore::open_in_memory();
// No hot `bpm`; an analysis fact carries it instead.
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "A", "X", "Alb")])
.unwrap();
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO track_fact \
(server_id, track_id, fact_kind, value_int, source_kind, source_id, confidence, fetched_at) \
VALUES ('s1', 't1', 'bpm', 128, 'analysis', 'seed', 1.0, 1)",
[],
)
})
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(125)), Some(json!(130)))];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1, "bpm should resolve via track_fact fallback");
assert_eq!(resp.tracks[0].bpm, Some(128));
assert_eq!(resp.tracks[0].bpm_source.as_deref(), Some("analysis"));
}
#[test]
fn bpm_filter_prefers_analysis_fact_over_hot_tag() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.bpm = Some(90);
TrackRepository::new(&store).upsert_batch(&[a]).unwrap();
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO track_fact \
(server_id, track_id, fact_kind, value_int, source_kind, source_id, confidence, fetched_at) \
VALUES ('s1', 't1', 'bpm', 128, 'analysis', 'oximedia-60s-center', 1.0, 1)",
[],
)
})
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(125)), Some(json!(130)))];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].bpm, Some(128));
assert_eq!(resp.tracks[0].bpm_source.as_deref(), Some("analysis"));
}
#[test]
fn bpm_source_is_tag_when_only_hot_column_set() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.bpm = Some(125);
TrackRepository::new(&store).upsert_batch(&[a]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].bpm_source.as_deref(), Some("tag"));
}
// ── mood tag / group filters ─────────────────────────────────────
fn insert_mood_tag(store: &LibraryStore, server: &str, track: &str, tag: &str) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO track_fact \
(server_id, track_id, fact_kind, value_text, source_kind, source_id, confidence, fetched_at) \
VALUES (?1, ?2, 'mood_tag', ?3, 'analysis', ?4, 1.0, 1)",
rusqlite::params![server, track, tag, format!("oximedia-60s-center:{tag}")],
)
})
.unwrap();
}
#[test]
fn mood_group_joy_matches_happy_mood_tag() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "X", "Alb"),
track("s1", "t2", "B", "X", "Alb"),
])
.unwrap();
insert_mood_tag(&store, "s1", "t1", "happy");
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("mood_group", FilterOp::Eq, Some(json!("joy")), None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
}
#[test]
fn mood_groups_overlap_work_and_romance_on_calm_peaceful_track() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Calm", "X", "Alb")])
.unwrap();
insert_mood_tag(&store, "s1", "t1", "calm");
insert_mood_tag(&store, "s1", "t1", "peaceful");
for group in ["work", "romance"] {
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("mood_group", FilterOp::Eq, Some(json!(group)), None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1, "group `{group}` should match calm/peaceful");
}
}
#[test]
fn mood_group_in_joy_matches_happy_tag() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "X", "Alb"),
track("s1", "t2", "B", "X", "Alb"),
])
.unwrap();
insert_mood_tag(&store, "s1", "t1", "happy");
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause(
"mood_group",
FilterOp::In,
Some(json!(["joy"])),
None,
)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
}
#[test]
fn mood_tag_eq_calm_matches_calm_fact() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
track("s1", "t1", "A", "X", "Alb"),
track("s1", "t2", "B", "X", "Alb"),
])
.unwrap();
insert_mood_tag(&store, "s1", "t2", "calm");
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("mood_tag", FilterOp::Eq, Some(json!("calm")), None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t2");
}
// ── entity routing / errors ────────────────────────────────────────
#[test]
fn track_only_filter_is_ignored_for_album_entity_no_error() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Some Album", Some(2001), None);
let mut r = req("s1", &[EntityKind::Album]);
// bpm is track-only; for an album query it must be skipped, not error.
r.filters = vec![clause("bpm", FilterOp::Between, Some(json!(120)), Some(json!(130)))];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert!(!resp.applied_filters.contains(&"bpm".to_string()));
}
#[test]
fn unknown_field_is_an_error() {
let store = LibraryStore::open_in_memory();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("nope", FilterOp::Eq, Some(json!("x")), None)];
let err = run_advanced_search(&store, &r).unwrap_err();
assert!(err.contains("unknown filter field"), "got: {err}");
}
#[test]
fn lossless_filter_returns_only_lossless_tracks() {
let store = LibraryStore::open_in_memory();
let mut flac = track("s1", "t1", "A", "X", "Alb");
flac.suffix = Some("flac".into());
let mut mp3 = track("s1", "t2", "B", "X", "Alb");
mp3.suffix = Some("mp3".into());
TrackRepository::new(&store)
.upsert_batch(&[flac, mp3])
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
assert!(resp.applied_filters.contains(&"lossless".to_string()));
}
#[test]
fn lossless_filter_on_album_entity_requires_lossless_track() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Lossless Album", None, None);
insert_album(&store, "s1", "al2", "Lossy Album", None, None);
let mut flac = track("s1", "t1", "A", "X", "Alb");
flac.album_id = Some("al1".into());
flac.suffix = Some("flac".into());
let mut mp3 = track("s1", "t2", "B", "Y", "Alb2");
mp3.album_id = Some("al2".into());
mp3.suffix = Some("mp3".into());
TrackRepository::new(&store)
.upsert_batch(&[flac, mp3])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al1");
}
#[test]
fn restrict_album_ids_intersects_with_lossless_filter() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al_fav_lossless", "Fav Lossless", None, None);
insert_album(&store, "s1", "al_fav_lossy", "Fav Lossy", None, None);
insert_album(&store, "s1", "al_other_lossless", "Other Lossless", None, None);
let mut flac_fav = track("s1", "t1", "A", "X", "Alb");
flac_fav.album_id = Some("al_fav_lossless".into());
flac_fav.suffix = Some("flac".into());
let mut mp3_fav = track("s1", "t2", "B", "Y", "Alb2");
mp3_fav.album_id = Some("al_fav_lossy".into());
mp3_fav.suffix = Some("mp3".into());
let mut flac_other = track("s1", "t3", "C", "Z", "Alb3");
flac_other.album_id = Some("al_other_lossless".into());
flac_other.suffix = Some("flac".into());
TrackRepository::new(&store)
.upsert_batch(&[flac_fav, mp3_fav, flac_other])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
r.restrict_album_ids = Some(vec![
"al_fav_lossless".into(),
"al_fav_lossy".into(),
]);
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_fav_lossless");
assert!(resp.applied_filters.contains(&"albumIds".to_string()));
}
#[test]
fn lossless_and_year_filters_use_track_year_when_album_table_differs() {
let store = LibraryStore::open_in_memory();
insert_album(&store, "s1", "al1", "Hi-Res Album", Some(1990), None);
let mut flac = track("s1", "t1", "Track", "Art", "Alb");
flac.album_id = Some("al1".into());
flac.suffix = Some("flac".into());
flac.year = Some(2022);
TrackRepository::new(&store)
.upsert_batch(&[flac])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![
clause("year", FilterOp::Between, Some(json!(2020)), Some(json!(2024))),
clause("lossless", FilterOp::IsTrue, None, None),
];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al1");
}
#[test]
fn lossless_album_browse_with_name_sort_returns_rows() {
let store = LibraryStore::open_in_memory();
let mut flac = track("s1", "t1", "Track", "Art", "Zebra Album");
flac.suffix = Some("flac".into());
TrackRepository::new(&store)
.upsert_batch(&[flac])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
r.sort = vec![LibrarySortClause {
field: "name".into(),
dir: SortDir::Asc,
}];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
}
#[test]
fn lossless_filter_on_artist_entity_requires_lossless_track() {
let store = LibraryStore::open_in_memory();
insert_artist(&store, "s1", "ar1", "Lossless Artist");
insert_artist(&store, "s1", "ar2", "Lossy Artist");
let mut flac = track("s1", "t1", "A", "Lossless Artist", "Alb");
flac.artist_id = Some("ar1".into());
flac.suffix = Some("flac".into());
let mut mp3 = track("s1", "t2", "B", "Lossy Artist", "Alb2");
mp3.artist_id = Some("ar2".into());
mp3.suffix = Some("mp3".into());
TrackRepository::new(&store)
.upsert_batch(&[flac, mp3])
.unwrap();
let mut r = req("s1", &[EntityKind::Artist]);
r.filters = vec![clause("lossless", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].id, "ar1");
}
fn insert_album_raw(
store: &LibraryStore,
server: &str,
id: &str,
name: &str,
raw_json: &str,
) {
store
.with_conn("misc", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, synced_at, raw_json) \
VALUES (?1, ?2, ?3, 1, ?4)",
rusqlite::params![server, id, name, raw_json],
)
})
.unwrap();
}
#[test]
fn compilation_filter_only_returns_compilation_albums() {
let store = LibraryStore::open_in_memory();
insert_album_raw(
&store,
"s1",
"al_comp",
"Greatest Hits",
r#"{"compilation":true}"#,
);
insert_album_raw(&store, "s1", "al_regular", "Studio", "{}");
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_comp");
}
#[test]
fn compilation_filter_on_track_grouped_album_browse() {
let store = LibraryStore::open_in_memory();
let mut comp = track("s1", "t_comp", "Hit", "VA", "Comp Album");
comp.album_id = Some("al_comp".into());
comp.raw_json = r#"{"compilation":true}"#.into();
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
reg.album_id = Some("al_reg".into());
reg.raw_json = "{}".into();
TrackRepository::new(&store)
.upsert_batch(&[comp, reg])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_comp");
assert!(resp.applied_filters.contains(&"compilation".to_string()));
}
#[test]
fn compilation_eq_false_hides_compilations() {
let store = LibraryStore::open_in_memory();
insert_album_raw(
&store,
"s1",
"al_comp",
"Greatest Hits",
r#"{"releaseTypes":["Compilation"]}"#,
);
insert_album_raw(&store, "s1", "al_regular", "Studio", "{}");
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::Eq, Some(json!(false)), None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_regular");
}
#[test]
fn planned_but_unbuilt_field_is_an_error() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "A", "X", "Alb")])
.unwrap();
let mut r = req("s1", &[EntityKind::Track]);
// `suffix` is registered (Planned) but has no v1 SQL builder.
r.filters = vec![clause("suffix", FilterOp::Eq, Some(json!("flac")), None)];
let err = run_advanced_search(&store, &r).unwrap_err();
assert!(err.contains("not queryable"), "got: {err}");
}
#[test]
fn undeclared_op_for_known_field_is_an_error() {
let store = LibraryStore::open_in_memory();
let mut r = req("s1", &[EntityKind::Track]);
// `genre` only declares `eq`.
r.filters = vec![clause("genre", FilterOp::Gte, Some(json!("rock")), None)];
let err = run_advanced_search(&store, &r).unwrap_err();
assert!(err.contains("not supported"), "got: {err}");
}
// ── scope / pagination / totals ────────────────────────────────────
#[test]
fn library_scope_narrows_track_results() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.library_id = Some("lib1".into());
let mut b = track("s1", "t2", "B", "X", "Alb");
b.library_id = Some("lib2".into());
TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.library_scope = Some("lib1".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
}
#[test]
fn library_scope_reads_library_id_from_raw_json_when_column_null() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.raw_json = serde_json::json!({"libraryId": 3}).to_string();
TrackRepository::new(&store).upsert_batch(&[a]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.library_scope = Some("3".into());
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].id, "t1");
}
#[test]
fn totals_reflect_full_match_count_not_page_size() {
let store = LibraryStore::open_in_memory();
let rows: Vec<TrackRow> = (0..10)
.map(|i| track("s1", &format!("t{i}"), "Common Title", "X", "Alb"))
.collect();
TrackRepository::new(&store).upsert_batch(&rows).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.query = Some("common".into());
r.limit = 3;
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.tracks.len(), 3, "page is capped by limit");
assert_eq!(resp.totals.tracks, 10, "total is the full match count");
}
#[test]
fn offset_pages_through_results() {
let store = LibraryStore::open_in_memory();
let rows: Vec<TrackRow> = (0..5)
.map(|i| track("s1", &format!("t{i}"), &format!("Title {i}"), "X", "Alb"))
.collect();
TrackRepository::new(&store).upsert_batch(&rows).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.sort = vec![LibrarySortClause { field: "title".into(), dir: SortDir::Asc }];
r.limit = 2;
r.offset = 2;
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["t2", "t3"]);
assert_eq!(resp.totals.tracks, 5);
}
#[test]
fn unrequested_entities_are_empty() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "A", "X", "Alb")])
.unwrap();
insert_album(&store, "s1", "al1", "Alb", None, None);
let resp = run_advanced_search(&store, &req("s1", &[EntityKind::Track])).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert!(resp.albums.is_empty());
assert!(resp.artists.is_empty());
assert_eq!(resp.totals.albums, 0);
}
#[test]
fn sort_desc_orders_results() {
let store = LibraryStore::open_in_memory();
let mut a = track("s1", "t1", "A", "X", "Alb");
a.year = Some(2000);
let mut b = track("s1", "t2", "B", "X", "Alb");
b.year = Some(2020);
TrackRepository::new(&store).upsert_batch(&[a, b]).unwrap();
let mut r = req("s1", &[EntityKind::Track]);
r.sort = vec![LibrarySortClause { field: "year".into(), dir: SortDir::Desc }];
let resp = run_advanced_search(&store, &r).unwrap();
let ids: Vec<&str> = resp.tracks.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["t2", "t1"]);
}
}