Skip to main content

game_manager_lib/sources/
steam.rs

1//! Módulo para importar jogos da plataforma Steam.
2//!
3//! Fornece estruturas de dados e funções para: buscar jogos instalados (via arquivos VDF locais) e
4//! não instalados (via librarycache). Buscar via API Steam como fallback e faz merge de múltiplas
5//! fontes, preservando jogos grátis não instalados.
6
7use 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/// Estrutura interna para representar um jogo
18#[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
30// === FUNÇÃO PRINCIPAL ===
31
32/// Obtém a lista completa de jogos Steam do usuário
33pub 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    // 1. API Steam primeiro (para obter owned AppIDs)
41    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    // 2. Jogos instalados (com filtro leve)
58    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    // 3. Library cache (com filtro leve)
72    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    // 4. Merge de todas as fontes
86    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    // 5. Filtro inteligente de demos
91    merged = filter_duplicate_demos(merged, &owned_appids);
92    info!("Final: {} jogos após filtro de demos", merged.len());
93
94    Ok(merged)
95}
96
97// === SCAN DE JOGOS INSTALADOS ===
98
99/// Escaneia jogos instalados via VDF e appmanifest
100pub 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
127/// Lê libraryfolders.vdf e retorna lista de bibliotecas Steam
128fn 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
172/// Parseia um arquivo appmanifest
173fn 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
235// === SCAN DO LIBRARY CACHE ===
236
237/// Escaneia jogos não-instalados via library cache
238pub 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
266/// Escaneia um diretório librarycache
267async 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    // Busca nomes em batch com rate limiting
292    let names = fetch_game_names_batch(&appids).await;
293
294    // Cria GameData para cada jogo
295    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
316/// Busca nomes de jogos em lote com rate limiting
317async 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); // 1 segundo entre batches
321    let delay_between_requests = Duration::from_millis(100); // 100ms entre requests
322
323    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        // Rate limiting entre batches (exceto no último)
340        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
354/// Busca nome do jogo via Steam Store API - VERSÃO ASYNC
355async 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
387// === API STEAM ===
388
389/// Busca jogos via API Steam
390async 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
409// === FILTRO DE DEMOS DUPLICADAS ===
410
411/// Detecta se um jogo é uma demo baseado no nome
412fn 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
434/// Normaliza nome do jogo removendo sufixos de demo
435fn 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
462/// Filtra demos duplicadas mantendo jogos gratuitos
463fn 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        // Caso 1: Tem jogo base E demo
491        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        // Caso 2: Só tem demos (sem jogo base)
511        else if !demos.is_empty() {
512            result.extend(demos);
513        }
514        // Caso 3: Só tem jogos-base (sem demos)
515        else {
516            result.extend(base_games);
517        }
518    }
519
520    result
521}
522
523// === MERGE DE JOGOS ===
524
525/// Faz merge de múltiplas fontes de jogos
526pub 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
591/// Estrutura que implementa a interface comum de importação
592pub 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        // Reutiliza a lógica robusta de merge (VDF + Cache + API)
604        let library = get_complete_library(steam_root_path, &self.api_key, &self.steam_id)
605            .await
606            .map_err(AppError::NetworkError)?; //
607
608        // Converte o GameData interno para o SourceGame genérico do provedor
609        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, // Steam usa AppIDs, não paths diretos no import inicial
618                    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}