Bare minimum functionality. Steam Web API, tag weighting, etc

This commit is contained in:
boris committed 2026-07-16 13:19:23 +01:00
1 parent 300a5e53e3
commit 233f0dc48d
82 files changed
+15666 -9

No files matched your search

+5347
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "backlog-helper"
version = "0.1.0"
description = "Steam Backlog Helper"
authors = ["Boris"]
edition = "2021"
license = ""
repository = ""
[lib]
name = "backlog_helper_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default desktop permissions for Steam Backlog Helper.",
"windows": ["main"],
"permissions": ["opener:default"]
}
+1
View File
@@ -0,0 +1 @@
{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-reLine truncated
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default desktop permissions for Steam Backlog Helper.","local":true,"windows":["main"],"permissions":["opener:default"]}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

+754
View File
@@ -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("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
backlog_helper_lib::run()
}
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Steam Backlog Helper",
"version": "0.1.0",
"identifier": "com.boris.backloghelper",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "Steam Backlog Helper",
"width": 1200,
"height": 820,
"minWidth": 960,
"minHeight": 640,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["appimage", "dmg", "msi", "nsis"],
"category": "Utility",
"shortDescription": "Pick the next Steam backlog game to play.",
"longDescription": "Steam Backlog Helper helps users choose what to play next from a large Steam library."
}
}