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
@@ -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<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)]
|
||||
min_size: Option<u64>,
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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<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>,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user