Add mode READMEs and move Bigscreen app

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.
This commit is contained in:
Andrew Zambazos
2026-05-21 20:11:18 +12:00
parent b141c0a058
commit f0d2926872
87 changed files with 974 additions and 322 deletions
@@ -0,0 +1,250 @@
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,
}
}
}