mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
4ac373a65b
* 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)
2187 lines
80 KiB
Rust
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"]);
|
|
}
|
|
}
|