-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add achievement cache and progress bar #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
25a9250
8bc8f3d
65d0569
18a37c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String>, | ||
| pub rarest_percent: Option<f64>, | ||
| } | ||
|
|
||
| #[derive(Debug, Default, Serialize, Deserialize)] | ||
| pub struct AchievementCache { | ||
| games: HashMap<u32, CachedAchievement>, | ||
| } | ||
|
|
||
| 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<PathBuf> { | ||
| dirs::cache_dir().map(|p| p.join("steamfetch").join("achievements.json")) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| mod cache; | ||
| mod config; | ||
| mod display; | ||
| mod steam; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,13 @@ | ||
| use anyhow::{Context, Result}; | ||
| use futures::future::join_all; | ||
| use indicatif::{ProgressBar, ProgressStyle}; | ||
| use reqwest::Client; | ||
| 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"; | ||
|
|
||
|
|
@@ -104,6 +105,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 +311,75 @@ impl SteamClient { | |
| &self, | ||
| games: &super::models::OwnedGamesData, | ||
| ) -> Option<AchievementStats> { | ||
| 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 pb = ProgressBar::new(all_games.len() as u64); | ||
| pb.set_style( | ||
| ProgressStyle::default_bar() | ||
| .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} games") | ||
| .unwrap() | ||
| .progress_chars("#>-"), | ||
| ); | ||
|
|
||
| let mut total_achieved = 0u32; | ||
| let mut total_possible = 0u32; | ||
| let mut perfect_games = 0u32; | ||
| let mut rarest_candidates: Vec<RarestAchievement> = 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; | ||
| for game in &all_games { | ||
| pb.inc(1); | ||
| 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) { | ||
| 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, | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
|
Comment on lines
+354
to
381
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result:
Sources: [1] [2] [3] [4] 🏁 Script executed: # Let's examine the cache implementation to understand the matching logic
find . -type f -name "*.rs" | head -20Repository: unhappychoice/steamfetch Length of output: 225 🏁 Script executed: # Search for the cache implementation
rg -t rust "AchievementCache" -A 10 -B 2Repository: unhappychoice/steamfetch Length of output: 2049 🏁 Script executed: # Look at the cache.get method implementation
rg -t rust "fn get\(" -A 5 | grep -A 5 "cache\|Cache"Repository: unhappychoice/steamfetch Length of output: 334 🏁 Script executed: # Check the file structure to find cache-related code
fd -e rs -type f | xargs grep -l "cache\|Cache" 2>/dev/null | head -10Repository: unhappychoice/steamfetch Length of output: 238 🏁 Script executed: # Examine the cache.rs file in detail to understand the full implementation
cat -n src/cache.rsRepository: unhappychoice/steamfetch Length of output: 2285 🏁 Script executed: # Check the CachedAchievement structure and how it's populated
rg -t rust "struct CachedAchievement" -A 10Repository: unhappychoice/steamfetch Length of output: 511 🏁 Script executed: # Look for where cache entries are inserted/stored to understand the populate flow
rg -t rust "cache\.save\|insert" -B 5 -A 5Repository: unhappychoice/steamfetch Length of output: 50 🏁 Script executed: # Check steam/models.rs to understand the Game struct and rtime_last_played field
cat -n src/steam/models.rs | head -100Repository: unhappychoice/steamfetch Length of output: 2888 🏁 Script executed: # Find where cache.set is called to understand the full cache populate flow
rg -t rust "cache\.set" -B 10 -A 3Repository: unhappychoice/steamfetch Length of output: 928 🏁 Script executed: # Look at the complete context around lines 330-352 in client.rs
sed -n '300,380p' src/steam/client.rsRepository: unhappychoice/steamfetch Length of output: 2888 Bypass cache for games with When Treat Proposed fix in cache hit check // Check cache first
+ if game.rtime_last_played != 0 {
if let Some(cached) = cache.get(game.appid, game.rtime_last_played) {
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,
});
}
continue;
}
+ }Alternatively, centralize the guard in pub fn get(&self, appid: u32, last_played: u64) -> Option<&CachedAchievement> {
if last_played == 0 {
return None;
}
self.games
.get(&appid)
.filter(|c| c.last_played == last_played)
}🤖 Prompt for AI Agents |
||
|
|
||
| if let Some(r) = result.rarest { | ||
| rarest_candidates.push(r); | ||
| // Fetch from API | ||
| 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(); | ||
| 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 +487,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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unwrap_or_default()on serialization failure will silently wipe the cache file.If
serde_json::to_string(self)ever fails,unwrap_or_default()yields"", andfs::writeoverwrites the cache with an empty string — destroying all previously cached data. Guard the write behind a successful serialization instead.Proposed fix
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()); + if let Ok(json) = serde_json::to_string(self) { + let _ = fs::write(path, json); + } } }📝 Committable suggestion
🤖 Prompt for AI Agents