diff --git a/Cargo.lock b/Cargo.lock index c227589..18c1cee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -208,6 +221,12 @@ dependencies = [ "syn", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -648,6 +667,19 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -785,6 +817,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "once_cell" version = "1.21.3" @@ -900,6 +938,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1220,6 +1264,7 @@ dependencies = [ "colored", "dirs", "futures", + "indicatif", "libloading", "reqwest", "serde", @@ -1533,6 +1578,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -1662,6 +1713,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 182f513..ac6b7fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ thiserror = "2" anyhow = "1" steamworks = { version = "0.12", features = ["raw-bindings"] } libloading = "0.8" +indicatif = "0.17" diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..c3791d5 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedAchievement { + pub last_played: u64, + pub achieved: u32, + pub total: u32, + pub rarest_name: Option, + pub rarest_percent: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct AchievementCache { + games: HashMap, +} + +impl AchievementCache { + pub fn load() -> Self { + cache_path() + .and_then(|p| fs::read_to_string(p).ok()) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + pub fn save(&self) { + if let Some(path) = cache_path() { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::write(path, serde_json::to_string(self).unwrap_or_default()); + } + } + + pub fn get(&self, appid: u32, last_played: u64) -> Option<&CachedAchievement> { + self.games + .get(&appid) + .filter(|c| c.last_played == last_played) + } + + pub fn set( + &mut self, + appid: u32, + last_played: u64, + achieved: u32, + total: u32, + rarest: Option<(&str, f64)>, + ) { + self.games.insert( + appid, + CachedAchievement { + last_played, + achieved, + total, + rarest_name: rarest.map(|(n, _)| n.to_string()), + rarest_percent: rarest.map(|(_, p)| p), + }, + ); + } +} + +fn cache_path() -> Option { + dirs::cache_dir().map(|p| p.join("steamfetch").join("achievements.json")) +} diff --git a/src/main.rs b/src/main.rs index f49cc9f..8ce5e40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod cache; mod config; mod display; mod steam; diff --git a/src/steam/client.rs b/src/steam/client.rs index ff0f249..be410fd 100644 --- a/src/steam/client.rs +++ b/src/steam/client.rs @@ -1,15 +1,27 @@ use anyhow::{Context, Result}; -use futures::future::join_all; +use indicatif::{ProgressBar, ProgressStyle}; use reqwest::Client; +use std::io::{self, Write}; use std::time::Duration; use super::models::{ AchievementStats, AchievementsResponse, GameStat, GlobalAchievementsResponse, OwnedGamesResponse, PlayerSummaryResponse, RarestAchievement, SteamStats, }; +use crate::cache::AchievementCache; const BASE_URL: &str = "https://api.steampowered.com"; +fn print_status(msg: &str) { + eprint!("\r\x1b[K{}", msg); + let _ = io::stderr().flush(); +} + +fn clear_status() { + eprint!("\r\x1b[K"); + let _ = io::stderr().flush(); +} + pub struct SteamClient { client: Client, api_key: String, @@ -39,9 +51,13 @@ impl SteamClient { } pub async fn fetch_stats(&self) -> Result { - // Sequential requests to avoid Steam API rate limiting + print_status("Fetching player info..."); let player = self.fetch_player().await?; + + print_status("Fetching owned games..."); let games = self.fetch_owned_games().await?; + + print_status("Fetching account details..."); let steam_level = self.fetch_steam_level().await?; let recently_played = self.fetch_recently_played().await?; @@ -73,9 +89,13 @@ impl SteamClient { appids: &[u32], username: &str, ) -> Result { - // Sequential requests to avoid Steam API rate limiting + print_status("Fetching player info..."); let player = self.fetch_player().await?; + + print_status("Fetching owned games..."); let games = self.fetch_owned_games_for_appids(appids).await?; + + print_status("Fetching account details..."); let steam_level = self.fetch_steam_level().await?; let recently_played = self.fetch_recently_played().await?; @@ -104,6 +124,9 @@ impl SteamClient { .get(&appid) .map_or(0, |g| g.playtime_forever), playtime_2weeks: 0, + rtime_last_played: games_with_playtime + .get(&appid) + .map_or(0, |g| g.rtime_last_played), }) .collect(), }; @@ -307,32 +330,93 @@ impl SteamClient { &self, games: &super::models::OwnedGamesData, ) -> Option { - let all_games = self.get_all_games(games); - let results = join_all( - all_games - .iter() - .map(|(appid, name)| self.fetch_game_achievements(*appid, name.clone())), - ) - .await; + let mut cache = AchievementCache::load(); + let all_games: Vec<_> = games.games.iter().collect(); + let total_games = all_games.len(); + + clear_status(); + let pb = ProgressBar::new(total_games as u64); + pb.set_style( + ProgressStyle::default_bar() + .template("\r{msg} [{bar:30.cyan/blue}] {pos}/{len}") + .unwrap() + .progress_chars("#>-"), + ); + pb.set_message("Achievements (0 cached, 0 fetched)"); let mut total_achieved = 0u32; let mut total_possible = 0u32; let mut perfect_games = 0u32; let mut rarest_candidates: Vec = Vec::new(); - - for result in results.into_iter().flatten() { - total_achieved += result.achieved; - total_possible += result.total; - - if result.achieved == result.total && result.total > 0 { - perfect_games += 1; + let mut cached_count = 0u32; + let mut fetched_count = 0u32; + + for game in &all_games { + let game_name = game + .name + .clone() + .unwrap_or_else(|| format!("App {}", game.appid)); + + // Check cache first + if let Some(cached) = cache.get(game.appid, game.rtime_last_played) { + cached_count += 1; + total_achieved += cached.achieved; + total_possible += cached.total; + if cached.achieved == cached.total && cached.total > 0 { + perfect_games += 1; + } + if let (Some(name), Some(percent)) = (&cached.rarest_name, cached.rarest_percent) { + rarest_candidates.push(RarestAchievement { + name: name.clone(), + game: game_name, + percent, + }); + } + pb.inc(1); + pb.set_message(format!( + "Achievements ({} cached, {} fetched)", + cached_count, fetched_count + )); + continue; } - if let Some(r) = result.rarest { - rarest_candidates.push(r); + // Fetch from API + fetched_count += 1; + pb.inc(1); + pb.set_message(format!( + "Achievements ({} cached, {} fetched)", + cached_count, fetched_count + )); + + if let Some(result) = self + .fetch_game_achievements(game.appid, game_name.clone()) + .await + { + total_achieved += result.achieved; + total_possible += result.total; + if result.achieved == result.total && result.total > 0 { + perfect_games += 1; + } + + let rarest_for_cache = result.rarest.as_ref().map(|r| (r.name.as_str(), r.percent)); + cache.set( + game.appid, + game.rtime_last_played, + result.achieved, + result.total, + rarest_for_cache, + ); + + if let Some(r) = result.rarest { + rarest_candidates.push(r); + } } } + pb.finish_and_clear(); + clear_status(); + cache.save(); + // Sort by percent, then by game name, then by achievement name for deterministic results let rarest = rarest_candidates.into_iter().min_by(|a, b| { a.percent @@ -440,19 +524,6 @@ impl SteamClient { }) .collect() } - - fn get_all_games(&self, games: &super::models::OwnedGamesData) -> Vec<(u32, String)> { - games - .games - .iter() - .map(|g| { - ( - g.appid, - g.name.clone().unwrap_or_else(|| format!("App {}", g.appid)), - ) - }) - .collect() - } } struct GameAchievementResult { diff --git a/src/steam/models.rs b/src/steam/models.rs index 8406e65..1511eff 100644 --- a/src/steam/models.rs +++ b/src/steam/models.rs @@ -20,6 +20,8 @@ pub struct Game { pub playtime_forever: u32, #[serde(default)] pub playtime_2weeks: u32, + #[serde(default)] + pub rtime_last_played: u64, } // Recently Played Games API