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::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/// Estrutura interna para representar um jogo
19#[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
31// === FUNÇÃO PRINCIPAL ===
32
33/// Obtém a lista completa de jogos Steam do usuário
34pub 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    // 1. API Steam primeiro (para obter owned AppIDs)
42    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    // 2. Jogos instalados (com filtro leve)
59    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    // 3. Library cache (com filtro leve)
73    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    // 4. Merge de todas as fontes
87    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    // 5. Filtro inteligente de demos
92    merged = filter_duplicate_demos(merged, &owned_appids);
93    info!("Final: {} jogos após filtro de demos", merged.len());
94
95    Ok(merged)
96}
97
98// === SCAN DE JOGOS INSTALADOS ===
99
100/// Escaneia jogos instalados via VDF e appmanifest
101pub 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
128/// Lê libraryfolders.vdf e retorna lista de bibliotecas Steam
129fn 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
173/// Parseia um arquivo appmanifest
174fn 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
236// === SCAN DO LIBRARY CACHE ===
237
238/// Escaneia jogos não-instalados via library cache
239pub 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
267/// Escaneia um diretório librarycache
268async 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    // Busca nomes em batch com rate limiting
293    let names = fetch_game_names_batch(&appids).await;
294
295    // Cria GameData para cada jogo
296    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
317/// Busca nomes de jogos em lote, respeitando o limitador compartilhado da Steam
318/// (mesmo STEAM_LIMITER usado no enrichment).
319async 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
339/// Busca nome do jogo via Steam Store API, com rate limiting e backoff
340/// compartilhados via STEAM_LIMITER (mesmo usado por get_app_details/search_app_by_name).
341async 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
381// === API STEAM ===
382
383/// Busca jogos via API Steam
384async 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
403// === FILTRO DE DEMOS DUPLICADAS ===
404
405/// Detecta se um jogo é uma demo baseado no nome
406fn 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
428/// Normaliza nome do jogo removendo sufixos de demo
429fn 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
456/// Filtra demos duplicadas mantendo jogos gratuitos
457fn 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        // Caso 1: Tem jogo base E demo
485        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        // Caso 2: Só tem demos (sem jogo base)
505        else if !demos.is_empty() {
506            result.extend(demos);
507        }
508        // Caso 3: Só tem jogos-base (sem demos)
509        else {
510            result.extend(base_games);
511        }
512    }
513
514    result
515}
516
517// === MERGE DE JOGOS ===
518
519/// Faz merge de múltiplas fontes de jogos
520pub 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
585/// Estrutura que implementa a interface comum de importação
586pub 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        // Reutiliza a lógica robusta de merge (VDF + Cache + API)
598        let library = get_complete_library(steam_root_path, &self.api_key, &self.steam_id)
599            .await
600            .map_err(AppError::NetworkError)?; //
601
602        // Converte o GameData interno para o SourceGame genérico do provedor
603        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, // Steam usa AppIDs, não paths diretos no import inicial
612                    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}