Expose exact music filters in desktop and local agent clients
This commit is contained in:
1 parent
2eace15a33
commit
c33047998b
6 files changed
+263
-35
No files matched your search
@@ -228,6 +228,8 @@ struct Filters {
|
|||||||
code_only: bool,
|
code_only: bool,
|
||||||
folder: Option<PathBuf>,
|
folder: Option<PathBuf>,
|
||||||
extension: Option<String>,
|
extension: Option<String>,
|
||||||
|
media_artist: Option<String>,
|
||||||
|
media_title: Option<String>,
|
||||||
min_size: Option<u64>,
|
min_size: Option<u64>,
|
||||||
max_size: Option<u64>,
|
max_size: Option<u64>,
|
||||||
min_duration_ms: Option<u32>,
|
min_duration_ms: Option<u32>,
|
||||||
@@ -242,6 +244,8 @@ impl From<Filters> for SearchOptions {
|
|||||||
code_only: value.code_only,
|
code_only: value.code_only,
|
||||||
folder: value.folder,
|
folder: value.folder,
|
||||||
extension: value.extension,
|
extension: value.extension,
|
||||||
|
media_artist: value.media_artist,
|
||||||
|
media_title: value.media_title,
|
||||||
min_size: value.min_size,
|
min_size: value.min_size,
|
||||||
max_size: value.max_size,
|
max_size: value.max_size,
|
||||||
min_duration_ms: value.min_duration_ms,
|
min_duration_ms: value.min_duration_ms,
|
||||||
@@ -253,18 +257,36 @@ impl From<Filters> for SearchOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Each job owns its connection. Slow indexing never holds a desktop mutex.
|
// Each job owns its connection. Slow indexing never holds a desktop mutex.
|
||||||
|
fn validate_search_input(query: &str, options: &SearchOptions) -> Result<(), String> {
|
||||||
|
if query.trim().is_empty() && options.media_artist.is_none() && options.media_title.is_none() {
|
||||||
|
return Err("Supply search text or an exact media artist/title constraint".to_owned());
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
options.media_artist.as_deref(),
|
||||||
|
options.media_title.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
searchi_core::normalize_media_field(value).map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn search(
|
async fn search(
|
||||||
query: String,
|
query: String,
|
||||||
filters: Option<Filters>,
|
filters: Option<Filters>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<SearchResult>, String> {
|
) -> Result<Vec<SearchResult>, String> {
|
||||||
|
let options: SearchOptions = filters.unwrap_or_default().into();
|
||||||
|
validate_search_input(&query, &options)?;
|
||||||
let lexical = state.lexical.clone();
|
let lexical = state.lexical.clone();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
lexical
|
lexical
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())?
|
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())?
|
||||||
.search_with_options(&query, 50, &filters.unwrap_or_default().into())
|
.search_with_options(&query, 50, &options)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -290,19 +312,15 @@ async fn search_measured(
|
|||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
trace.end(queue, Default::default());
|
trace.end(queue, Default::default());
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
|
let options: SearchOptions = filters.unwrap_or_default().into();
|
||||||
|
validate_search_input(&query, &options)?;
|
||||||
let index = trace.timed("desktop.lexical_lock", None, |_, _| {
|
let index = trace.timed("desktop.lexical_lock", None, |_, _| {
|
||||||
lexical
|
lexical
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())
|
.map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned())
|
||||||
})?;
|
})?;
|
||||||
index
|
index
|
||||||
.search_with_options_traced(
|
.search_with_options_traced(&query, 50, &options, &mut trace, None)
|
||||||
&query,
|
|
||||||
50,
|
|
||||||
&filters.unwrap_or_default().into(),
|
|
||||||
&mut trace,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
})();
|
})();
|
||||||
measured_outcome(result, trace)
|
measured_outcome(result, trace)
|
||||||
@@ -841,4 +859,42 @@ mod tests {
|
|||||||
< preview_pixel_limit(Some(image::ImageFormat::Jpeg))
|
< preview_pixel_limit(Some(image::ImageFormat::Jpeg))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn native_exact_media_filters_preserve_fields_and_validate_empty_queries() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("track.ogg");
|
||||||
|
std::fs::write(&file, b"metadata storage fixture").unwrap();
|
||||||
|
let mut index = SearchIndex::open(":memory:").unwrap();
|
||||||
|
index.index_folder(dir.path()).unwrap();
|
||||||
|
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
|
||||||
|
let (bytes, modified) = index.file_signature(&path).unwrap().unwrap();
|
||||||
|
let revision = index.media_change_revision(&path, bytes, modified).unwrap();
|
||||||
|
let tags = std::collections::BTreeMap::from([
|
||||||
|
("artist".to_owned(), "Björk".to_owned()),
|
||||||
|
("title".to_owned(), "Jóga".to_owned()),
|
||||||
|
("comment".to_owned(), "artist: Other Artist".to_owned()),
|
||||||
|
]);
|
||||||
|
index
|
||||||
|
.set_structured_media_metadata_at_revision(
|
||||||
|
&path, &tags, 1000, bytes, modified, revision,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let filters: Filters =
|
||||||
|
serde_json::from_str(r#"{"media_artist":" BJÖRK ","media_title":"JÓGA"}"#).unwrap();
|
||||||
|
let mut options: SearchOptions = filters.into();
|
||||||
|
assert!(validate_search_input("", &options).is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
index.search_with_options("", 1, &options).unwrap()[0].filename,
|
||||||
|
"track.ogg"
|
||||||
|
);
|
||||||
|
options.media_artist = Some("Other Artist".into());
|
||||||
|
assert!(index
|
||||||
|
.search_with_options("", 1, &options)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
options.media_artist = Some(" ".into());
|
||||||
|
assert!(validate_search_input("", &options).is_err());
|
||||||
|
assert!(validate_search_input("", &SearchOptions::default()).is_err());
|
||||||
|
assert!(validate_search_input("song", &SearchOptions::default()).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+28
-18
@@ -15,6 +15,7 @@ import {
|
|||||||
LatestLane,
|
LatestLane,
|
||||||
semanticAvailable,
|
semanticAvailable,
|
||||||
scopedFilters,
|
scopedFilters,
|
||||||
|
hasSearchInput,
|
||||||
SEMANTIC_SETTLE_MS,
|
SEMANTIC_SETTLE_MS,
|
||||||
} from "./search-state";
|
} from "./search-state";
|
||||||
import type { SemanticPhase, SearchFilters } from "./search-state";
|
import type { SemanticPhase, SearchFilters } from "./search-state";
|
||||||
@@ -146,7 +147,7 @@ interface SearchRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Artist (exact)<input id="filter-artist" placeholder="Artist from music tags" maxlength="256"></label><label>Track title (exact)<input id="filter-title" placeholder="Title from music tags" maxlength="256"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
|
||||||
<main>
|
<main>
|
||||||
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
|
||||||
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
|
||||||
@@ -305,6 +306,13 @@ const automaticIndex = new AutomaticIndexGate();
|
|||||||
|
|
||||||
function refreshEmptySearchState() {
|
function refreshEmptySearchState() {
|
||||||
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
|
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
|
||||||
|
if (!latestSearch.query.trim()) {
|
||||||
|
element("empty").querySelector("h2")!.textContent =
|
||||||
|
"No matching music tags.";
|
||||||
|
element("empty").querySelector("p")!.textContent =
|
||||||
|
"Try the complete artist or track title from your files. Missing tags cannot match these filters.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
let phase: SemanticPhase;
|
let phase: SemanticPhase;
|
||||||
if (querySemanticFailed || localSemanticError || semanticStatus?.error)
|
if (querySemanticFailed || localSemanticError || semanticStatus?.error)
|
||||||
phase = "unavailable";
|
phase = "unavailable";
|
||||||
@@ -1042,6 +1050,8 @@ function captureFilters() {
|
|||||||
return scopedFilters(element<HTMLSelectElement>("filter-scope").value, {
|
return scopedFilters(element<HTMLSelectElement>("filter-scope").value, {
|
||||||
folder: input("filter-folder") || null,
|
folder: input("filter-folder") || null,
|
||||||
extension: input("filter-extension") || null,
|
extension: input("filter-extension") || null,
|
||||||
|
media_artist: input("filter-artist") || null,
|
||||||
|
media_title: input("filter-title") || null,
|
||||||
min_duration_ms: duration("filter-duration-min"),
|
min_duration_ms: duration("filter-duration-min"),
|
||||||
max_duration_ms: duration("filter-duration-max"),
|
max_duration_ms: duration("filter-duration-max"),
|
||||||
min_size: size("filter-min"),
|
min_size: size("filter-min"),
|
||||||
@@ -1236,7 +1246,7 @@ function refreshIndexedSearch(
|
|||||||
};
|
};
|
||||||
latestSearch = refreshed;
|
latestSearch = refreshed;
|
||||||
submitSearch(refreshed, "lexical");
|
submitSearch(refreshed, "lexical");
|
||||||
if (canSearchSemantically()) {
|
if (refreshed.query.trim() && canSearchSemantically()) {
|
||||||
hybridTimer = setTimeout(() => {
|
hybridTimer = setTimeout(() => {
|
||||||
if (isCurrent(refreshed)) submitSearch(refreshed, "hybrid");
|
if (isCurrent(refreshed)) submitSearch(refreshed, "hybrid");
|
||||||
}, SEMANTIC_SETTLE_MS);
|
}, SEMANTIC_SETTLE_MS);
|
||||||
@@ -1287,23 +1297,23 @@ async function search(inputAt = performance.now()) {
|
|||||||
querySemanticFailed = false;
|
querySemanticFailed = false;
|
||||||
shownLane = "lexical";
|
shownLane = "lexical";
|
||||||
const value = query.value.trim();
|
const value = query.value.trim();
|
||||||
if (!value) {
|
|
||||||
resultSelection.begin("");
|
|
||||||
element("performance-panel").hidden = true;
|
|
||||||
results = [];
|
|
||||||
displayedLiteralQuery = literalQuery("");
|
|
||||||
displayedRequestId = null;
|
|
||||||
selected = -1;
|
|
||||||
render();
|
|
||||||
element("result-summary").textContent = "READY WHEN YOU ARE";
|
|
||||||
element("empty").querySelector("h2")!.textContent =
|
|
||||||
"Find what matters. Locally.";
|
|
||||||
element("empty").querySelector("p")!.textContent =
|
|
||||||
"Index a folder, then search a filename, phrase, or what you remember.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const filters = captureFilters();
|
const filters = captureFilters();
|
||||||
|
if (!hasSearchInput(value, filters)) {
|
||||||
|
resultSelection.begin("");
|
||||||
|
element("performance-panel").hidden = true;
|
||||||
|
results = [];
|
||||||
|
displayedLiteralQuery = literalQuery("");
|
||||||
|
displayedRequestId = null;
|
||||||
|
selected = -1;
|
||||||
|
render();
|
||||||
|
element("result-summary").textContent = "READY WHEN YOU ARE";
|
||||||
|
element("empty").querySelector("h2")!.textContent =
|
||||||
|
"Find what matters. Locally.";
|
||||||
|
element("empty").querySelector("p")!.textContent =
|
||||||
|
"Index a folder, then search a filename, phrase, or what you remember.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
resultSelection.begin(JSON.stringify([value, filters]));
|
resultSelection.begin(JSON.stringify([value, filters]));
|
||||||
const request: SearchRequest = {
|
const request: SearchRequest = {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
@@ -1322,7 +1332,7 @@ async function search(inputAt = performance.now()) {
|
|||||||
element("result-summary").textContent = "SEARCHING…";
|
element("result-summary").textContent = "SEARCHING…";
|
||||||
submitSearch(request, "lexical");
|
submitSearch(request, "lexical");
|
||||||
hybridTimer = setTimeout(() => {
|
hybridTimer = setTimeout(() => {
|
||||||
if (isCurrent(request) && canSearchSemantically())
|
if (value && isCurrent(request) && canSearchSemantically())
|
||||||
submitSearch(request, "hybrid");
|
submitSearch(request, "hybrid");
|
||||||
}, SEMANTIC_SETTLE_MS);
|
}, SEMANTIC_SETTLE_MS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ export function scopedFilters(
|
|||||||
return Object.freeze({ ...filters, code_only: scope === "code" });
|
return Object.freeze({ ...filters, code_only: scope === "code" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exact music metadata can be searched without embedding an empty query. */
|
||||||
|
export function hasSearchInput(query: string, filters: SearchFilters): boolean {
|
||||||
|
return Boolean(
|
||||||
|
query.trim() ||
|
||||||
|
(typeof filters.media_artist === "string" &&
|
||||||
|
filters.media_artist.trim()) ||
|
||||||
|
(typeof filters.media_title === "string" && filters.media_title.trim()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Let rapid typing settle before starting the model lane. */
|
/** Let rapid typing settle before starting the model lane. */
|
||||||
export const SEMANTIC_SETTLE_MS = 120;
|
export const SEMANTIC_SETTLE_MS = 120;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { LatestLane, AutomaticIndexGate, emptySearchState, semanticAvailable, SEMANTIC_SETTLE_MS, scopedFilters } from '../src/search-state.ts';
|
import { LatestLane, AutomaticIndexGate, emptySearchState, semanticAvailable, SEMANTIC_SETTLE_MS, hasSearchInput, scopedFilters } from '../src/search-state.ts';
|
||||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
|
||||||
test('coalesces queued keystrokes and discards stale responses', async () => {
|
test('coalesces queued keystrokes and discards stale responses', async () => {
|
||||||
@@ -107,3 +107,11 @@ test('same-query scope change discards old lane output and runs latest scoped re
|
|||||||
assert.equal(accepted[0].filters.code_only, true);
|
assert.equal(accepted[0].filters.code_only, true);
|
||||||
assert.equal(accepted[0].filters.extension, 'rs');
|
assert.equal(accepted[0].filters.extension, 'rs');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('exact music fields allow metadata-only searches without general filter browsing', () => {
|
||||||
|
assert.equal(hasSearchInput('', scopedFilters('all', {media_artist: ' Artist '})), true);
|
||||||
|
assert.equal(hasSearchInput('', scopedFilters('all', {media_title: 'Song'})), true);
|
||||||
|
assert.equal(hasSearchInput(' ', scopedFilters('all', {media_artist: ' ', extension: 'mp3'})), false);
|
||||||
|
assert.equal(hasSearchInput('violin', scopedFilters('all', {})), true);
|
||||||
|
});
|
||||||
@@ -52,6 +52,7 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
db: PathBuf,
|
db: PathBuf,
|
||||||
/// Search text; quote phrases containing spaces in your shell.
|
/// Search text; quote phrases containing spaces in your shell.
|
||||||
|
#[arg(default_value = "")]
|
||||||
query: String,
|
query: String,
|
||||||
/// Maximum number of returned results (1–1000).
|
/// Maximum number of returned results (1–1000).
|
||||||
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
||||||
@@ -74,6 +75,12 @@ enum Command {
|
|||||||
/// File extension, with or without the leading dot.
|
/// File extension, with or without the leading dot.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
extension: Option<String>,
|
extension: Option<String>,
|
||||||
|
/// Exact artist tag; unrelated semantic neighbours cannot bypass it.
|
||||||
|
#[arg(long)]
|
||||||
|
media_artist: Option<String>,
|
||||||
|
/// Exact track/media title tag.
|
||||||
|
#[arg(long)]
|
||||||
|
media_title: Option<String>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
min_size: Option<u64>,
|
min_size: Option<u64>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -171,6 +178,8 @@ fn main() -> Result<()> {
|
|||||||
folder,
|
folder,
|
||||||
code_only,
|
code_only,
|
||||||
extension,
|
extension,
|
||||||
|
media_artist,
|
||||||
|
media_title,
|
||||||
min_size,
|
min_size,
|
||||||
max_size,
|
max_size,
|
||||||
min_duration_ms,
|
min_duration_ms,
|
||||||
@@ -181,14 +190,16 @@ fn main() -> Result<()> {
|
|||||||
embedding_worker,
|
embedding_worker,
|
||||||
cpu_threads,
|
cpu_threads,
|
||||||
} => {
|
} => {
|
||||||
if query.trim().is_empty() {
|
if query.trim().is_empty() && media_artist.is_none() && media_title.is_none() {
|
||||||
bail!("Search text cannot be empty");
|
bail!("Supply search text or an exact media artist/title constraint");
|
||||||
}
|
}
|
||||||
require_existing_index(&db)?;
|
require_existing_index(&db)?;
|
||||||
let options = SearchOptions {
|
let options = SearchOptions {
|
||||||
folder,
|
folder,
|
||||||
code_only,
|
code_only,
|
||||||
extension,
|
extension,
|
||||||
|
media_artist,
|
||||||
|
media_title,
|
||||||
min_size,
|
min_size,
|
||||||
max_size,
|
max_size,
|
||||||
min_duration_ms,
|
min_duration_ms,
|
||||||
@@ -201,7 +212,10 @@ fn main() -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
MetricsMode::Basic
|
MetricsMode::Basic
|
||||||
});
|
});
|
||||||
let results = if let (Some(model), Some(runtime)) = (model_dir, embedding_worker) {
|
let semantic_assets = model_dir
|
||||||
|
.zip(embedding_worker)
|
||||||
|
.filter(|_| !query.trim().is_empty());
|
||||||
|
let results = if let Some((model, runtime)) = semantic_assets {
|
||||||
let mut service = trace.timed("cli.session_initialize", None, |_, _| {
|
let mut service = trace.timed("cli.session_initialize", None, |_, _| {
|
||||||
HybridSearch::open_query(&db, model, runtime, usize::from(cpu_threads))
|
HybridSearch::open_query(&db, model, runtime, usize::from(cpu_threads))
|
||||||
})?;
|
})?;
|
||||||
@@ -393,4 +407,62 @@ mod tests {
|
|||||||
}
|
}
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn exact_media_cli_filters_match_real_metadata_without_query_or_model() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("track.ogg");
|
||||||
|
std::fs::write(&file, b"metadata storage fixture").unwrap();
|
||||||
|
let mut index = SearchIndex::open(":memory:").unwrap();
|
||||||
|
index.index_folder(dir.path()).unwrap();
|
||||||
|
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
|
||||||
|
let (bytes, modified) = index.file_signature(&path).unwrap().unwrap();
|
||||||
|
let revision = index.media_change_revision(&path, bytes, modified).unwrap();
|
||||||
|
let tags = std::collections::BTreeMap::from([
|
||||||
|
("artist".to_owned(), "Björk".to_owned()),
|
||||||
|
("title".to_owned(), "Jóga".to_owned()),
|
||||||
|
("comment".to_owned(), "artist: Other Artist".to_owned()),
|
||||||
|
]);
|
||||||
|
index
|
||||||
|
.set_structured_media_metadata_at_revision(
|
||||||
|
&path, &tags, 1000, bytes, modified, revision,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"searchi",
|
||||||
|
"search",
|
||||||
|
"--db",
|
||||||
|
"index.db",
|
||||||
|
"--media-artist",
|
||||||
|
" BJÖRK ",
|
||||||
|
"--media-title",
|
||||||
|
"JÓGA",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let Command::Search {
|
||||||
|
query,
|
||||||
|
media_artist,
|
||||||
|
media_title,
|
||||||
|
..
|
||||||
|
} = cli.command
|
||||||
|
else {
|
||||||
|
panic!("search command");
|
||||||
|
};
|
||||||
|
assert!(query.is_empty());
|
||||||
|
let mut options = SearchOptions {
|
||||||
|
media_artist,
|
||||||
|
media_title,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
index.search_with_options(&query, 1, &options).unwrap()[0].filename,
|
||||||
|
"track.ogg"
|
||||||
|
);
|
||||||
|
options.media_artist = Some("Other Artist".into());
|
||||||
|
assert!(index
|
||||||
|
.search_with_options(&query, 1, &options)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
options.media_artist = Some(" ".into());
|
||||||
|
assert!(index.search_with_options(&query, 1, &options).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,8 @@ use tokio_util::codec::{FramedRead, FramedWrite};
|
|||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct SearchInput {
|
struct SearchInput {
|
||||||
/// Filename, content or remembered meaning. At most 4096 UTF-8 bytes.
|
/// Filename, content or remembered meaning. At most 4096 UTF-8 bytes;
|
||||||
|
/// may be empty when an exact media_artist/media_title constraint is supplied.
|
||||||
query: String,
|
query: String,
|
||||||
|
|
||||||
/// Maximum returned files, 1–100; default 10.
|
/// Maximum returned files, 1–100; default 10.
|
||||||
@@ -43,6 +44,10 @@ struct SearchInput {
|
|||||||
code_only: bool,
|
code_only: bool,
|
||||||
|
|
||||||
extension: Option<String>,
|
extension: Option<String>,
|
||||||
|
/// Exact full artist tag, case-insensitive; at most 1024 UTF-8 bytes.
|
||||||
|
media_artist: Option<String>,
|
||||||
|
/// Exact full media title tag, case-insensitive; at most 1024 UTF-8 bytes.
|
||||||
|
media_title: Option<String>,
|
||||||
|
|
||||||
min_size: Option<u64>,
|
min_size: Option<u64>,
|
||||||
|
|
||||||
@@ -132,9 +137,10 @@ impl AgentServer {
|
|||||||
|
|
||||||
ensure!(
|
ensure!(
|
||||||
|
|
||||||
!input.query.trim().is_empty() && input.query.len() <= 4096,
|
input.query.len() <= 4096 && (!input.query.trim().is_empty()
|
||||||
|
|| input.media_artist.is_some() || input.media_title.is_some()),
|
||||||
|
|
||||||
"Query must contain 1–4096 UTF-8 bytes"
|
"Supply search text (at most 4096 UTF-8 bytes) or an exact media artist/title constraint"
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -174,6 +180,8 @@ impl AgentServer {
|
|||||||
code_only: input.code_only,
|
code_only: input.code_only,
|
||||||
|
|
||||||
extension: input.extension,
|
extension: input.extension,
|
||||||
|
media_artist: input.media_artist,
|
||||||
|
media_title: input.media_title,
|
||||||
|
|
||||||
min_size: input.min_size,
|
min_size: input.min_size,
|
||||||
|
|
||||||
@@ -189,7 +197,12 @@ impl AgentServer {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (mode, response) = if let Some(hybrid) = state.hybrid.as_mut() {
|
let semantic_available = state.hybrid.is_some();
|
||||||
|
let (mode, response) = if input.query.trim().is_empty() {
|
||||||
|
("metadata_only", state.index.search_with_options_measured(
|
||||||
|
&input.query, usize::from(input.limit), &options, false,
|
||||||
|
)?)
|
||||||
|
} else if let Some(hybrid) = state.hybrid.as_mut() {
|
||||||
|
|
||||||
("hybrid", hybrid.search_measured(
|
("hybrid", hybrid.search_measured(
|
||||||
|
|
||||||
@@ -278,7 +291,7 @@ impl AgentServer {
|
|||||||
"mode": mode,
|
"mode": mode,
|
||||||
"code_only": input.code_only,
|
"code_only": input.code_only,
|
||||||
|
|
||||||
"semantic_available": mode == "hybrid",
|
"semantic_available": semantic_available,
|
||||||
|
|
||||||
"results": results,
|
"results": results,
|
||||||
|
|
||||||
@@ -513,3 +526,62 @@ pub fn serve(
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn metadata_only_mcp_filters_real_fields_and_rejects_empty_constraints() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file = dir.path().join("track.ogg");
|
||||||
|
std::fs::write(&file, b"metadata storage fixture").unwrap();
|
||||||
|
let mut index = SearchIndex::open(":memory:").unwrap();
|
||||||
|
index.index_folder(dir.path()).unwrap();
|
||||||
|
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
|
||||||
|
let (bytes, modified) = index.file_signature(&path).unwrap().unwrap();
|
||||||
|
let revision = index.media_change_revision(&path, bytes, modified).unwrap();
|
||||||
|
let tags = std::collections::BTreeMap::from([
|
||||||
|
("artist".to_owned(), "Björk".to_owned()),
|
||||||
|
("title".to_owned(), "Jóga".to_owned()),
|
||||||
|
("comment".to_owned(), "artist: Other Artist".to_owned()),
|
||||||
|
]);
|
||||||
|
index
|
||||||
|
.set_structured_media_metadata_at_revision(
|
||||||
|
&path, &tags, 1000, bytes, modified, revision,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let server = AgentServer {
|
||||||
|
state: Arc::new(Mutex::new(State {
|
||||||
|
index,
|
||||||
|
hybrid: None,
|
||||||
|
handles: BTreeMap::new(),
|
||||||
|
recent: VecDeque::new(),
|
||||||
|
})),
|
||||||
|
gate: Arc::new(tokio::sync::Semaphore::new(1)),
|
||||||
|
};
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
for (input, expected) in [
|
||||||
|
(
|
||||||
|
json!({"query":"", "media_artist":" BJÖRK ", "media_title":"JÓGA"}),
|
||||||
|
Some(1),
|
||||||
|
),
|
||||||
|
(json!({"query":"", "media_artist":"Other Artist"}), Some(0)),
|
||||||
|
(json!({"query":"", "media_artist":" "}), None),
|
||||||
|
(json!({"query":""}), None),
|
||||||
|
] {
|
||||||
|
let response =
|
||||||
|
runtime.block_on(server.search(Parameters(serde_json::from_value(input).unwrap())));
|
||||||
|
if let Some(count) = expected {
|
||||||
|
let data = response.structured_content.unwrap();
|
||||||
|
assert_eq!(data["mode"], "metadata_only");
|
||||||
|
assert_eq!(data["semantic_available"], false);
|
||||||
|
assert_eq!(data["results"].as_array().unwrap().len(), count);
|
||||||
|
} else {
|
||||||
|
assert_eq!(response.is_error, Some(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user