f0d2926872
Add comprehensive READMEs for Bigscreen and Desktop modes, and relocate the Tauri frontend prototype into a new Bigscreen/ subdirectory. This moves package.json/package-lock, src, src-tauri, assets, views, styles and related files into Bigscreen/ to separate the controller-first shell from top-level docs. Update the root README.md to reframe the project as "NebulaOS", describe Bigscreen/Desktop modes, the vision, tech stack and development notes. This reorganizes the repo layout for clearer mode separation and documentation.
251 lines
7.7 KiB
Rust
251 lines
7.7 KiB
Rust
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<Option<GameMetadata>, 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<Option<GameMetadata>, String> {
|
|
if candidate.source == GameSource::Steam {
|
|
return self.resolve_steam(candidate);
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
impl LocalCacheMetadataProvider {
|
|
fn resolve_steam(&self, candidate: &GameCandidate) -> Result<Option<GameMetadata>, 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::<Vec<_>>()
|
|
})
|
|
.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::<Vec<_>>()
|
|
})
|
|
.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::<Vec<_>>();
|
|
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<Option<Value>, 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<String> {
|
|
value
|
|
.and_then(|value| value.get(key))
|
|
.and_then(Value::as_array)
|
|
.map(|items| {
|
|
items
|
|
.iter()
|
|
.filter_map(Value::as_str)
|
|
.collect::<Vec<_>>()
|
|
.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,
|
|
}
|
|
}
|
|
}
|