mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
06da15caf3
* feat(albums): persist browse sort and genre filter for the session Keep Albums sort and genre selection in an in-memory Zustand store so navigating into album detail and back no longer resets browse context. Fixes #875 (partial). * feat(albums): restore browse filters only when returning from album detail Keep sort in the session store for the app lifetime. Stash genre, year, compilation, starred, and lossless filters when leaving Albums for an album page and restore them on POP (back). Clear the stash when opening Albums from elsewhere via sidebar navigation. * feat(albums): filter quick-clear chips; fix lossless A–Z sort Add inline × on active toolbar filters (genre, year, favorites, lossless, compilations) without opening the popover. Route lossless album browse through advanced search with album sort clauses on Albums and Lossless Albums; client-sort on the network fallback path. * fix(albums): apply year filter when only from or to is set Resolve open-ended year bounds with gte/lte on the local index and partial fromYear/toYear on Subsonic. Update the year filter chip label for single-bound ranges. * refactor(albums): combine browse filters in one query (genre + year + lossless) Replace mutually exclusive load/loadFiltered branches with fetchAlbumBrowsePage that ANDs server-side filters on the local index (genre OR union). Network fallback applies year bounds after genre fetch. Always show sort while a year filter is active. * fix(albums): load favorites filter server-side instead of scanning all albums Starred on Albums was client-only: each page was filtered locally and pendingClientFilterMatch kept paginating the full catalog. Query starred albums via the local index or getAlbumList(starred); apply overrides only for in-session star/unstar. * feat(library): local album/artist favorites via patch-on-use Mirror album- and artist-level stars into the library index (library_patch_album, library_patch_artist, migration 010). Albums and Artists favorites browse use entity starred_at only; normal album catalog stays track-derived so patch stubs do not hide the library. Keep album year on favorite cards via track COALESCE, patch metadata, and safer raw_json merge. * fix(library): reconcile album/artist stars from server, drop stubs Favorites browse uses getAlbumList/getStarred2 as source of truth. library_reconcile_*_stars clears local stars removed elsewhere; patch-on-use updates existing rows only (no stub INSERT). Reconcile on favorites load and after star/unstar in-app. * feat(albums): favorites reconcile, filter combos, and back-navigation fix Album browse keeps filter state when returning from album detail (POP stash read on mount, request-generation guard against stale loads). Favorites use getStarred2 as source of truth: reconcile album.starred_at in the local index (UPDATE only, no stub rows), with a small session cache for instant paint. Combine favorites with lossless or genre via restrictAlbumIds in advanced search. Remove album/artist patch-on-use and migration 010; artist favorites stay network-only. Track patch-on-use unchanged. * feat(albums): catalog year bounds and genre list narrowed by filters Year filter spinners use min/max years from the local track index (not 1900); "from" starts at oldest, "to" at newest, values clamp to catalog. When year, lossless, favorites, or compilation filters are active, the genre picker lists only genres present on matching albums (other filters applied, genre excluded). Adds library_get_catalog_year_bounds for the year UI. * feat(albums): debounce year filter and show genre album counts Debounce year range changes by 350ms before reloading browse. Genre picker lists album counts per genre (from getGenres or from albums matching other active filters) and sorts genres by count descending. * fix(albums): compilation filter detection and scan cap Recognize OpenSubsonic compilation flags (compilation, releaseTypes) so client-side comp filters work on local index rows. Cap background pagination at 500 albums when no matches are visible and show empty state instead of spinning through the whole catalog. * feat(albums): filter compilations via local library index Add `compilation` to advanced search (album entity): reads OpenSubsonic flags from album raw_json. Album browse passes compFilter into library_advanced_search when the index is ready; network-only path keeps client-side filtering with the existing scan cap. * fix(albums): apply compilation filter on track-grouped index browse Album browse uses track aggregation, so compilation clauses were skipped. Filter track raw_json (same SQL as album), merge album flags at sync, and always run the client-side compilation pass as a fallback. * refactor(albums): split browse modules and extract browse_support commands Move album browse fetch/filter logic into focused modules and useAlbumBrowseData; register reconcile/year-bounds Tauri commands from browse_support. Trim dead helpers and barrel exports; fix typecheck in compilation tests. * chore: note PR #876 in CHANGELOG and settings credits * fix(albums): show catalog min/max in partial year filter chip label When only from or to year is set, the active chip now reads e.g. 1990–2020 instead of 1990– or –2025, using indexed catalog bounds when available.
2141 lines
78 KiB
Rust
2141 lines
78 KiB
Rust
//! 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<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())
|
|
}
|
|
|
|
#[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(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<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 {
|
|
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<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,
|
|
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 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"]);
|
|
}
|
|
}
|