use super::images::ImageCache; use super::models::{ infer_library_item_kind, GameCandidate, GameMetadata, GameSource, MetadataStatus, }; use serde_json::Value; pub mod providers { pub mod igdb { use crate::library::metadata::MetadataProvider; #[allow(dead_code)] pub trait IgdbMetadataProvider: MetadataProvider {} } pub mod rawg { use crate::library::metadata::MetadataProvider; #[allow(dead_code)] pub trait RawgMetadataProvider: MetadataProvider {} } pub mod steam { use crate::library::metadata::MetadataProvider; #[allow(dead_code)] pub trait SteamMetadataProvider: MetadataProvider {} } } pub trait MetadataProvider: Send + Sync { #[allow(dead_code)] fn provider_id(&self) -> &'static str; fn resolve(&self, candidate: &GameCandidate) -> Result, String>; } pub struct LocalCacheMetadataProvider { image_cache: ImageCache, } impl LocalCacheMetadataProvider { pub fn new(image_cache: ImageCache) -> Self { Self { image_cache } } } impl MetadataProvider for LocalCacheMetadataProvider { fn provider_id(&self) -> &'static str { "local_cache" } fn resolve(&self, candidate: &GameCandidate) -> Result, String> { if candidate.source == GameSource::Steam { return self.resolve_steam(candidate); } Ok(None) } } impl LocalCacheMetadataProvider { fn resolve_steam(&self, candidate: &GameCandidate) -> Result, String> { let Some(app_id) = candidate.app_id.as_deref() else { return Ok(None); }; let store_data = fetch_steam_store_data(app_id)?; let title = store_data .as_ref() .and_then(|value| value.get("name")) .and_then(Value::as_str) .map(ToString::to_string) .or_else(|| Some(candidate.title.clone())); let description = store_data .as_ref() .and_then(|value| value.get("short_description")) .and_then(Value::as_str) .map(ToString::to_string); let genres = store_data .as_ref() .and_then(|value| value.get("genres")) .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| item.get("description").and_then(Value::as_str)) .map(ToString::to_string) .collect::>() }) .unwrap_or_default(); let steam_categories = store_data .as_ref() .and_then(|value| value.get("categories")) .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| item.get("description").and_then(Value::as_str)) .map(ToString::to_string) .collect::>() }) .unwrap_or_default(); let steam_app_type = store_data .as_ref() .and_then(|value| value.get("type")) .and_then(Value::as_str) .map(ToString::to_string); let app_kind = infer_library_item_kind( steam_app_type.as_deref(), title.as_deref(), &genres, &steam_categories, ); let release_date = store_data .as_ref() .and_then(|value| value.get("release_date")) .and_then(|value| value.get("date")) .and_then(Value::as_str) .map(ToString::to_string); let developer = joined_string_array(store_data.as_ref(), "developers"); let publisher = joined_string_array(store_data.as_ref(), "publishers"); let header_image = store_data .as_ref() .and_then(|value| value.get("header_image")) .and_then(Value::as_str) .map(ToString::to_string); let source = candidate.source.as_str(); let cover_image = self.image_cache.cache_first_remote_image( source, app_id, "cover", "jpg", &[ format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/library_600x900.jpg"), format!("https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/{app_id}/library_600x900.jpg"), format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_616x353.jpg"), ], )?; let hero_urls = [ Some(format!( "https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/library_hero.jpg" )), header_image.clone(), Some(format!( "https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/header.jpg" )), ] .into_iter() .flatten() .collect::>(); let hero_image = self .image_cache .cache_first_remote_image(source, app_id, "hero", "jpg", &hero_urls)?; let icon_image = self.image_cache.cache_first_remote_image( source, app_id, "icon", "jpg", &[ format!( "https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/capsule_184x69.jpg" ), format!("https://cdn.cloudflare.steamstatic.com/steam/apps/{app_id}/header.jpg"), ], )?; Ok(Some(GameMetadata { title, description, app_kind, steam_app_type, genres, steam_categories, release_date, developer, publisher, cover_image, hero_image, icon_image, status: MetadataStatus::Matched, })) } } fn fetch_steam_store_data(app_id: &str) -> Result, String> { let url = format!("https://store.steampowered.com/api/appdetails?appids={app_id}"); let response = match ureq::get(&url) .header("User-Agent", "NebulaOS/0.1 library-scanner") .call() { Ok(response) => response, Err(_) => return Ok(None), }; if !response.status().is_success() { return Ok(None); } let mut response = response; let body = response .body_mut() .read_to_string() .map_err(|err| format!("Failed to read Steam metadata response: {err}"))?; let value: Value = serde_json::from_str(&body) .map_err(|err| format!("Failed to parse Steam metadata: {err}"))?; Ok(value .get(app_id) .filter(|entry| { entry .get("success") .and_then(Value::as_bool) .unwrap_or(false) }) .and_then(|entry| entry.get("data")) .cloned()) } fn joined_string_array(value: Option<&Value>, key: &str) -> Option { value .and_then(|value| value.get(key)) .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .collect::>() .join(", ") }) .filter(|value| !value.is_empty()) } #[allow(dead_code)] pub struct MetadataMatchPlan { pub exact_app_id: bool, pub store_specific_lookup: bool, pub fuzzy_title_matching: bool, pub manual_user_correction: bool, } impl Default for MetadataMatchPlan { fn default() -> Self { Self { exact_app_id: true, store_specific_lookup: true, fuzzy_title_matching: true, manual_user_correction: true, } } }