348c2bc9bc
Stop a running daemon during uninstall, fuzzy-match cached app names, and pace remote sequences with a configurable delay so Roku secret screen sequences behave more reliably.
284 lines
8.9 KiB
Rust
284 lines
8.9 KiB
Rust
use std::{cmp::Reverse, collections::BTreeMap, path::PathBuf};
|
|
|
|
use tokio::fs;
|
|
|
|
use crate::adapters::AppInfo;
|
|
|
|
/// A platform-level cache of app metadata discovered from live devices.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AppCache {
|
|
/// The normalized platform identifier for the cache file.
|
|
pub platform: String,
|
|
/// The apps currently known for that platform.
|
|
pub apps: Vec<AppInfo>,
|
|
}
|
|
|
|
/// Persisted store for per-platform app cache files.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppCacheStore {
|
|
root_dir: PathBuf,
|
|
}
|
|
|
|
impl AppCacheStore {
|
|
/// Create a cache store rooted at the given directory.
|
|
pub fn new(root_dir: PathBuf) -> Self {
|
|
Self { root_dir }
|
|
}
|
|
|
|
/// Load the app cache for a single platform or return an empty cache when absent.
|
|
pub async fn load_platform(&self, platform: &str) -> anyhow::Result<AppCache> {
|
|
let path = self.platform_path(platform);
|
|
let apps = match fs::read_to_string(&path).await {
|
|
Ok(contents) => serde_json::from_str::<Vec<AppInfo>>(&contents)?,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
|
Err(error) => return Err(error.into()),
|
|
};
|
|
|
|
Ok(AppCache {
|
|
platform: platform.to_string(),
|
|
apps,
|
|
})
|
|
}
|
|
|
|
/// Persist the app list for a platform.
|
|
pub async fn save_platform(&self, platform: &str, apps: &[AppInfo]) -> anyhow::Result<()> {
|
|
fs::create_dir_all(&self.root_dir).await?;
|
|
let contents = serde_json::to_string_pretty(apps)?;
|
|
fs::write(self.platform_path(platform), contents).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Merge newly seen apps into the normalized, de-duplicated platform cache.
|
|
pub async fn record_platform_apps(
|
|
&self,
|
|
platform: &str,
|
|
apps: Vec<AppInfo>,
|
|
) -> anyhow::Result<AppCache> {
|
|
let mut deduped = BTreeMap::new();
|
|
for app in self.load_platform(platform).await?.apps {
|
|
deduped.insert(app.platform_id.clone(), app);
|
|
}
|
|
for app in apps {
|
|
deduped.insert(app.platform_id.clone(), app);
|
|
}
|
|
let apps: Vec<AppInfo> = deduped.into_values().collect();
|
|
self.save_platform(platform, &apps).await?;
|
|
Ok(AppCache {
|
|
platform: platform.to_string(),
|
|
apps,
|
|
})
|
|
}
|
|
|
|
/// 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<Option<AppInfo>> {
|
|
let cache = self.load_platform(platform).await?;
|
|
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.
|
|
pub async fn clear_platform(&self, platform: &str) -> anyhow::Result<()> {
|
|
match fs::remove_file(self.platform_path(platform)).await {
|
|
Ok(()) => Ok(()),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(error) => Err(error.into()),
|
|
}
|
|
}
|
|
|
|
fn platform_path(&self, platform: &str) -> PathBuf {
|
|
self.root_dir.join(format!("{platform}.apps.json"))
|
|
}
|
|
}
|
|
|
|
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<u8> {
|
|
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::*;
|
|
|
|
#[tokio::test]
|
|
async fn cache_round_trips_and_resolves_names() {
|
|
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
|
let store = AppCacheStore::new(temp_dir.path().join("cache"));
|
|
let apps = vec![
|
|
AppInfo {
|
|
id: "12".to_string(),
|
|
name: "Netflix".to_string(),
|
|
version: None,
|
|
platform_id: "12".to_string(),
|
|
},
|
|
AppInfo {
|
|
id: "837".to_string(),
|
|
name: "YouTube".to_string(),
|
|
version: None,
|
|
platform_id: "837".to_string(),
|
|
},
|
|
];
|
|
|
|
store
|
|
.record_platform_apps("roku", apps.clone())
|
|
.await
|
|
.expect("apps should save");
|
|
|
|
let loaded = store.load_platform("roku").await.expect("apps should load");
|
|
assert_eq!(loaded.apps, apps);
|
|
|
|
let resolved = store
|
|
.find_app("roku", "youtube")
|
|
.await
|
|
.expect("app lookup should work")
|
|
.expect("youtube should exist");
|
|
assert_eq!(resolved.platform_id, "837");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn record_platform_apps_merges_existing_entries() {
|
|
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: "12".to_string(),
|
|
name: "Netflix".to_string(),
|
|
version: None,
|
|
platform_id: "12".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("first cache should save");
|
|
store
|
|
.record_platform_apps(
|
|
"roku",
|
|
vec![AppInfo {
|
|
id: "837".to_string(),
|
|
name: "YouTube".to_string(),
|
|
version: None,
|
|
platform_id: "837".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("second cache should merge");
|
|
|
|
let loaded = store.load_platform("roku").await.expect("apps should load");
|
|
assert_eq!(loaded.apps.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_platform_removes_persisted_cache() {
|
|
let temp_dir = tempfile::tempdir().expect("temp dir should exist");
|
|
let store = AppCacheStore::new(temp_dir.path().join("cache"));
|
|
|
|
store
|
|
.save_platform(
|
|
"roku",
|
|
&[AppInfo {
|
|
id: "12".to_string(),
|
|
name: "Netflix".to_string(),
|
|
version: None,
|
|
platform_id: "12".to_string(),
|
|
}],
|
|
)
|
|
.await
|
|
.expect("apps should save");
|
|
store
|
|
.clear_platform("roku")
|
|
.await
|
|
.expect("cache should clear");
|
|
|
|
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");
|
|
}
|
|
}
|