Skip to main content

game_manager_lib/services/
playtime.rs

1//! Rastreamento local de tempo jogado, usado como fallback para plataformas
2//! que não expõem playtime via API própria (todas exceto Steam/Itch/Indiegala).
3//!
4//! Funciona observando, por polling, se o executável resolvido do jogo (já
5//! persistido em `games.executable_path` na importação) está em execução.
6//! Não depende do processo ser filho direto do Playlite — cobre também os
7//! casos em que o Playlite só abriu o launcher da plataforma (protocolo ou
8//! executável do launcher) e o jogo de fato sobe depois, por fora.
9
10use std::collections::HashSet;
11use std::path::PathBuf;
12use std::sync::Mutex;
13use std::time::Duration;
14
15use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
16use tauri::{AppHandle, Manager};
17
18use crate::database::AppState;
19use crate::models::Platform;
20use crate::utils::status_logic;
21
22/// Mesma granularidade das plataformas com API oficial (Steam/Itch/Indiegala
23/// reportam playtime em minutos) — evita precisão inconsistente entre fontes.
24const POLL_INTERVAL: Duration = Duration::from_secs(60);
25
26/// Tempo máximo esperando o processo do jogo aparecer depois que o launcher
27/// foi aberto (ex: usuário demora navegando antes de clicar "Play", ou desiste).
28/// Evita task órfã rodando indefinidamente.
29const MAX_WAIT_FOR_START: Duration = Duration::from_secs(10 * 60);
30
31/// Evita observar o mesmo jogo duas vezes (ex: usuário clica "Jogar" repetidamente).
32#[derive(Default)]
33pub struct PlaytimeRegistry {
34    watching: Mutex<HashSet<String>>,
35}
36
37impl PlaytimeRegistry {
38    /// Retorna `true` se conseguiu reservar o watch (ninguém mais observando este jogo).
39    fn try_start(&self, game_id: &str) -> bool {
40        self.watching.lock().unwrap().insert(game_id.to_string())
41    }
42
43    fn finish(&self, game_id: &str) {
44        self.watching.lock().unwrap().remove(game_id);
45    }
46}
47
48/// Plataformas que já expõem playtime oficial — o tracker local nunca roda pra elas,
49/// pra não gerar dado duplicado/conflitante com o valor sincronizado na importação.
50pub fn has_official_playtime_source(platform: &Platform) -> bool {
51    matches!(
52        platform,
53        Platform::Steam | Platform::Itch | Platform::Indiegala
54    )
55}
56
57fn normalize_path_str(s: &str) -> String {
58    s.replace('\\', "/")
59}
60
61/// Dispara o watcher em background para o jogo informado. Idempotente: se já
62/// houver um watch em andamento para o mesmo `game_id`, não faz nada.
63pub fn watch_game(
64    app: AppHandle,
65    game_id: String,
66    exe_path: PathBuf,
67    install_path: Option<PathBuf>,
68) {
69    let state = app.state::<AppState>();
70    if !state.playtime_registry.try_start(&game_id) {
71        tracing::warn!("[playtime] watch já em andamento para {game_id}, ignorando");
72        return;
73    }
74
75    let expected_filename = exe_path
76        .file_name()
77        .map(|f| f.to_string_lossy().to_lowercase());
78
79    let install_path_lower = install_path
80        .as_ref()
81        .map(|p| normalize_path_str(&p.to_string_lossy().to_lowercase()));
82
83    tracing::info!(
84        "[playtime] iniciando watch para {game_id} | expected_filename={expected_filename:?} | install_path_lower={install_path_lower:?}"
85    );
86
87    tauri::async_runtime::spawn(async move {
88        let mut sys = System::new_with_specifics(
89            RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
90        );
91
92        let mut found = false;
93        let mut waited = Duration::ZERO;
94
95        loop {
96            tokio::time::sleep(POLL_INTERVAL).await;
97            sys.refresh_processes(ProcessesToUpdate::All, true);
98
99            let mut running = false;
100
101            for p in sys.processes().values() {
102                let Some(exe) = p.exe() else { continue };
103                let exe_str = normalize_path_str(&exe.to_string_lossy().to_lowercase());
104
105                let candidate_filename =
106                    exe.file_name().map(|f| f.to_string_lossy().to_lowercase());
107                let name_matches = candidate_filename == expected_filename;
108
109                let within_install_dir = match &install_path_lower {
110                    Some(dir) => exe_str.starts_with(dir.as_str()),
111                    None => true,
112                };
113
114                if name_matches && within_install_dir {
115                    running = true;
116                    break; // Otimização: se achou o processo, não precisa continuar iterando
117                }
118            }
119
120            if running {
121                found = true;
122                if let Err(e) = increment_playtime(&app, &game_id) {
123                    tracing::warn!("[playtime] falha ao gravar incremento para {game_id}: {e}");
124                }
125            } else if found {
126                tracing::info!("[playtime] processo sumiu, encerrando sessão para {game_id}");
127                break;
128            } else {
129                waited += POLL_INTERVAL;
130                if waited >= MAX_WAIT_FOR_START {
131                    tracing::warn!("[playtime] timeout esperando processo iniciar para {game_id}");
132                    break;
133                }
134            }
135        }
136
137        app.state::<AppState>().playtime_registry.finish(&game_id);
138    });
139}
140
141/// Incrementa 1 minuto de playtime e recalcula `status`, no mesmo padrão usado
142/// pelos importadores (ex: `persist_indiegala_games`), pra manter a UI de
143/// status ("jogando"/"backlog") coerente sem esperar uma reimportação.
144fn increment_playtime(app: &AppHandle, game_id: &str) -> Result<(), rusqlite::Error> {
145    let state = app.state::<AppState>();
146    let conn = state.games_db.lock().unwrap();
147
148    let current: Option<i32> = conn.query_row(
149        "SELECT playtime FROM games WHERE id = ?1",
150        [game_id],
151        |row| row.get(0),
152    )?;
153    let new_playtime = current.unwrap_or(0) + 1;
154    let status = status_logic::calculate_status(new_playtime);
155    let source = crate::models::PlaytimeSource::Local.as_db_str();
156
157    conn.execute(
158        "UPDATE games SET playtime = ?1, status = ?2, playtime_source = ?3 WHERE id = ?4",
159        rusqlite::params![new_playtime, status, source, game_id],
160    )?;
161    Ok(())
162}