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

+1 -1
View File
@@ -32,4 +32,4 @@ The first version is picker-first: it asks a short set of questions, scores the
## Current Status ## Current Status
This repository currently contains planning documentation only. The next step is to scaffold the Tauri + SvelteKit application. This repository contains the initial Tauri + SvelteKit application shell. The frontend can be typechecked and built; Tauri desktop compilation requires a local Rust toolchain.
+1
View File
@@ -21,6 +21,7 @@ Do not open a public issue for:
- Never commit real API keys. - Never commit real API keys.
- Never include real API keys in tests, fixtures, screenshots, logs, or documentation examples. - Never include real API keys in tests, fixtures, screenshots, logs, or documentation examples.
- Prefer OS credential storage for Steam and third-party provider keys. - Prefer OS credential storage for Steam and third-party provider keys.
- Current development builds store the Steam API key in the local SQLite settings database. This is acceptable for the current local MVP but should be revisited before public release.
- If local plaintext storage is temporarily used during early development, it must be clearly marked as temporary and replaced before release. - If local plaintext storage is temporarily used during early development, it must be clearly marked as temporary and replaced before release.
- Logs must redact API keys and tokens. - Logs must redact API keys and tokens.
+10 -5
View File
@@ -24,10 +24,10 @@ Goal: create the first runnable desktop application.
Deliverables: Deliverables:
- Tauri app scaffold using `Steam Backlog Helper` as the display name and `com.boris.backloghelper` as the package ID. - Tauri app scaffold using `Steam Backlog Helper` as the display name and `com.boris.backloghelper` as the package ID. Initial shell complete.
- Svelte/SvelteKit frontend with TypeScript. - Svelte/SvelteKit frontend with TypeScript. Initial shell complete.
- Basic app shell with setup, picker, and settings routes or views. - Basic app shell with setup, picker, and settings routes or views. Setup and picker shell complete; settings route pending.
- Lint, format, typecheck, and test commands. - Lint, format, typecheck, and test commands. Typecheck and build scripts complete; formatting and test scripts pending.
- Initial SQLite migration system. - Initial SQLite migration system.
Exit criteria: Exit criteria:
@@ -45,6 +45,8 @@ Deliverables:
- Steam profile input supporting Steam ID, vanity URL, or profile URL. - Steam profile input supporting Steam ID, vanity URL, or profile URL.
- User-provided Steam Web API key setup. - User-provided Steam Web API key setup.
- Owned-games fetch. - Owned-games fetch.
- Store metadata lookup during import to identify Steam app type.
- Default exclusion of tools, DLC, demos, soundtracks, and other non-game app types.
- Library cache in SQLite. - Library cache in SQLite.
- Clear failure states for invalid keys, private profiles, rate limits, and network failures. - Clear failure states for invalid keys, private profiles, rate limits, and network failures.
@@ -65,6 +67,7 @@ Deliverables:
- HowLongToBeat-compatible completion-time integration after validation. - HowLongToBeat-compatible completion-time integration after validation.
- Metadata cache with freshness timestamps. - Metadata cache with freshness timestamps.
- Default filtering for demos, tools, soundtracks, and DLC. - Default filtering for demos, tools, soundtracks, and DLC.
- Single-player and multiplayer/co-op category detection.
Exit criteria: Exit criteria:
@@ -79,6 +82,8 @@ Deliverables:
- Short-question recommendation flow. - Short-question recommendation flow.
- Weighted scoring using available time, optional genre, completion preference, review score, and playtime. - Weighted scoring using available time, optional genre, completion preference, review score, and playtime.
- Hard eligibility filters for candidate removal.
- Preferred filters for review signal, single-player/multiplayer, genre, and playtime weighting.
- True-random toggle that bypasses weighted scoring. - True-random toggle that bypasses weighted scoring.
- Dynamic shortlist output. - Dynamic shortlist output.
- Human-readable recommendation explanations. - Human-readable recommendation explanations.
@@ -97,7 +102,7 @@ Goal: make the primary workflow feel focused and polished.
Deliverables: Deliverables:
- Recommendation-first home screen. - Recommendation-first home screen.
- Slot-machine style reveal for the primary recommendation. - Spinning-wheel reveal for the primary recommendation.
- Keyboard-accessible controls. - Keyboard-accessible controls.
- Steam app ID launch links. - Steam app ID launch links.
- Responsive desktop layout for common window sizes. - Responsive desktop layout for common window sizes.
+1 -1
View File
@@ -75,7 +75,7 @@ This file preserves the planning answers that shaped the current implementation
2. Should the first screen be the recommendation flow or the full library? 2. Should the first screen be the recommendation flow or the full library?
The first screen should be recommendations. The first screen should be recommendations.
3. Should there be a visual picker mode such as a wheel, cards, or slot-machine style animation? 3. Should there be a visual picker mode such as a wheel, cards, or slot-machine style animation?
Use a slot-machine style animation. Use a spinning-wheel picker.
4. Should keyboard navigation be a priority? 4. Should keyboard navigation be a priority?
Yes. The app should be accessible via keyboard. Yes. The app should be accessible via keyboard.
+12 -2
View File
@@ -26,7 +26,7 @@ Desktop App
Frontend UI Frontend UI
Setup flow Setup flow
Recommendation flow Recommendation flow
Slot-machine picker Spinning-wheel picker
Settings for API keys and refresh Settings for API keys and refresh
App Service Layer App Service Layer
Steam import Steam import
@@ -49,6 +49,7 @@ Responsibilities:
- Resolve Steam profile inputs. - Resolve Steam profile inputs.
- Fetch owned games. - Fetch owned games.
- Fetch Steam Store metadata for owned apps so tools, DLC, demos, and other non-game app types can be excluded before recommendation.
- Guide the user through Steam Web API key setup. - Guide the user through Steam Web API key setup.
- Handle API errors and private profile failures. - Handle API errors and private profile failures.
- Normalize Steam app IDs and playtime. - Normalize Steam app IDs and playtime.
@@ -72,6 +73,8 @@ Responsibilities:
- Score candidate games based on the short questionnaire. - Score candidate games based on the short questionnaire.
- Explain the score in human-readable terms. - Explain the score in human-readable terms.
- Apply hard eligibility filters before scoring.
- Apply preferred filters as weighting only, not as inclusion/exclusion rules.
- Support a true-random mode that bypasses weighted scoring. - Support a true-random mode that bypasses weighted scoring.
- Return a ranked dynamic shortlist and one primary recommendation. - Return a ranked dynamic shortlist and one primary recommendation.
- Keep mood tagging, history, and accepted/rejected tracking out of the MVP. - Keep mood tagging, history, and accepted/rejected tracking out of the MVP.
@@ -100,6 +103,8 @@ Responsibilities:
- `review_score` - `review_score`
- `is_free_to_play` - `is_free_to_play`
- `is_excluded_type` - `is_excluded_type`
- `supports_singleplayer`
- `supports_multiplayer`
- `metadata_updated_at` - `metadata_updated_at`
### game_lengths ### game_lengths
@@ -131,11 +136,13 @@ Example scoring inputs:
- Positive weight for matching selected genre or tag. - Positive weight for matching selected genre or tag.
- Positive weight for fitting available session time. - Positive weight for fitting available session time.
- Positive weight for matching preferred single-player or multiplayer mode.
- Positive weight for partially played games in "finish something" mode. - Positive weight for partially played games in "finish something" mode.
- Positive weight for unplayed games in "try something new" mode. - Positive weight for unplayed games in "try something new" mode.
- Positive weight for meeting the user's review-score preference. - Positive weight for meeting the user's review-score preference.
- Negative weight for extremely long games in short-session mode. - Negative weight for extremely long games in short-session mode.
- Hard exclusion for demos, tools, soundtracks, and DLC. - Hard exclusion for demos, tools, soundtracks, and DLC.
- Hard exclusion only for explicit eligibility filters; review, mode, genre, and playtime preferences should not remove candidates by themselves.
The UI should show concise explanations such as: The UI should show concise explanations such as:
@@ -169,6 +176,7 @@ The UI should show concise explanations such as:
- Fetch Steam Store metadata. - Fetch Steam Store metadata.
- Fetch public review signals where available. - Fetch public review signals where available.
- Exclude non-game Steam app types before they enter the wheel.
- Integrate a HowLongToBeat-compatible source after validating terms and package health. - Integrate a HowLongToBeat-compatible source after validating terms and package health.
- Cache metadata locally with refresh timestamps. - Cache metadata locally with refresh timestamps.
@@ -176,8 +184,10 @@ The UI should show concise explanations such as:
- Implement the short-question flow. - Implement the short-question flow.
- Add scoring and explanations. - Add scoring and explanations.
- Add eligibility filters that can remove games from the candidate pool.
- Add preferred filters for review signal, single-player/multiplayer, genre, and playtime weighting.
- Add true-random toggle behavior. - Add true-random toggle behavior.
- Add slot-machine reveal. - Add spinning-wheel reveal.
- Add Steam launch links. - Add Steam launch links.
### Phase 5: Packaging ### Phase 5: Packaging
+17
View File
@@ -0,0 +1,17 @@
# Goals
## Filtering
- Exclude all non-games by default, including tools, DLC, demos, soundtracks, and other Steam app types where Store metadata reports a non-`game` type.
- Keep hard eligibility filters separate from preference weighting:
- Eligibility filters remove games from the wheel entirely.
- Preferred filters adjust scoring and weighted selection without removing games.
- Add general-question-driven eligibility filters that can exclude games from the candidate set.
- Add preferred filters for:
- Steam review signal, from Overwhelmingly Positive through Negative/Mixed options.
- Single-player preference.
- Multiplayer/co-op preference.
- Genre preference.
- Playtime preference.
A 'find a game' mode, specified in the top bar, should run through a random amount of games (minimum 20) to programmatically find the best game for the user; each game should be thumbs up or thumbs down, or neutral. Neutral should add another game to be rated and not counted towards any weighting. Weighting should impact the next recommendation via the tags associated with them (genre, singleplayer/multiplayer, steam tags, etc.) This should take the form of the wheel for now, but may be changed in the future.
+1849
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "steam-backlog-helper",
"version": "0.1.0",
"private": true,
"description": "A cross-platform desktop app that helps Steam users pick a game from their backlog.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"local:check": "sh scripts/local-check.sh",
"local:dev": "sh scripts/local-dev.sh",
"local:build": "sh scripts/local-build.sh",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"svelte": "^5.0.0",
"vite": "^7.0.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tauri-apps/cli": "^2.0.0",
"@types/node": "^26.1.1",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
if [ -f "$HOME/.cargo/env" ]; then
. "$HOME/.cargo/env"
fi
npm run check
npm run tauri:build
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
if [ -f "$HOME/.cargo/env" ]; then
. "$HOME/.cargo/env"
fi
npm run check
(cd src-tauri && cargo check)
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
if [ -f "$HOME/.cargo/env" ]; then
. "$HOME/.cargo/env"
fi
npm run tauri:dev
+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."
}
}
+9
View File
@@ -0,0 +1,9 @@
declare global {
namespace App {}
interface Window {
__TAURI_INTERNALS__?: unknown;
}
}
export {};
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Steam Backlog Helper helps you choose what to play next from a large Steam library."
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import '../styles/app.css';
let { children } = $props();
</script>
{@render children()}
File diff suppressed because it is too large. Load diff
+1038
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
clearScreen: false,
server: {
strictPort: true,
port: 1420
},
envPrefix: ['VITE_', 'TAURI_'],
build: {
target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
minify: !process.env.TAURI_ENV_DEBUG,
sourcemap: Boolean(process.env.TAURI_ENV_DEBUG)
}
});