Skip to main content

game_manager_lib/commands/platforms/
core.rs

1//! Funções genéricas usadas na importação de bibliotecas de plataformas externas (Steam, Epic, GOG).
2//!
3//! Fornece comandos para salvar dados dos jogos nos bancos de dados e mensagens padronizadas.
4
5use crate::constants;
6use crate::database::AppState;
7use crate::errors::AppError;
8use crate::sources::providers::SourceGame;
9use crate::sources::scanner::GameDiscovery;
10use crate::utils::status_logic;
11use chrono::{TimeZone, Utc};
12use rusqlite::params;
13use serde::{Deserialize, Serialize};
14use tauri::{AppHandle, Emitter, Manager};
15use tracing::{info, warn};
16use uuid::Uuid;
17
18// === Estruturas de Dados ===
19
20#[derive(Serialize, Deserialize, Debug)]
21pub struct ScanResult {
22    pub success: bool,
23    pub message: String,
24    pub discoveries: Vec<GameDiscovery>,
25}
26
27#[derive(Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ScanGameInput {
30    pub name: String,
31    pub executable_path: String,
32    pub base_path: String,
33}
34
35/// Dados mínimos de um jogo recém-inserido, usados para disparar enriquecimento automático logo após a importação.
36#[derive(Debug, Clone)]
37pub struct NewlyImportedGame {
38    pub game_id: String,
39    pub name: String,
40    pub platform: String,
41    pub platform_game_id: String,
42}
43
44// === Funções Genéricas de Persistência ===
45
46/// Persiste uma lista de jogos de uma fonte externa (como Steam) no banco de dados.
47///
48/// Retorna o número de jogos inseridos e atualizados.
49pub(crate) async fn persist_source_games(
50    state: &AppState,
51    games: Vec<crate::sources::providers::SourceGame>,
52) -> Result<(u32, u32, Vec<NewlyImportedGame>), AppError> {
53    let mut conn = state.games_db.lock().map_err(|_| AppError::MutexError)?;
54
55    // Inicia uma transação única para todo o lote
56    let tx = conn
57        .transaction()
58        .map_err(|e| AppError::DatabaseError(e.to_string()))?;
59
60    let mut inserted = 0;
61    let mut updated = 0;
62    let mut newly_imported = Vec::new();
63    let now = Utc::now().to_rfc3339();
64
65    for game in games {
66        // Verifica existência usando a transação
67        let exists: bool = tx
68            .query_row(
69                "SELECT EXISTS(SELECT 1 FROM games WHERE platform = ?1 AND platform_game_id = ?2)",
70                params![&game.platform, &game.platform_game_id],
71                |row| row.get(0),
72            )
73            .unwrap_or(false);
74
75        let status = status_logic::calculate_status(game.playtime_minutes.unwrap_or(0) as i32);
76
77        let last_played_iso = game.last_played.and_then(|ts| {
78            if ts > 0 {
79                Some(Utc.timestamp_opt(ts, 0).single().map(|dt| dt.to_rfc3339()))
80            } else {
81                None
82            }
83        });
84
85        let is_official_playtime_platform = game.platform == "Steam";
86
87        if !exists {
88            let new_id = Uuid::new_v4().to_string();
89            let display_name = game.name.clone().unwrap_or_else(|| "Unknown".to_string());
90            let slug = crate::utils::text::slugify(&display_name);
91
92            let cover_url = if game.platform == "Steam" {
93                Some(format!(
94                    "{}/{}",
95                    constants::STEAM_CDN_URL,
96                    constants::STEAM_LIBRARY_IMAGE_PATH.replace("{}", &game.platform_game_id)
97                ))
98            } else {
99                None
100            };
101
102            // Só Steam grava playtime_source no INSERT; as demais ficam NULL até
103            // o tracker local (playtime.rs) assumir e marcar como 'local'.
104            let playtime_source = if is_official_playtime_platform {
105                Some(
106                    crate::models::PlaytimeSource::Platform(crate::models::Platform::Steam)
107                        .as_db_str(),
108                )
109            } else {
110                None
111            };
112
113            tx.execute(
114                "INSERT INTO games (
115                id, name, slug, cover_url, platform, platform_game_id,
116                installed, status, playtime, playtime_source, last_played, added_at,
117                favorite, user_rating, install_path, executable_path
118            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, NULL, ?13, ?14)",
119                params![
120                    new_id,
121                    display_name,
122                    slug,
123                    cover_url,
124                    game.platform,
125                    game.platform_game_id,
126                    game.installed,
127                    status,
128                    game.playtime_minutes.unwrap_or(0),
129                    playtime_source,
130                    last_played_iso,
131                    now,
132                    game.install_path,
133                    game.executable_path,
134                ],
135            )
136                .map_err(|e| AppError::DatabaseError(e.to_string()))?;
137
138            newly_imported.push(NewlyImportedGame {
139                game_id: new_id,
140                name: display_name,
141                platform: game.platform.clone(),
142                platform_game_id: game.platform_game_id.clone(),
143            });
144
145            inserted += 1;
146        } else {
147            if is_official_playtime_platform {
148                // Steam remanda playtime a cada sync — sobrescreve e marca fonte oficial.
149                tx.execute(
150                    "UPDATE games SET
151                    installed = ?1,
152                    status = ?2,
153                    playtime = ?3,
154                    playtime_source = ?4,
155                    last_played = ?5,
156                    install_path = COALESCE(?6, install_path),
157                    executable_path = COALESCE(?7, executable_path)
158                WHERE platform = ?8 AND platform_game_id = ?9",
159                    params![
160                        game.installed,
161                        status,
162                        game.playtime_minutes.unwrap_or(0),
163                        crate::models::PlaytimeSource::Platform(crate::models::Platform::Steam)
164                            .as_db_str(),
165                        last_played_iso,
166                        game.install_path,
167                        game.executable_path,
168                        game.platform,
169                        game.platform_game_id
170                    ],
171                )
172                    .map_err(|e| AppError::DatabaseError(e.to_string()))?;
173            } else {
174                // Demais plataformas: não têm playtime próprio pra remandar —
175                // preserva o que o tracker local já gravou (playtime e playtime_source intactos).
176                tx.execute(
177                    "UPDATE games SET
178                    installed = ?1,
179                    status = ?2,
180                    last_played = ?3,
181                    install_path = COALESCE(?4, install_path),
182                    executable_path = COALESCE(?5, executable_path)
183                WHERE platform = ?6 AND platform_game_id = ?7",
184                    params![
185                        game.installed,
186                        status,
187                        last_played_iso,
188                        game.install_path,
189                        game.executable_path,
190                        game.platform,
191                        game.platform_game_id
192                    ],
193                )
194                    .map_err(|e| AppError::DatabaseError(e.to_string()))?;
195            }
196
197            updated += 1;
198        }
199    }
200
201    // Finaliza a transação
202    tx.commit()
203        .map_err(|e| AppError::DatabaseError(e.to_string()))?;
204
205    Ok((inserted, updated, newly_imported))
206}
207
208/// Dispara o enriquecimento de metadados em background para jogos recém-importados, se houver algum.
209pub fn trigger_enrichment_if_needed(app: &AppHandle, newly_imported: Vec<NewlyImportedGame>) {
210    if !newly_imported.is_empty() {
211        let app_clone = app.clone();
212        tauri::async_runtime::spawn(async move {
213            crate::commands::metadata::enrichment::enrich_newly_imported(app_clone, newly_imported)
214                .await;
215        });
216    }
217}
218
219/// Executa uma importação de plataforma em background, sem bloquear o comando Tauri.
220///
221/// `fetch` recebe uma cópia do `AppHandle` (necessário para fontes com OAuth, como Amazon/Epic/GOG)
222/// e deve conter toda a parte lenta (rede, fetch, merge de fontes), retornando a lista final de
223/// `SourceGame` pronta para persistência. O comando retorna imediatamente.
224///
225/// O resultado chega ao **frontend** via eventos:
226///
227/// - `import_started`   → payload: platform (string)
228/// - `import_complete`  → payload: (platform, message)
229/// - `import_error`     → payload: (platform, error)
230/// - `library_updated`  → (mantido, sem payload, para compatibilidade com listeners existentes)
231pub fn spawn_import<F, Fut>(app: AppHandle, platform: &'static str, fetch: F)
232where
233    F: FnOnce(AppHandle) -> Fut + Send + 'static,
234    Fut: std::future::Future<Output=Result<Vec<SourceGame>, AppError>> + Send + 'static,
235{
236    let _ = app.emit("import_started", platform);
237    let app_task = app.clone();
238    let app_for_fetch = app.clone();
239
240    tauri::async_runtime::spawn(async move {
241        let games = match fetch(app_for_fetch).await {
242            Ok(g) => g,
243            Err(e) => {
244                warn!("Import {} falhou: {}", platform, e);
245                let _ = app_task.emit("import_error", (platform, e.to_string()));
246                return;
247            }
248        };
249
250        if games.is_empty() {
251            let msg = format_import_empty(platform);
252            let _ = app_task.emit("import_complete", (platform, msg));
253            return;
254        }
255
256        let state: tauri::State<AppState> = app_task.state();
257        let (inserted, updated, newly_imported) = match persist_source_games(&state, games).await {
258            Ok(r) => r,
259            Err(e) => {
260                warn!("Persist {} falhou: {}", platform, e);
261                let _ = app_task.emit("import_error", (platform, e.to_string()));
262                return;
263            }
264        };
265
266        let message = format_import_summary(platform, inserted, updated);
267        info!("{}", message);
268
269        let _ = app_task.emit("library_updated", ());
270        let _ = app_task.emit("import_complete", (platform, message));
271
272        trigger_enrichment_if_needed(&app_task, newly_imported);
273    });
274}
275
276/// Resultado de uma importação, usado por `spawn_import_custom` para fontes com persistência própria
277/// (IndieGala, Itch.io, Legacy Games), que gravam campos extras (`description_raw`, `tags`, `cover_url`)
278/// fora do `SourceGame` padrão e por isso não passam por `persist_source_games`.
279pub enum ImportOutcome {
280    Empty,
281    Persisted {
282        inserted: u32,
283        updated: u32,
284        newly_imported: Vec<NewlyImportedGame>,
285    },
286}
287
288/// Como `spawn_import`, mas para fontes cuja persistência já está embutida em `run`
289/// (fetch + persist_*_games próprio), retornando o resultado final e não uma lista crua de `SourceGame`.
290pub fn spawn_import_custom<F, Fut>(app: AppHandle, platform: &'static str, run: F)
291where
292    F: FnOnce(AppHandle) -> Fut + Send + 'static,
293    Fut: std::future::Future<Output=Result<ImportOutcome, AppError>> + Send + 'static,
294{
295    let _ = app.emit("import_started", platform);
296    let app_task = app.clone();
297    let app_for_run = app.clone();
298
299    tauri::async_runtime::spawn(async move {
300        match run(app_for_run).await {
301            Ok(ImportOutcome::Empty) => {
302                let msg = format_import_empty(platform);
303                let _ = app_task.emit("import_complete", (platform, msg));
304            }
305            Ok(ImportOutcome::Persisted {
306                   inserted,
307                   updated,
308                   newly_imported,
309               }) => {
310                let message = format_import_summary(platform, inserted, updated);
311                info!("{}", message);
312                let _ = app_task.emit("library_updated", ());
313                let _ = app_task.emit("import_complete", (platform, message));
314                trigger_enrichment_if_needed(&app_task, newly_imported);
315            }
316            Err(e) => {
317                warn!("Import {} falhou: {}", platform, e);
318                let _ = app_task.emit("import_error", (platform, e.to_string()));
319            }
320        }
321    });
322}
323
324// === Funções padronizadas para mensagens ===
325
326/// Mensagem padrão de sucesso: "<Plataforma>: X adicionados, Y atualizados".
327pub fn format_import_summary(platform: &str, inserted: u32, updated: u32) -> String {
328    format!("{platform}: {inserted} adicionados, {updated} atualizados")
329}
330
331/// Mensagem padrão de biblioteca vazia: "Nenhum jogo <plataforma> encontrado."
332pub fn format_import_empty(platform: &str) -> String {
333    format!("Nenhum jogo {platform} encontrado.")
334}
335
336/// Mensagem padrão de conexão bem sucedida: "Conta <plataforma> conectada com sucesso!"
337pub fn format_login_success(platform: &str) -> String {
338    format!("Conta {platform} conectada com sucesso!")
339}