Bare minimum functionality. Steam Web API, tag weighting, etc
This commit is contained in:
1 parent
300a5e53e3
commit
233f0dc48d
82 files changed
+15666
-9
No files matched your search
@@ -0,0 +1,754 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResolveVanityResponse {
|
||||
response: ResolveVanityPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResolveVanityPayload {
|
||||
success: i32,
|
||||
steamid: Option<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwnedGamesResponse {
|
||||
response: OwnedGamesPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwnedGamesPayload {
|
||||
games: Option<Vec<OwnedGame>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OwnedGame {
|
||||
appid: u32,
|
||||
name: Option<String>,
|
||||
playtime_forever: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StoreAppDetails {
|
||||
success: bool,
|
||||
data: Option<StoreAppData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StoreAppData {
|
||||
#[serde(rename = "type")]
|
||||
app_type: Option<String>,
|
||||
name: Option<String>,
|
||||
header_image: Option<String>,
|
||||
genres: Option<Vec<StoreNamedValue>>,
|
||||
categories: Option<Vec<StoreNamedValue>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct StoreNamedValue {
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppReviewsResponse {
|
||||
success: i32,
|
||||
query_summary: Option<AppReviewSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppReviewSummary {
|
||||
review_score_desc: Option<String>,
|
||||
total_positive: Option<u32>,
|
||||
total_reviews: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Recommendation {
|
||||
app_id: u32,
|
||||
rank: usize,
|
||||
title: String,
|
||||
fit: String,
|
||||
reason: String,
|
||||
image_url: String,
|
||||
playtime_minutes: u32,
|
||||
genres: Vec<String>,
|
||||
steam_tags: Vec<String>,
|
||||
categories: Vec<String>,
|
||||
review_summary: String,
|
||||
review_percent: Option<u8>,
|
||||
supports_singleplayer: bool,
|
||||
supports_multiplayer: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SetupSettings {
|
||||
steam_profile: String,
|
||||
steam_api_key: String,
|
||||
time_to_beat_provider: String,
|
||||
default_session_length: String,
|
||||
true_random_enabled: bool,
|
||||
include_unplayed_games: bool,
|
||||
include_played_games: bool,
|
||||
preferred_review_level: String,
|
||||
preferred_play_mode: String,
|
||||
preferred_genre: String,
|
||||
preferred_playtime: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn app_name() -> &'static str {
|
||||
"Steam Backlog Helper"
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_debug_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("debug") {
|
||||
window
|
||||
.set_focus()
|
||||
.map_err(|error| format!("Could not focus debug window: {error}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
WebviewWindowBuilder::new(&app, "debug", WebviewUrl::App("/#debug".into()))
|
||||
.title("Steam Backlog Helper Debug")
|
||||
.inner_size(1120.0, 820.0)
|
||||
.min_inner_size(760.0, 520.0)
|
||||
.resizable(true)
|
||||
.build()
|
||||
.map_err(|error| format!("Could not open debug window: {error}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_setup_settings(app: tauri::AppHandle, settings: SetupSettings) -> Result<(), String> {
|
||||
let connection = open_database(&app)?;
|
||||
|
||||
connection
|
||||
.execute(
|
||||
"CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| format!("Could not prepare settings database: {error}"))?;
|
||||
|
||||
let values = [
|
||||
("steam_profile", settings.steam_profile),
|
||||
("steam_api_key", settings.steam_api_key),
|
||||
("time_to_beat_provider", settings.time_to_beat_provider),
|
||||
("default_session_length", settings.default_session_length),
|
||||
(
|
||||
"true_random_enabled",
|
||||
settings.true_random_enabled.to_string(),
|
||||
),
|
||||
(
|
||||
"include_unplayed_games",
|
||||
settings.include_unplayed_games.to_string(),
|
||||
),
|
||||
(
|
||||
"include_played_games",
|
||||
settings.include_played_games.to_string(),
|
||||
),
|
||||
("preferred_review_level", settings.preferred_review_level),
|
||||
("preferred_play_mode", settings.preferred_play_mode),
|
||||
("preferred_genre", settings.preferred_genre),
|
||||
("preferred_playtime", settings.preferred_playtime),
|
||||
];
|
||||
|
||||
for (key, value) in values {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES (?1, ?2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = CURRENT_TIMESTAMP",
|
||||
(key, value),
|
||||
)
|
||||
.map_err(|error| format!("Could not save setting {key}: {error}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_setup_settings(app: tauri::AppHandle) -> Result<Option<SetupSettings>, String> {
|
||||
let connection = open_database(&app)?;
|
||||
|
||||
connection
|
||||
.execute(
|
||||
"CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|error| format!("Could not prepare settings database: {error}"))?;
|
||||
|
||||
let steam_profile = read_setting(&connection, "steam_profile")?;
|
||||
let steam_api_key = read_setting(&connection, "steam_api_key")?;
|
||||
|
||||
if steam_profile.is_empty() && steam_api_key.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(SetupSettings {
|
||||
steam_profile,
|
||||
steam_api_key,
|
||||
time_to_beat_provider: read_setting(&connection, "time_to_beat_provider")?,
|
||||
default_session_length: {
|
||||
let value = read_setting(&connection, "default_session_length")?;
|
||||
if value.is_empty() {
|
||||
"90 minutes".to_string()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
},
|
||||
true_random_enabled: read_setting(&connection, "true_random_enabled")? == "true",
|
||||
include_unplayed_games: read_bool_setting(&connection, "include_unplayed_games", true)?,
|
||||
include_played_games: read_bool_setting(&connection, "include_played_games", true)?,
|
||||
preferred_review_level: read_setting_with_default(
|
||||
&connection,
|
||||
"preferred_review_level",
|
||||
"any",
|
||||
)?,
|
||||
preferred_play_mode: read_setting_with_default(&connection, "preferred_play_mode", "any")?,
|
||||
preferred_genre: read_setting(&connection, "preferred_genre")?,
|
||||
preferred_playtime: read_setting_with_default(&connection, "preferred_playtime", "any")?,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_steam_library(
|
||||
steam_profile: String,
|
||||
api_key: String,
|
||||
) -> Result<Vec<Recommendation>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| format!("Could not prepare Steam HTTP client: {error}"))?;
|
||||
let steam_id = resolve_steam_id(&client, &steam_profile, &api_key).await?;
|
||||
let url = format!(
|
||||
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={api_key}&steamid={steam_id}&format=json&include_appinfo=true&include_played_free_games=true"
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Could not fetch owned games: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Steam owned-games request failed with HTTP {}.",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let payload = response
|
||||
.json::<OwnedGamesResponse>()
|
||||
.await
|
||||
.map_err(|error| format!("Could not read owned-games response: {error}"))?;
|
||||
|
||||
let mut games = payload.response.games.unwrap_or_default();
|
||||
games.sort_by(|left, right| {
|
||||
left.name
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.cmp(right.name.as_deref().unwrap_or(""))
|
||||
});
|
||||
|
||||
if games.is_empty() {
|
||||
return Err(
|
||||
"No games were returned. Check profile privacy and Steam API key access.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut recommendations = Vec::new();
|
||||
|
||||
for game in games {
|
||||
if let Some(recommendation) = build_recommendation(game) {
|
||||
recommendations.push(recommendation);
|
||||
}
|
||||
}
|
||||
|
||||
for (index, recommendation) in recommendations.iter_mut().enumerate() {
|
||||
recommendation.rank = index + 1;
|
||||
}
|
||||
|
||||
if recommendations.is_empty() {
|
||||
return Err("Steam returned owned apps, but none had usable app names.".to_string());
|
||||
}
|
||||
|
||||
Ok(recommendations)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn enrich_steam_metadata(games: Vec<Recommendation>) -> Result<Vec<Recommendation>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build()
|
||||
.map_err(|error| format!("Could not prepare Steam metadata HTTP client: {error}"))?;
|
||||
let mut enriched_games = Vec::with_capacity(games.len());
|
||||
|
||||
for chunk in games.chunks(12) {
|
||||
let app_details = fetch_store_metadata_batch(&client, chunk)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for game in chunk {
|
||||
let mut enriched_game = game.clone();
|
||||
|
||||
if let Some(metadata) = app_details.get(&game.app_id) {
|
||||
if !metadata
|
||||
.app_type
|
||||
.as_deref()
|
||||
.is_some_and(|app_type| app_type.eq_ignore_ascii_case("game"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
enriched_game.title = metadata
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| enriched_game.title.clone());
|
||||
enriched_game.image_url = metadata
|
||||
.header_image
|
||||
.clone()
|
||||
.unwrap_or_else(|| enriched_game.image_url.clone());
|
||||
enriched_game.genres = named_values_to_strings(metadata.genres.as_ref());
|
||||
enriched_game.categories = named_values_to_strings(metadata.categories.as_ref());
|
||||
enriched_game.supports_singleplayer = has_singleplayer(&enriched_game.categories);
|
||||
enriched_game.supports_multiplayer = has_multiplayer(&enriched_game.categories);
|
||||
}
|
||||
|
||||
let reviews = fetch_review_summary(&client, game.app_id)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
enriched_game.review_summary = reviews
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.review_score_desc.clone())
|
||||
.unwrap_or_else(|| "No public review signal".to_string());
|
||||
enriched_game.review_percent = reviews.as_ref().and_then(review_percent);
|
||||
|
||||
enriched_game.steam_tags = fetch_store_tags(&client, game.app_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
enriched_game.reason = build_enriched_reason(&enriched_game);
|
||||
enriched_games.push(enriched_game);
|
||||
}
|
||||
}
|
||||
|
||||
for (index, game) in enriched_games.iter_mut().enumerate() {
|
||||
game.rank = index + 1;
|
||||
}
|
||||
|
||||
Ok(enriched_games)
|
||||
}
|
||||
|
||||
fn build_recommendation(game: OwnedGame) -> Option<Recommendation> {
|
||||
let title = match game.name {
|
||||
Some(title) => title,
|
||||
None => return None,
|
||||
};
|
||||
let playtime_minutes = game.playtime_forever.unwrap_or_default();
|
||||
let playtime_hours = playtime_minutes / 60;
|
||||
let genres = Vec::new();
|
||||
let categories = Vec::new();
|
||||
let review_summary = "Review signal pending".to_string();
|
||||
let review_percent = None;
|
||||
let supports_singleplayer = false;
|
||||
let supports_multiplayer = false;
|
||||
|
||||
Some(Recommendation {
|
||||
app_id: game.appid,
|
||||
rank: 0,
|
||||
title,
|
||||
fit: if playtime_hours == 0 {
|
||||
"Unplayed".to_string()
|
||||
} else {
|
||||
format!("{playtime_hours}h played")
|
||||
},
|
||||
reason: format!(
|
||||
"Imported from your Steam library. Metadata enrichment will filter app type, genres, categories, and review signals in a background pass."
|
||||
),
|
||||
image_url: format!(
|
||||
"https://cdn.akamai.steamstatic.com/steam/apps/{}/header.jpg",
|
||||
game.appid
|
||||
),
|
||||
playtime_minutes,
|
||||
genres,
|
||||
steam_tags: Vec::new(),
|
||||
categories,
|
||||
review_summary,
|
||||
review_percent,
|
||||
supports_singleplayer,
|
||||
supports_multiplayer,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_store_metadata_batch(
|
||||
client: &reqwest::Client,
|
||||
games: &[Recommendation],
|
||||
) -> Result<HashMap<u32, StoreAppData>, String> {
|
||||
if games.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let app_ids = games
|
||||
.iter()
|
||||
.map(|game| game.app_id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let url = format!(
|
||||
"https://store.steampowered.com/api/appdetails?appids={app_ids}&filters=basic,genres,categories"
|
||||
);
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Could not fetch Steam Store metadata: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let details = match response.json::<HashMap<String, StoreAppDetails>>().await {
|
||||
Ok(details) => details,
|
||||
Err(_) => return Ok(HashMap::new()),
|
||||
};
|
||||
let mut metadata = HashMap::new();
|
||||
|
||||
for (app_id, details) in details {
|
||||
let Ok(app_id) = app_id.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let (true, Some(data)) = (details.success, details.data) {
|
||||
metadata.insert(app_id, data);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
async fn fetch_review_summary(
|
||||
client: &reqwest::Client,
|
||||
app_id: u32,
|
||||
) -> Result<Option<AppReviewSummary>, String> {
|
||||
let url = format!(
|
||||
"https://store.steampowered.com/appreviews/{app_id}?json=1&filter=summary&language=all&purchase_type=all&num_per_page=0"
|
||||
);
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Could not fetch Steam reviews for {app_id}: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let reviews = response
|
||||
.json::<AppReviewsResponse>()
|
||||
.await
|
||||
.map_err(|error| format!("Could not read Steam reviews for {app_id}: {error}"))?;
|
||||
|
||||
if reviews.success != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(reviews.query_summary)
|
||||
}
|
||||
|
||||
async fn fetch_store_tags(client: &reqwest::Client, app_id: u32) -> Result<Vec<String>, String> {
|
||||
let url = format!("https://store.steampowered.com/app/{app_id}/");
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Could not fetch Steam tags for {app_id}: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let html = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("Could not read Steam tags for {app_id}: {error}"))?;
|
||||
|
||||
Ok(parse_popular_tags(&html))
|
||||
}
|
||||
|
||||
fn parse_popular_tags(html: &str) -> Vec<String> {
|
||||
let Some(section_start) = html.find("popular_tags") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let section = &html[section_start..html.len().min(section_start + 12_000)];
|
||||
let mut tags = Vec::new();
|
||||
let mut remaining = section;
|
||||
|
||||
while let Some(link_start) = remaining.find("<a") {
|
||||
remaining = &remaining[link_start..];
|
||||
let Some(tag_start) = remaining.find('>') else {
|
||||
break;
|
||||
};
|
||||
remaining = &remaining[tag_start + 1..];
|
||||
let Some(tag_end) = remaining.find("</a>") else {
|
||||
break;
|
||||
};
|
||||
|
||||
let tag = decode_html_text(&strip_html_tags(&remaining[..tag_end]));
|
||||
|
||||
if !tag.is_empty()
|
||||
&& tag != "+"
|
||||
&& !tags.iter().any(|existing| existing == &tag)
|
||||
&& tags.len() < 20
|
||||
{
|
||||
tags.push(tag);
|
||||
}
|
||||
|
||||
remaining = &remaining[tag_end + 4..];
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
fn strip_html_tags(input: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut in_tag = false;
|
||||
|
||||
for character in input.chars() {
|
||||
match character {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => output.push(character),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
output.trim().to_string()
|
||||
}
|
||||
|
||||
fn decode_html_text(input: &str) -> String {
|
||||
input
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn named_values_to_strings(values: Option<&Vec<StoreNamedValue>>) -> Vec<String> {
|
||||
values
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.description.clone())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn has_singleplayer(categories: &[String]) -> bool {
|
||||
categories
|
||||
.iter()
|
||||
.any(|category| category.eq_ignore_ascii_case("Single-player"))
|
||||
}
|
||||
|
||||
fn has_multiplayer(categories: &[String]) -> bool {
|
||||
categories.iter().any(|category| {
|
||||
let lower_category = category.to_ascii_lowercase();
|
||||
lower_category.contains("multi-player")
|
||||
|| lower_category.contains("multiplayer")
|
||||
|| lower_category.contains("co-op")
|
||||
|| lower_category.contains("online pvp")
|
||||
|| lower_category.contains("lan pvp")
|
||||
})
|
||||
}
|
||||
|
||||
fn review_percent(summary: &AppReviewSummary) -> Option<u8> {
|
||||
let total_reviews = summary.total_reviews?;
|
||||
|
||||
if total_reviews == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let percent = summary.total_positive? as f64 / total_reviews as f64 * 100.0;
|
||||
Some(percent.round().clamp(0.0, 100.0) as u8)
|
||||
}
|
||||
|
||||
fn build_enriched_reason(game: &Recommendation) -> String {
|
||||
let reviews = game.review_summary.as_str();
|
||||
let tags = if game.steam_tags.is_empty() {
|
||||
"tags pending".to_string()
|
||||
} else {
|
||||
game.steam_tags
|
||||
.iter()
|
||||
.take(4)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
|
||||
format!("Enriched from Steam metadata. {reviews}; {tags}.")
|
||||
}
|
||||
|
||||
async fn resolve_steam_id(
|
||||
client: &reqwest::Client,
|
||||
profile: &str,
|
||||
api_key: &str,
|
||||
) -> Result<String, String> {
|
||||
let trimmed = profile.trim();
|
||||
|
||||
if trimmed.chars().all(|character| character.is_ascii_digit()) && trimmed.len() >= 16 {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
|
||||
if let Some(profile_id) = extract_after_path_segment(trimmed, "profiles") {
|
||||
if profile_id
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_digit())
|
||||
{
|
||||
return Ok(profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
let vanity = extract_after_path_segment(trimmed, "id").unwrap_or_else(|| trimmed.to_string());
|
||||
let url = format!(
|
||||
"https://api.steampowered.com/ISteamUser/ResolveVanityURL/v0001/?key={api_key}&vanityurl={vanity}"
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Could not resolve Steam vanity URL: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Steam vanity resolution failed with HTTP {}.",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let payload = response
|
||||
.json::<ResolveVanityResponse>()
|
||||
.await
|
||||
.map_err(|error| format!("Could not read Steam vanity response: {error}"))?;
|
||||
|
||||
if payload.response.success != 1 {
|
||||
return Err(payload
|
||||
.response
|
||||
.message
|
||||
.unwrap_or_else(|| "Could not resolve that Steam profile.".to_string()));
|
||||
}
|
||||
|
||||
payload
|
||||
.response
|
||||
.steamid
|
||||
.ok_or_else(|| "Steam did not return a Steam ID for that profile.".to_string())
|
||||
}
|
||||
|
||||
fn open_database(app: &tauri::AppHandle) -> Result<rusqlite::Connection, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("Could not resolve app data directory: {error}"))?;
|
||||
|
||||
fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|error| format!("Could not create app data directory: {error}"))?;
|
||||
|
||||
let database_path: PathBuf = app_data_dir.join("steam-backlog-helper.sqlite3");
|
||||
rusqlite::Connection::open(database_path)
|
||||
.map_err(|error| format!("Could not open settings database: {error}"))
|
||||
}
|
||||
|
||||
fn read_setting(connection: &rusqlite::Connection, key: &str) -> Result<String, String> {
|
||||
match connection.query_row(
|
||||
"SELECT value FROM app_settings WHERE key = ?1",
|
||||
[key],
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(String::new()),
|
||||
Err(error) => Err(format!("Could not read setting {key}: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_setting_with_default(
|
||||
connection: &rusqlite::Connection,
|
||||
key: &str,
|
||||
default_value: &str,
|
||||
) -> Result<String, String> {
|
||||
let value = read_setting(connection, key)?;
|
||||
|
||||
if value.is_empty() {
|
||||
Ok(default_value.to_string())
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bool_setting(
|
||||
connection: &rusqlite::Connection,
|
||||
key: &str,
|
||||
default_value: bool,
|
||||
) -> Result<bool, String> {
|
||||
let value = read_setting(connection, key)?;
|
||||
|
||||
if value.is_empty() {
|
||||
Ok(default_value)
|
||||
} else {
|
||||
Ok(value == "true")
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_after_path_segment(input: &str, segment: &str) -> Option<String> {
|
||||
let marker = format!("/{segment}/");
|
||||
let start = input.find(&marker)? + marker.len();
|
||||
let value = input[start..]
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_name,
|
||||
enrich_steam_metadata,
|
||||
import_steam_library,
|
||||
load_setup_settings,
|
||||
open_debug_window,
|
||||
save_setup_settings
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Steam Backlog Helper");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
backlog_helper_lib::run()
|
||||
}
|
||||
Reference in new issue
Block a user