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