//! Advanced Search SQL builder (spec §5.13). PR-5d ships the backend only — //! the `AdvancedSearch.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_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, Option, Option, Option, Option, Option, 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 { 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 { // `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 = 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 = 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, ) -> Result<(Vec, 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 { repos::row_to_track_row(row).map(|r| LibraryTrackDto::from_row(&r)) } fn map_track_row_resolved_bpm(row: &rusqlite::Row<'_>) -> rusqlite::Result { 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 { 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()) } #[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, ) -> Result<(Vec, 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(fts_album_prefix_match_query) { 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(fts_album_prefix_match_query) { 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, ) -> Result<(Vec, 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, include_album_table_rows: bool, ) -> Result<(Vec, 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, ) -> Result<(Vec, 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, ) -> Result<(Vec, 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, ) -> Result<(Vec, 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, ) -> Result<(Vec, 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 = 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::>>()?; let mut seen = HashSet::new(); let mut deduped: Vec = 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, ) -> Result<(Vec, 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, 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::>>()?; let mut seen = HashSet::new(); let mut deduped: Vec = 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, 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 { if skip_totals { return Ok(0); } if from.contains("track_fts") { let mut bound: Vec = 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, ) { 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::>().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, params: Vec, } 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) { 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( store: &LibraryStore, select_cols: &str, from: &str, w: &WhereBuilder, order_sql: &str, limit: u32, offset: u32, skip_totals: bool, map: F, ) -> Result<(Vec, u32), String> where F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, { 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 = 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> = 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( 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, u32), String> where F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, { let where_sql = w.where_sql(); store.with_read_conn(|conn| { let mut bind: Vec = 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::>>()?; 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( 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, u32), String> where F: Fn(&rusqlite::Row<'_>) -> rusqlite::Result, { let where_sql = w.where_sql(); store.with_read_conn(|conn| { let total = if skip_totals { 0u32 } else { count_matching_rows(conn, from, &where_sql, &w.params, false)? }; let page_sql = format!( "SELECT {select_cols} FROM {from} WHERE {where_sql} {group_sql} {order_sql} LIMIT ? OFFSET ?" ); let mut page_params: Vec = 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> = 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 { let raw: Option = 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 { let raw: Option = 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 { 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 { 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) -> Value { raw.and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or(Value::Null) } // ── small helpers ────────────────────────────────────────────────────── fn trimmed_nonempty(s: Option<&str>) -> Option { s.map(str::trim).filter(|s| !s.is_empty()).map(String::from) } fn order_clause(sort: &[LibrarySortClause], entity: EntityKind) -> Option { let mut keys: Vec = 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 { let mut keys: Vec = 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, 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 { 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 { 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, 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, 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, sort: Vec::new(), limit: 50, offset: 0, skip_totals: false, } } fn clause(field: &str, op: FilterOp, value: Option, value_to: Option) -> 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 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 = (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 = (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"]); } }