diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8426a76..a03d7ec 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -25,6 +25,7 @@ use crate::{ const DAEMON_START_WAIT_ATTEMPTS: usize = 20; const DAEMON_START_WAIT_INTERVAL: Duration = Duration::from_millis(250); +const DEFAULT_REMOTE_SEQUENCE_DELAY_MS: u64 = 200; /// The tvctl command-line interface. #[derive(Debug, Parser)] @@ -182,6 +183,9 @@ pub enum RemoteCommand { Sequence { /// Key names such as `home down select`. keys: Vec, + /// Delay between keys in milliseconds. + #[arg(long, default_value_t = DEFAULT_REMOTE_SEQUENCE_DELAY_MS)] + delay_ms: u64, }, } @@ -450,7 +454,7 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), let result: ActionResult = parse_response_data(response)?; render(cli, &result, || result.detail.clone()) } - RemoteCommand::Sequence { keys } => { + RemoteCommand::Sequence { keys, delay_ms } => { if keys.is_empty() { return Err(CliError::new( "At least one key is required for `tvctl remote sequence`.", @@ -466,6 +470,7 @@ async fn handle_remote_command(cli: &Cli, command: RemoteCommand) -> Result<(), &DaemonRequest::SendSequence { device: cli.device.clone(), keys: parsed, + delay_ms, }, ) .await?; @@ -730,6 +735,9 @@ async fn daemon_install(cli: &Cli) -> Result<(), CliError> { } async fn daemon_uninstall(cli: &Cli) -> Result<(), CliError> { + if daemon_status_payload().await.is_some() { + daemon_stop(cli).await?; + } let unit_path = systemd_unit_path(); let _ = run_systemctl(&["--user", "disable", "--now", "tvctld.service"]).await; match fs::remove_file(&unit_path).await { diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs index 647fd35..fc1e8cf 100644 --- a/src/daemon/cache.rs +++ b/src/daemon/cache.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, path::PathBuf}; +use std::{cmp::Reverse, collections::BTreeMap, path::PathBuf}; use tokio::fs; @@ -72,12 +72,20 @@ impl AppCacheStore { /// Resolve an app by case-insensitive name or exact ID from the persisted platform cache. pub async fn find_app(&self, platform: &str, query: &str) -> anyhow::Result> { let cache = self.load_platform(platform).await?; - let normalized = query.to_ascii_lowercase(); - Ok(cache.apps.into_iter().find(|app| { - app.platform_id == query - || app.id == query - || app.name.to_ascii_lowercase() == normalized - })) + let normalized = normalize_query(query); + + if let Some(app) = cache.apps.iter().find(|app| { + app.platform_id == query || app.id == query || normalize_query(&app.name) == normalized + }) { + return Ok(Some(app.clone())); + } + + Ok(cache + .apps + .into_iter() + .filter_map(|app| fuzzy_match_score(&normalized, &app).map(|score| (score, app))) + .max_by_key(|(score, app)| (*score, Reverse(app.name.len()))) + .map(|(_, app)| app)) } /// Remove the persisted app cache for a platform. @@ -94,6 +102,52 @@ impl AppCacheStore { } } +fn normalize_query(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn fuzzy_match_score(query: &str, app: &AppInfo) -> Option { + if query.is_empty() { + return None; + } + + let name = normalize_query(&app.name); + let id = normalize_query(&app.id); + let platform_id = normalize_query(&app.platform_id); + + if name.starts_with(query) || id.starts_with(query) || platform_id.starts_with(query) { + return Some(3); + } + if name.contains(query) || id.contains(query) || platform_id.contains(query) { + return Some(2); + } + if is_subsequence(query, &name) { + return Some(1); + } + + None +} + +fn is_subsequence(needle: &str, haystack: &str) -> bool { + let mut needle_chars = needle.chars(); + let mut current = needle_chars.next(); + + for hay in haystack.chars() { + if current == Some(hay) { + current = needle_chars.next(); + if current.is_none() { + return true; + } + } + } + + false +} + #[cfg(test)] mod tests { use super::*; @@ -192,4 +246,38 @@ mod tests { let loaded = store.load_platform("roku").await.expect("apps should load"); assert!(loaded.apps.is_empty()); } + + #[tokio::test] + async fn find_app_supports_fuzzy_name_matches() { + let temp_dir = tempfile::tempdir().expect("temp dir should exist"); + let store = AppCacheStore::new(temp_dir.path().join("cache")); + + store + .record_platform_apps( + "roku", + vec![ + AppInfo { + id: "592369".to_string(), + name: "Jellyfin".to_string(), + version: None, + platform_id: "592369".to_string(), + }, + AppInfo { + id: "12".to_string(), + name: "Netflix".to_string(), + version: None, + platform_id: "12".to_string(), + }, + ], + ) + .await + .expect("apps should save"); + + let resolved = store + .find_app("roku", "jelly") + .await + .expect("lookup should work") + .expect("jellyfin should resolve"); + assert_eq!(resolved.name, "Jellyfin"); + } } diff --git a/src/daemon/ipc.rs b/src/daemon/ipc.rs index 13435c0..704d119 100644 --- a/src/daemon/ipc.rs +++ b/src/daemon/ipc.rs @@ -87,6 +87,8 @@ pub enum DaemonRequest { device: Option, /// Normalized key identifiers. keys: Vec, + /// Delay between keys in milliseconds. + delay_ms: u64, }, /// Install a dev package from a local zip path on one device. DevInstall { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 4002cb4..e61a0aa 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -25,7 +25,7 @@ use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{UnixListener, UnixStream}, sync::Mutex, - time::{self, MissedTickBehavior}, + time::{self, MissedTickBehavior, sleep}, }; use tracing::warn; @@ -596,14 +596,18 @@ async fn handle_request( ), } } - DaemonRequest::SendSequence { device, keys } => { + DaemonRequest::SendSequence { + device, + keys, + delay_ms, + } => { let guard = daemon.lock().await; let device = match resolve_target_device(&guard.registry, device.as_deref()) { Ok(device) => device, Err(response) => return (response, false), }; - let detail = format!("Sent {} key(s).", keys.len()); - match guard.adapters.sequence(&device, keys).await { + let detail = format!("Sent {} key(s) with {} ms spacing.", keys.len(), delay_ms); + match send_key_sequence(&guard.adapters, &device, keys, delay_ms).await { Ok(()) => ( DaemonResponse::success(ActionResult { device, detail }), false, @@ -759,6 +763,22 @@ async fn handle_request( } } +async fn send_key_sequence( + adapters: &AdapterRegistry, + device: &Device, + keys: Vec, + delay_ms: u64, +) -> anyhow::Result<()> { + let mut pending = keys.into_iter().peekable(); + while let Some(key) = pending.next() { + adapters.key(device, key).await?; + if delay_ms > 0 && pending.peek().is_some() { + sleep(Duration::from_millis(delay_ms)).await; + } + } + Ok(()) +} + async fn run_discovery(daemon: &mut Daemon) -> anyhow::Result> { let discovery = daemon.discovery.clone(); let devices = discovery.discover_all(&mut daemon.registry).await?;