game_manager_lib/sources/
steam.rs1use crate::constants::{STEAM_STORE_TIMEOUT_SECS, USER_AGENT_BROWSER};
8use crate::errors::AppError;
9use crate::sources::providers::{GameSource, SourceGame};
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use tokio::time::{sleep, Duration};
16use tracing::{debug, info, warn};
17
18#[derive(Debug, Deserialize, Serialize, Clone)]
20pub struct GameData {
21 pub name: String,
22 pub platform: String,
23 pub platform_game_id: String,
24 pub install_path: Option<String>,
25 pub installed: bool,
26 pub import_confidence: String,
27 pub playtime_forever: i32,
28 pub rtime_last_played: i64,
29}
30
31pub async fn get_complete_library(
35 steam_root: &Path,
36 api_key: &str,
37 steam_id: &str,
38) -> Result<Vec<GameData>, String> {
39 info!("Iniciando importação da biblioteca Steam");
40
41 let api_games = match fetch_steam_api(api_key, steam_id).await {
43 Ok(games) => {
44 info!("API: {} jogos possuídos", games.len());
45 games
46 }
47 Err(e) => {
48 warn!("Aviso ao buscar API: {}", e);
49 Vec::new()
50 }
51 };
52
53 let owned_appids: HashSet<String> = api_games
54 .iter()
55 .map(|g| g.platform_game_id.clone())
56 .collect();
57
58 let installed = match scan_installed_games(steam_root) {
60 Ok(mut games) => {
61 let mut seen = HashSet::new();
62 games.retain(|g| seen.insert(g.platform_game_id.clone()));
63 info!("VDF: {} jogos instalados encontrados", games.len());
64 games
65 }
66 Err(e) => {
67 warn!("Aviso ao buscar instalados: {}", e);
68 Vec::new()
69 }
70 };
71
72 let cached = match scan_library_cache(steam_root).await {
74 Ok(mut games) => {
75 let mut seen = HashSet::new();
76 games.retain(|g| seen.insert(g.platform_game_id.clone()));
77 info!("Cache: {} jogos encontrados", games.len());
78 games
79 }
80 Err(e) => {
81 warn!("Aviso ao buscar cache: {}", e);
82 Vec::new()
83 }
84 };
85
86 let sources = vec![installed, cached, api_games];
88 let mut merged = merge_games(sources);
89 info!("Merge: {} jogos antes de filtro de demos", merged.len());
90
91 merged = filter_duplicate_demos(merged, &owned_appids);
93 info!("Final: {} jogos após filtro de demos", merged.len());
94
95 Ok(merged)
96}
97
98pub fn scan_installed_games(steam_root: &Path) -> Result<Vec<GameData>, String> {
102 let mut games = Vec::new();
103 let library_folders = read_library_folders(steam_root)?;
104
105 for library in library_folders {
106 let steamapps = library.join("steamapps");
107 if !steamapps.exists() {
108 continue;
109 }
110
111 let entries =
112 fs::read_dir(&steamapps).map_err(|e| format!("Erro ao ler steamapps: {}", e))?;
113
114 for entry in entries.flatten() {
115 let path = entry.path();
116
117 if is_appmanifest(&path) {
118 if let Ok(game) = parse_appmanifest(&path, &library) {
119 games.push(game);
120 }
121 }
122 }
123 }
124
125 Ok(games)
126}
127
128fn read_library_folders(steam_root: &Path) -> Result<Vec<PathBuf>, String> {
130 let vdf_path = steam_root.join("steamapps").join("libraryfolders.vdf");
131 let mut libraries = vec![steam_root.to_path_buf()];
132
133 if !vdf_path.exists() {
134 return Ok(libraries);
135 }
136
137 let content = fs::read_to_string(&vdf_path)
138 .map_err(|e| format!("Erro ao ler libraryfolders.vdf: {}", e))?;
139
140 let mut in_folder = false;
141 let mut current_path: Option<PathBuf> = None;
142
143 for line in content.lines() {
144 let trimmed = line.trim();
145
146 if trimmed.starts_with('"') && trimmed.contains('"') {
147 let parts: Vec<&str> = trimmed.split('"').collect();
148 if parts.len() >= 2 && parts[1].chars().all(|c| c.is_ascii_digit()) {
149 in_folder = true;
150 }
151 }
152
153 if in_folder && trimmed.starts_with("\"path\"") {
154 if let Some(path_str) = extract_vdf_value(trimmed) {
155 let cleaned = path_str.replace("\\\\", "\\");
156 current_path = Some(PathBuf::from(cleaned));
157 }
158 }
159
160 if trimmed == "}" && in_folder {
161 if let Some(path) = current_path.take() {
162 if path != steam_root.to_path_buf() {
163 libraries.push(path);
164 }
165 }
166 in_folder = false;
167 }
168 }
169
170 Ok(libraries)
171}
172
173fn parse_appmanifest(manifest_path: &Path, library_root: &Path) -> Result<GameData, String> {
175 let content = fs::read_to_string(manifest_path)
176 .map_err(|e| AppError::SteamManifestError(e.to_string()).to_string())?;
177 let mut appid: Option<String> = None;
178 let mut name: Option<String> = None;
179 let mut installdir: Option<String> = None;
180
181 for line in content.lines() {
182 let trimmed = line.trim();
183
184 if trimmed.starts_with("\"appid\"") {
185 appid = extract_vdf_value(trimmed);
186 } else if trimmed.starts_with("\"name\"") {
187 name = extract_vdf_value(trimmed);
188 } else if trimmed.starts_with("\"installdir\"") {
189 installdir = extract_vdf_value(trimmed);
190 }
191
192 if appid.is_some() && name.is_some() && installdir.is_some() {
193 break;
194 }
195 }
196
197 let appid = appid.ok_or("appid não encontrado")?;
198 let name = name.unwrap_or_else(|| "Unknown".to_string());
199 let install_path = installdir.map(|dir| {
200 library_root
201 .join("steamapps")
202 .join("common")
203 .join(dir)
204 .to_string_lossy()
205 .to_string()
206 });
207
208 Ok(GameData {
209 name,
210 platform: "Steam".to_string(),
211 platform_game_id: appid,
212 install_path,
213 installed: true,
214 import_confidence: "High".to_string(),
215 playtime_forever: 0,
216 rtime_last_played: 0,
217 })
218}
219
220fn is_appmanifest(path: &Path) -> bool {
221 path.file_name()
222 .and_then(|f| f.to_str())
223 .map(|f| f.starts_with("appmanifest_") && f.ends_with(".acf"))
224 .unwrap_or(false)
225}
226
227fn extract_vdf_value(line: &str) -> Option<String> {
228 let parts: Vec<&str> = line.split('"').collect();
229 if parts.len() >= 4 {
230 Some(parts[3].to_string())
231 } else {
232 None
233 }
234}
235
236pub async fn scan_library_cache(steam_root: &Path) -> Result<Vec<GameData>, String> {
240 let mut games = Vec::new();
241 let userdata = steam_root.join("userdata");
242
243 if !userdata.exists() {
244 return Ok(games);
245 }
246
247 let user_dirs = fs::read_dir(&userdata).map_err(|e| format!("Erro: {}", e))?;
248
249 for user_entry in user_dirs.flatten() {
250 if !user_entry.path().is_dir() {
251 continue;
252 }
253
254 let cache_dir = user_entry.path().join("config").join("librarycache");
255
256 if cache_dir.exists() {
257 scan_librarycache_dir(&cache_dir, &mut games).await?;
258 }
259 }
260
261 let mut seen = HashSet::new();
262 games.retain(|g| seen.insert(g.platform_game_id.clone()));
263
264 Ok(games)
265}
266
267async fn scan_librarycache_dir(dir: &Path, games: &mut Vec<GameData>) -> Result<(), String> {
269 let entries = fs::read_dir(dir).map_err(|e| format!("Erro: {}", e))?;
270 let mut appids = Vec::new();
271
272 for entry in entries.flatten() {
273 let path = entry.path();
274
275 if path.extension().and_then(|e| e.to_str()) != Some("json") {
276 continue;
277 }
278
279 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
280 if filename.chars().all(|c| c.is_ascii_digit()) {
281 appids.push(filename.to_string());
282 }
283 }
284 }
285
286 if appids.is_empty() {
287 return Ok(());
288 }
289
290 info!("Cache: {} AppIDs para buscar nomes", appids.len());
291
292 let names = fetch_game_names_batch(&appids).await;
294
295 for appid in appids {
297 let name = names
298 .get(&appid)
299 .cloned()
300 .unwrap_or_else(|| format!("Game {}", appid));
301
302 games.push(GameData {
303 name,
304 platform: "Steam".to_string(),
305 platform_game_id: appid,
306 install_path: None,
307 installed: false,
308 import_confidence: "Medium".to_string(),
309 playtime_forever: 0,
310 rtime_last_played: 0,
311 });
312 }
313
314 Ok(())
315}
316
317async fn fetch_game_names_batch(appids: &[String]) -> HashMap<String, String> {
320 let mut names = HashMap::new();
321
322 info!("Iniciando busca em batch de {} jogos", appids.len());
323
324 for appid in appids {
325 if let Some(name) = fetch_steam_game_name(appid).await {
326 names.insert(appid.clone(), name);
327 }
328 sleep(Duration::from_millis(crate::constants::STEAM_RATE_LIMIT_MS)).await;
329 }
330
331 info!(
332 "Busca concluída: {}/{} nomes obtidos",
333 names.len(),
334 appids.len()
335 );
336 names
337}
338
339async fn fetch_steam_game_name(app_id: &str) -> Option<String> {
342 use crate::utils::http_client::HTTP_CLIENT;
343
344 crate::services::rate_limiter::STEAM_LIMITER
345 .run(|| async {
346 let url = format!(
347 "https://store.steampowered.com/api/appdetails?appids={}",
348 app_id
349 );
350
351 let resp = HTTP_CLIENT
352 .get(&url)
353 .header("User-Agent", USER_AGENT_BROWSER)
354 .timeout(Duration::from_secs(STEAM_STORE_TIMEOUT_SECS))
355 .send()
356 .await
357 .map_err(|e| e.to_string())?;
358
359 if !resp.status().is_success() {
360 return Err(format!("Steam appdetails Error: {}", resp.status()));
361 }
362
363 let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
364 Ok(json)
365 })
366 .await
367 .ok()
368 .and_then(|json| {
369 let app_data = json.get(app_id)?;
370 if !app_data.get("success")?.as_bool()? {
371 return None;
372 }
373 app_data
374 .get("data")?
375 .get("name")?
376 .as_str()
377 .map(|s| s.to_string())
378 })
379}
380
381async fn fetch_steam_api(api_key: &str, steam_id: &str) -> Result<Vec<GameData>, String> {
385 let steam_games =
386 crate::services::integration::steam_api::list_steam_games(api_key, steam_id).await?;
387
388 Ok(steam_games
389 .into_iter()
390 .map(|game| GameData {
391 name: game.name,
392 platform: "Steam".to_string(),
393 platform_game_id: game.appid.to_string(),
394 install_path: None,
395 installed: false,
396 import_confidence: "Low".to_string(),
397 playtime_forever: game.playtime_forever,
398 rtime_last_played: game.rtime_last_played,
399 })
400 .collect())
401}
402
403fn is_demo_game(name: &str) -> bool {
407 let name_lower = name.to_lowercase();
408
409 let demo_keywords = [
410 " demo",
411 "(demo)",
412 " - demo",
413 " playtest",
414 "(playtest)",
415 " - playtest",
416 " free weekend",
417 " trial",
418 " beta test",
419 " limited test",
420 " technical test",
421 ];
422
423 demo_keywords
424 .iter()
425 .any(|keyword| name_lower.contains(keyword))
426}
427
428fn normalize_game_name(name: &str) -> String {
430 let mut normalized = name.to_lowercase();
431
432 let suffixes_to_remove = [
433 " demo",
434 " (demo)",
435 " - demo",
436 " playtest",
437 " (playtest)",
438 " - playtest",
439 " free weekend",
440 " trial",
441 " beta test",
442 " limited test",
443 " technical test",
444 ];
445
446 for suffix in &suffixes_to_remove {
447 if let Some(pos) = normalized.find(suffix) {
448 normalized = normalized[..pos].to_string();
449 break;
450 }
451 }
452
453 normalized.trim().to_string()
454}
455
456fn filter_duplicate_demos(games: Vec<GameData>, owned_appids: &HashSet<String>) -> Vec<GameData> {
458 let mut by_normalized_name: HashMap<String, Vec<GameData>> = HashMap::new();
459
460 for game in games {
461 let normalized = normalize_game_name(&game.name);
462 by_normalized_name.entry(normalized).or_default().push(game);
463 }
464
465 let mut result = Vec::new();
466
467 for (normalized_name, mut games_group) in by_normalized_name {
468 if games_group.len() == 1 {
469 result.push(games_group.pop().unwrap());
470 continue;
471 }
472
473 let mut demos: Vec<GameData> = Vec::new();
474 let mut base_games: Vec<GameData> = Vec::new();
475
476 for game in games_group {
477 if is_demo_game(&game.name) {
478 demos.push(game);
479 } else {
480 base_games.push(game);
481 }
482 }
483
484 if !base_games.is_empty() && !demos.is_empty() {
486 let has_owned_base = base_games
487 .iter()
488 .any(|g| owned_appids.contains(&g.platform_game_id));
489
490 if has_owned_base {
491 debug!(
492 "Removendo demos de '{}' (jogo base possuído)",
493 normalized_name
494 );
495 result.extend(base_games);
496 } else {
497 debug!(
498 "Mantendo demos de '{}' (jogo base não possuído)",
499 normalized_name
500 );
501 result.extend(demos);
502 }
503 }
504 else if !demos.is_empty() {
506 result.extend(demos);
507 }
508 else {
510 result.extend(base_games);
511 }
512 }
513
514 result
515}
516
517pub fn merge_games(sources: Vec<Vec<GameData>>) -> Vec<GameData> {
521 let mut map: HashMap<String, Vec<GameData>> = HashMap::new();
522
523 for list in sources {
524 for game in list {
525 let key = format!("{}::{}", game.platform, game.platform_game_id);
526 map.entry(key).or_default().push(game);
527 }
528 }
529
530 let mut result: Vec<GameData> = map.into_values().map(merge_multiple).collect();
531 result.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
532 result
533}
534
535fn merge_multiple(mut games: Vec<GameData>) -> GameData {
536 if games.is_empty() {
537 panic!("Lista vazia");
538 }
539
540 if games.len() == 1 {
541 return games.into_iter().next().unwrap();
542 }
543
544 games.sort_by(|a, b| {
545 let a_score = confidence_score(&a.import_confidence);
546 let b_score = confidence_score(&b.import_confidence);
547 b_score.cmp(&a_score)
548 });
549
550 games
551 .into_iter()
552 .reduce(|acc, next| enrich(&acc, &next))
553 .unwrap()
554}
555
556fn confidence_score(conf: &str) -> i32 {
557 match conf {
558 "High" => 3,
559 "Medium" => 2,
560 "Low" => 1,
561 _ => 0,
562 }
563}
564
565fn enrich(primary: &GameData, secondary: &GameData) -> GameData {
566 GameData {
567 name: if primary.name.len() >= secondary.name.len() {
568 primary.name.clone()
569 } else {
570 secondary.name.clone()
571 },
572 platform: primary.platform.clone(),
573 platform_game_id: primary.platform_game_id.clone(),
574 install_path: primary
575 .install_path
576 .clone()
577 .or_else(|| secondary.install_path.clone()),
578 installed: primary.installed || secondary.installed,
579 import_confidence: primary.import_confidence.clone(),
580 playtime_forever: primary.playtime_forever.max(secondary.playtime_forever),
581 rtime_last_played: primary.rtime_last_played.max(secondary.rtime_last_played),
582 }
583}
584
585pub struct SteamSource {
587 pub steam_root: String,
588 pub api_key: String,
589 pub steam_id: String,
590}
591
592#[async_trait]
593impl GameSource for SteamSource {
594 async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
595 let steam_root_path = Path::new(&self.steam_root);
596
597 let library = get_complete_library(steam_root_path, &self.api_key, &self.steam_id)
599 .await
600 .map_err(AppError::NetworkError)?; let games = library
604 .into_iter()
605 .map(|game| {
606 SourceGame {
607 platform: "Steam".to_string(),
608 platform_game_id: game.platform_game_id,
609 name: Some(game.name),
610 installed: game.installed,
611 executable_path: None, install_path: game.install_path,
613 playtime_minutes: Some(game.playtime_forever as u32),
614 last_played: Some(game.rtime_last_played),
615 }
616 })
617 .collect();
618
619 Ok(games)
620 }
621}