diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 0e066e7..75cd078 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -228,6 +228,8 @@ struct Filters { code_only: bool, folder: Option, extension: Option, + media_artist: Option, + media_title: Option, min_size: Option, max_size: Option, min_duration_ms: Option, @@ -242,6 +244,8 @@ impl From for SearchOptions { code_only: value.code_only, folder: value.folder, extension: value.extension, + media_artist: value.media_artist, + media_title: value.media_title, min_size: value.min_size, max_size: value.max_size, min_duration_ms: value.min_duration_ms, @@ -253,18 +257,36 @@ impl From for SearchOptions { } // 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] async fn search( query: String, filters: Option, state: State<'_, AppState>, ) -> Result, String> { + let options: SearchOptions = filters.unwrap_or_default().into(); + validate_search_input(&query, &options)?; let lexical = state.lexical.clone(); tauri::async_runtime::spawn_blocking(move || { lexical .try_lock() .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()) }) .await @@ -290,19 +312,15 @@ async fn search_measured( tauri::async_runtime::spawn_blocking(move || { trace.end(queue, Default::default()); let result = (|| { + let options: SearchOptions = filters.unwrap_or_default().into(); + validate_search_input(&query, &options)?; let index = trace.timed("desktop.lexical_lock", None, |_, _| { lexical .try_lock() .map_err(|_| "Keyword worker is busy; try the latest query again.".to_owned()) })?; index - .search_with_options_traced( - &query, - 50, - &filters.unwrap_or_default().into(), - &mut trace, - None, - ) + .search_with_options_traced(&query, 50, &options, &mut trace, None) .map_err(|error| error.to_string()) })(); measured_outcome(result, trace) @@ -841,4 +859,42 @@ mod tests { < 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()); + } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9f32c71..5a51a9a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,6 +15,7 @@ import { LatestLane, semanticAvailable, scopedFilters, + hasSearchInput, SEMANTIC_SETTLE_MS, } from "./search-state"; import type { SemanticPhase, SearchFilters } from "./search-state"; @@ -146,7 +147,7 @@ interface SearchRequest { } document.querySelector("#app")!.innerHTML = ` -
searchi
Loading your index…
${icon("filter")}All files${icon("chevron")}

Filter your search

${icon("settings")}

Settings

Appearance

90%

A subtle window background. Search results and passages stay clear.

Folders & resources

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.

    Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.

    Optional AI answers

    Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.

    Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.

    +
    searchi
    Loading your index…
    ${icon("filter")}All files${icon("chevron")}

    Filter your search

    ${icon("settings")}

    Settings

    Appearance

    90%

    A subtle window background. Search results and passages stay clear.

    Folders & resources

    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.

      Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.

      Optional AI answers

      Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.

      Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.

      ${icon("sparkles")}Preparing local semantic search…Local model${icon("chevron")}
      Google EmbeddingGemma 2

      The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.

      @@ -305,6 +306,13 @@ const automaticIndex = new AutomaticIndexGate(); function refreshEmptySearchState() { 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; if (querySemanticFailed || localSemanticError || semanticStatus?.error) phase = "unavailable"; @@ -1042,6 +1050,8 @@ function captureFilters() { return scopedFilters(element("filter-scope").value, { folder: input("filter-folder") || 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"), max_duration_ms: duration("filter-duration-max"), min_size: size("filter-min"), @@ -1236,7 +1246,7 @@ function refreshIndexedSearch( }; latestSearch = refreshed; submitSearch(refreshed, "lexical"); - if (canSearchSemantically()) { + if (refreshed.query.trim() && canSearchSemantically()) { hybridTimer = setTimeout(() => { if (isCurrent(refreshed)) submitSearch(refreshed, "hybrid"); }, SEMANTIC_SETTLE_MS); @@ -1287,23 +1297,23 @@ async function search(inputAt = performance.now()) { querySemanticFailed = false; shownLane = "lexical"; 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 { 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])); const request: SearchRequest = { id: requestId, @@ -1322,7 +1332,7 @@ async function search(inputAt = performance.now()) { element("result-summary").textContent = "SEARCHING…"; submitSearch(request, "lexical"); hybridTimer = setTimeout(() => { - if (isCurrent(request) && canSearchSemantically()) + if (value && isCurrent(request) && canSearchSemantically()) submitSearch(request, "hybrid"); }, SEMANTIC_SETTLE_MS); } catch (error) { diff --git a/apps/desktop/src/search-state.ts b/apps/desktop/src/search-state.ts index 1042308..c20f87b 100644 --- a/apps/desktop/src/search-state.ts +++ b/apps/desktop/src/search-state.ts @@ -11,6 +11,16 @@ export function scopedFilters( 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. */ export const SEMANTIC_SETTLE_MS = 120; diff --git a/apps/desktop/tests/search-state.test.mjs b/apps/desktop/tests/search-state.test.mjs index cca4b21..6521c70 100644 --- a/apps/desktop/tests/search-state.test.mjs +++ b/apps/desktop/tests/search-state.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; 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)); 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.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); +}); diff --git a/crates/searchi-cli/src/main.rs b/crates/searchi-cli/src/main.rs index bd87f65..654007f 100644 --- a/crates/searchi-cli/src/main.rs +++ b/crates/searchi-cli/src/main.rs @@ -52,6 +52,7 @@ enum Command { #[arg(long)] db: PathBuf, /// Search text; quote phrases containing spaces in your shell. + #[arg(default_value = "")] query: String, /// Maximum number of returned results (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. #[arg(long)] extension: Option, + /// Exact artist tag; unrelated semantic neighbours cannot bypass it. + #[arg(long)] + media_artist: Option, + /// Exact track/media title tag. + #[arg(long)] + media_title: Option, #[arg(long)] min_size: Option, #[arg(long)] @@ -171,6 +178,8 @@ fn main() -> Result<()> { folder, code_only, extension, + media_artist, + media_title, min_size, max_size, min_duration_ms, @@ -181,14 +190,16 @@ fn main() -> Result<()> { embedding_worker, cpu_threads, } => { - if query.trim().is_empty() { - bail!("Search text cannot be empty"); + if query.trim().is_empty() && media_artist.is_none() && media_title.is_none() { + bail!("Supply search text or an exact media artist/title constraint"); } require_existing_index(&db)?; let options = SearchOptions { folder, code_only, extension, + media_artist, + media_title, min_size, max_size, min_duration_ms, @@ -201,7 +212,10 @@ fn main() -> Result<()> { } else { 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, |_, _| { 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()); + } } diff --git a/crates/searchi-cli/src/mcp.rs b/crates/searchi-cli/src/mcp.rs index 97b5db5..e66a5f4 100644 --- a/crates/searchi-cli/src/mcp.rs +++ b/crates/searchi-cli/src/mcp.rs @@ -29,7 +29,8 @@ use tokio_util::codec::{FramedRead, FramedWrite}; #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] 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, /// Maximum returned files, 1–100; default 10. @@ -43,6 +44,10 @@ struct SearchInput { code_only: bool, extension: Option, + /// Exact full artist tag, case-insensitive; at most 1024 UTF-8 bytes. + media_artist: Option, + /// Exact full media title tag, case-insensitive; at most 1024 UTF-8 bytes. + media_title: Option, min_size: Option, @@ -132,9 +137,10 @@ impl AgentServer { 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, extension: input.extension, + media_artist: input.media_artist, + media_title: input.media_title, 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( @@ -278,7 +291,7 @@ impl AgentServer { "mode": mode, "code_only": input.code_only, - "semantic_available": mode == "hybrid", + "semantic_available": semantic_available, "results": results, @@ -513,3 +526,62 @@ pub fn serve( 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)); + } + } + } +}