Skip to main content

game_manager_lib/commands/metadata/
enrichment.rs

1//! Comandos para enriquecimento automático de metadados
2//!
3//! Este módulo contém comandos Tauri para atualizar metadados de jogos na biblioteca
4//! do usuário, buscando informações de APIs externas como RAWG e Steam.
5//! Versão otimizada com cache SQLite e processamento em batch.
6//!
7//! Design notes:
8//! - Cache persistente via SQLite (metadata.db)
9//! - block_in_place usado para manter conexão SQLite durante awaits
10//! - Itens compartilhados com covers estão no módulo shared
11
12use super::shared::{
13    fetch_rawg_metadata, fetch_steam_playtime, fetch_steam_reviews, fetch_steam_store_data,
14    resolve_steam_app_id, EnrichProgress,
15};
16use crate::commands::platforms::core::NewlyImportedGame;
17use crate::constants::{RAWG_RATE_LIMIT_MS, RAWG_REQUISITIONS_PER_BATCH};
18use crate::database;
19use crate::database::AppState;
20use crate::errors::AppError;
21use crate::services::integration::steam_api;
22use crate::services::{cache, playtime};
23use crate::utils::series;
24use rusqlite::params;
25use std::collections::{HashMap, HashSet};
26use std::time::Duration;
27use tauri::{AppHandle, Emitter, Manager, State};
28use tokio::time::sleep;
29use tracing::{info, warn};
30
31const ENRICH_SKIP_SOURCE: &str = "enrich";
32
33// === HELPERS LOCAIS ===
34
35fn enrich_skip_key(game_id: &str) -> String {
36    format!("skip_{}", game_id)
37}
38
39fn is_enrich_skipped(game_id: &str, cache_conn: &rusqlite::Connection) -> bool {
40    let key = enrich_skip_key(game_id);
41    cache::get_stale_api_data(cache_conn, ENRICH_SKIP_SOURCE, &key).is_some()
42}
43
44fn mark_enrich_skipped(game_id: &str, cache_conn: &rusqlite::Connection) {
45    let key = enrich_skip_key(game_id);
46    let _ = cache::save_cached_api_data(cache_conn, ENRICH_SKIP_SOURCE, &key, "1");
47}
48
49// === ESTRUTURAS DE DADOS ===
50
51#[derive(serde::Serialize)]
52pub struct ImportSummary {
53    pub success_count: i32,
54    pub error_count: i32,
55    pub total_processed: i32,
56    pub message: String,
57    pub errors: Vec<String>,
58}
59
60/// Estrutura intermediária
61pub(in crate::commands::metadata) struct ProcessedGameDetails {
62    pub(in crate::commands::metadata) game_id: String,
63    pub(in crate::commands::metadata) description_raw: Option<String>,
64    pub(in crate::commands::metadata) description_ptbr: Option<String>,
65    pub(in crate::commands::metadata) release_date: Option<String>,
66    pub(in crate::commands::metadata) genres: String,
67    pub(in crate::commands::metadata) tags: Vec<crate::models::GameTag>,
68    pub(in crate::commands::metadata) developer: Option<String>,
69    pub(in crate::commands::metadata) publisher: Option<String>,
70    pub(in crate::commands::metadata) critic_score: Option<i32>,
71    pub(in crate::commands::metadata) background_image: Option<String>,
72    pub(in crate::commands::metadata) series: Option<String>,
73    pub(in crate::commands::metadata) steam_review_label: Option<String>,
74    pub(in crate::commands::metadata) steam_review_count: Option<i32>,
75    pub(in crate::commands::metadata) steam_review_score: Option<f32>,
76    pub(in crate::commands::metadata) steam_review_updated_at: Option<String>,
77    pub(in crate::commands::metadata) esrb_rating: Option<String>,
78    pub(in crate::commands::metadata) is_adult: bool,
79    pub(in crate::commands::metadata) adult_tags: Option<String>,
80    pub(in crate::commands::metadata) external_links: Option<String>,
81    pub(in crate::commands::metadata) steam_app_id: Option<String>,
82    pub(in crate::commands::metadata) median_playtime: Option<i32>,
83    pub(in crate::commands::metadata) estimated_playtime: Option<f32>,
84}
85
86// === LÓGICA CORE (REFATORADA) ===
87
88pub async fn enrich_newly_imported(app: AppHandle, games: Vec<NewlyImportedGame>) {
89    let api_key = match database::get_secret(&app, "rawg_api_key") {
90        Ok(key) if !key.is_empty() => key,
91        _ => {
92            warn!("Enrichment pós-import ignorado: API Key da RAWG não configurada.");
93            return;
94        }
95    };
96
97    let state: State<AppState> = app.state();
98    let total = games.len();
99    let mut all_session_tags: HashSet<String> = HashSet::new();
100    let mut batch_results = Vec::new();
101
102    for (index, game) in games.into_iter().enumerate() {
103        let _ = app.emit(
104            "enrich_progress",
105            EnrichProgress {
106                current: (index + 1) as i32,
107                total_found: total as i32,
108                last_game: game.name.clone(),
109                status: "running".to_string(),
110            },
111        );
112
113        let (processed_data, raw_tags, _rawg_found) = {
114            let cache_conn = match state.cache_db.lock() {
115                Ok(c) => c,
116                Err(_) => continue,
117            };
118            tokio::task::block_in_place(|| {
119                let rt = tokio::runtime::Handle::current();
120                rt.block_on(async {
121                    enrich_game_metadata(
122                        &api_key,
123                        &game.game_id,
124                        &game.name,
125                        &game.platform,
126                        Some(game.platform_game_id.clone()),
127                        &cache_conn,
128                    )
129                    .await
130                })
131            })
132        };
133
134        for tag in raw_tags {
135            all_session_tags.insert(tag);
136        }
137        batch_results.push((game.name, processed_data));
138
139        sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
140    }
141
142    if let Ok(mut conn) = state.games_db.lock() {
143        if let Ok(tx) = conn.transaction() {
144            let mut success = 0;
145            let mut errors = 0;
146            for (name, data) in batch_results {
147                if let Err(e) = save_game_details(&tx, data) {
148                    warn!("enrich_newly_imported: erro ao salvar {}: {}", name, e);
149                    errors += 1;
150                } else {
151                    success += 1;
152                }
153            }
154            match tx.commit() {
155                Ok(_) => info!("enrich_newly_imported: {} ok, {} erros", success, errors),
156                Err(e) => warn!("enrich_newly_imported: commit falhou: {}", e),
157            }
158        }
159    }
160
161    let _ = crate::services::tags::generate_analysis_report(&app, all_session_tags);
162    let _ = app.emit("enrich_complete", "Enriquecimento pós-import concluído.");
163}
164
165/// Processa um único jogo com cache integrado (sem manter lock)
166async fn enrich_game_metadata(
167    api_key: &str,
168    game_id: &str,
169    name: &str,
170    platform: &str,
171    platform_game_id: Option<String>,
172    cache_conn: &rusqlite::Connection,
173) -> (ProcessedGameDetails, Vec<String>, bool) {
174    let series_name = series::infer_series(name);
175    let mut details = ProcessedGameDetails {
176        game_id: game_id.to_string(),
177        description_raw: None,
178        description_ptbr: None,
179        release_date: None,
180        genres: String::new(),
181        tags: Vec::new(),
182        developer: None,
183        publisher: None,
184        critic_score: None,
185        background_image: None,
186        series: series_name,
187        steam_review_label: None,
188        steam_review_count: None,
189        steam_review_score: None,
190        steam_review_updated_at: None,
191        esrb_rating: None,
192        is_adult: false,
193        adult_tags: None,
194        external_links: None,
195        steam_app_id: None,
196        median_playtime: None,
197        estimated_playtime: None,
198    };
199
200    let mut links_map: HashMap<String, String> = HashMap::new();
201    let mut found_raw_tags: Vec<String> = Vec::new();
202    let mut rawg_found = false;
203
204    // 1. Resolução do Steam App ID — cobre tanto jogos Steam (direto do platform_game_id) quanto
205    // jogos de outras plataformas (via busca por nome na Steam Store Search), com cache de tentativas sem sucesso.
206    let target_steam_id =
207        resolve_steam_app_id(name, platform, platform_game_id.as_deref(), cache_conn)
208            .await
209            .map(|resolution| resolution.app_id);
210
211    // 2. Busca na RAWG (com cache)
212    if let Some(rawg_det) = fetch_rawg_metadata(api_key, name, cache_conn).await {
213        rawg_found = true;
214        found_raw_tags = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
215
216        let raw_tag_slugs: Vec<String> = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
217
218        details.description_raw = rawg_det.description_raw;
219        details.release_date = rawg_det.released;
220        details.genres = rawg_det
221            .genres
222            .iter()
223            .map(|g| g.name.clone())
224            .collect::<Vec<_>>()
225            .join(", ");
226        details.tags = crate::services::tags::classify_and_sort_tags(raw_tag_slugs, 10);
227        details.developer = rawg_det.developers.first().map(|d| d.name.clone());
228        details.publisher = rawg_det.publishers.first().map(|p| p.name.clone());
229        details.critic_score = rawg_det.metacritic;
230        details.background_image = rawg_det.background_image;
231        details.esrb_rating = rawg_det.esrb_rating.as_ref().map(|r| r.name.clone());
232
233        if let Some(url) = &rawg_det.website {
234            links_map.insert("website".to_string(), url.clone());
235        }
236        if let Some(url) = &rawg_det.reddit_url {
237            links_map.insert("reddit".to_string(), url.clone());
238        }
239        if let Some(url) = &rawg_det.metacritic_url {
240            links_map.insert("metacritic".to_string(), url.clone());
241        }
242        links_map.insert(
243            "rawg".to_string(),
244            format!("https://rawg.io/games/{}", rawg_det.id),
245        );
246    }
247
248    // 3. Busca na Steam (com cache) — usa o ID já resolvido no passo 1
249    if let Some(steam_id) = &target_steam_id {
250        links_map
251            .entry("steam".to_string())
252            .or_insert_with(|| format!("https://store.steampowered.com/app/{}", steam_id));
253        details.steam_app_id = Some(steam_id.clone());
254
255        if let Some(store_data) = fetch_steam_store_data(steam_id, cache_conn).await {
256            let (detected_adult, flags) = steam_api::detect_adult_content(&store_data);
257            details.is_adult = detected_adult;
258            if !flags.is_empty() {
259                details.adult_tags = serde_json::to_string(&flags).ok();
260            }
261            if details.description_raw.is_none() {
262                details.description_raw = Some(store_data.short_description);
263            }
264            if details.release_date.is_none() {
265                details.release_date = store_data.release_date;
266            }
267            if details.background_image.is_none() {
268                details.background_image = Some(store_data.header_image);
269            }
270        }
271
272        if let Some(reviews) = fetch_steam_reviews(steam_id, cache_conn).await {
273            details.steam_review_label = Some(reviews.review_score_desc);
274            details.steam_review_count = Some(reviews.total_reviews as i32);
275            let total = reviews.total_positive + reviews.total_negative;
276            if total > 0 {
277                details.steam_review_score =
278                    Some((reviews.total_positive as f32 / total as f32) * 100.0);
279            }
280            details.steam_review_updated_at = Some(chrono::Utc::now().to_rfc3339());
281        }
282
283        if let Some(hours) = fetch_steam_playtime(steam_id, cache_conn).await {
284            details.median_playtime = Some(hours as i32);
285            let genre_list: Vec<String> = details
286                .genres
287                .split(',')
288                .map(|s| s.trim().to_lowercase())
289                .collect();
290            if let Some(estimated_hours) =
291                playtime::estimate_playtime(Some(hours), &genre_list, &details.tags)
292            {
293                details.estimated_playtime = Some(estimated_hours as f32);
294            }
295        }
296    }
297
298    if !links_map.is_empty() {
299        details.external_links = serde_json::to_string(&links_map).ok();
300    }
301
302    (details, found_raw_tags, rawg_found)
303}
304
305// === PERSISTÊNCIA ===
306
307/// Salva detalhes do jogo no banco
308/// Aceita tanto Connection quanto Transaction (via Deref trait)
309pub(in crate::commands::metadata) fn save_game_details<C>(
310    conn: &C,
311    d: ProcessedGameDetails,
312) -> Result<(), rusqlite::Error>
313where
314    C: std::ops::Deref<Target = rusqlite::Connection>,
315{
316    let tags_json = database::serialize_tags(&d.tags).unwrap_or_else(|_| "[]".to_string());
317
318    // Garante que a linha existe antes do UPDATE (para jogos que já têm
319    // description_raw da Legacy Games, o INSERT OR IGNORE preserva o valor).
320    conn.execute(
321        "INSERT OR IGNORE INTO game_details (game_id) VALUES (?1)",
322        params![d.game_id],
323    )?;
324
325    // Atualiza todos os campos usando COALESCE nos campos de texto para nunca
326    // sobrescrever um valor existente com NULL vindo da RAWG.
327    conn.execute(
328        "UPDATE game_details SET
329            description_raw     = COALESCE(?2,  description_raw),
330            description_ptbr    = COALESCE(?3,  description_ptbr),
331            release_date        = COALESCE(?4,  release_date),
332            genres              = COALESCE(NULLIF(?5, ''), genres),
333            tags                = COALESCE(NULLIF(?6, '[]'), tags),
334            developer           = COALESCE(?7,  developer),
335            publisher           = COALESCE(?8,  publisher),
336            critic_score        = COALESCE(?9,  critic_score),
337            background_image    = COALESCE(?10, background_image),
338            series              = COALESCE(?11, series),
339            steam_review_label  = COALESCE(?12, steam_review_label),
340            steam_review_count  = COALESCE(?13, steam_review_count),
341            steam_review_score  = COALESCE(?14, steam_review_score),
342            steam_review_updated_at = COALESCE(?15, steam_review_updated_at),
343            esrb_rating         = COALESCE(?16, esrb_rating),
344            is_adult            = ?17,
345            adult_tags          = COALESCE(?18, adult_tags),
346            external_links      = COALESCE(?19, external_links),
347            steam_app_id        = COALESCE(?20, steam_app_id),
348            median_playtime     = COALESCE(?21, median_playtime),
349            estimated_playtime  = COALESCE(?22, estimated_playtime)
350         WHERE game_id = ?1",
351        params![
352            d.game_id,
353            d.description_raw,
354            d.description_ptbr,
355            d.release_date,
356            d.genres,
357            tags_json,
358            d.developer,
359            d.publisher,
360            d.critic_score,
361            d.background_image.clone(),
362            d.series,
363            d.steam_review_label,
364            d.steam_review_count,
365            d.steam_review_score,
366            d.steam_review_updated_at,
367            d.esrb_rating,
368            d.is_adult,
369            d.adult_tags,
370            d.external_links,
371            d.steam_app_id,
372            d.median_playtime,
373            d.estimated_playtime
374        ],
375    )?;
376
377    if let Some(img) = d.background_image {
378        conn.execute(
379            "UPDATE games SET cover_url = ?1 WHERE id = ?2 AND (cover_url IS NULL OR cover_url = '')",
380            params![img, d.game_id],
381        )?;
382    }
383
384    Ok(())
385}
386
387// === COMANDOS PRINCIPAIS ===
388
389/// Atualiza metadados de jogos na biblioteca (OTIMIZADO)
390#[tauri::command]
391pub async fn update_metadata(app: AppHandle) -> Result<(), AppError> {
392    let app_handle = app.clone();
393    let api_key = database::get_secret(&app, "rawg_api_key")?;
394    if api_key.is_empty() {
395        return Err(AppError::ValidationError(
396            "API Key da RAWG não configurada.".to_string(),
397        ));
398    }
399
400    tauri::async_runtime::spawn(async move {
401        info!("Iniciando enriquecimento com cache...");
402
403        let state: State<AppState> = app_handle.state();
404        let mut all_session_tags: HashSet<String> = HashSet::new();
405
406        // Limpeza de cache expirado no início
407        {
408            let cache_conn = state.cache_db.lock().unwrap();
409            let _ = cache::cleanup_expired_cache(&cache_conn);
410        }
411
412        loop {
413            // 1. Busca batch de jogos
414            let mut games_to_update: Vec<(String, String, String, Option<String>)> = {
415                let conn = match state.games_db.lock() {
416                    Ok(c) => c,
417                    Err(_) => break,
418                };
419                let mut stmt = conn
420                    .prepare(
421                        // Inclui jogos sem nenhuma entrada em game_details
422                        // E também jogos da Legacy Games que já têm description_raw da
423                        // loja, mas ainda não foram enriquecidos pela RAWG (identificados
424                        // pela ausência de genres/developer/tags).
425                        "SELECT g.id, g.name, g.platform, g.platform_game_id
426                         FROM games g
427                         LEFT JOIN game_details gd ON g.id = gd.game_id
428                         WHERE gd.game_id IS NULL
429                            OR (
430                                gd.game_id IS NOT NULL
431                                AND (gd.genres IS NULL OR gd.genres = '')
432                                AND (gd.developer IS NULL OR gd.developer = '')
433                                AND (gd.tags IS NULL OR gd.tags = '' OR gd.tags = '[]')
434                            )
435                         LIMIT ?",
436                    )
437                    .unwrap();
438
439                stmt.query_map(params![RAWG_REQUISITIONS_PER_BATCH], |row| {
440                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
441                })
442                .unwrap()
443                .flatten()
444                .collect()
445            };
446
447            if games_to_update.is_empty() {
448                break;
449            }
450
451            let skipped_rawg_miss = {
452                let cache_conn = match state.cache_db.lock() {
453                    Ok(c) => c,
454                    Err(_) => break,
455                };
456                let before = games_to_update.len();
457                games_to_update.retain(|(_, name, _, _)| {
458                    !super::shared::rawg_not_found_cached(name, &cache_conn)
459                });
460                before - games_to_update.len()
461            };
462
463            let skipped_enrich = {
464                let cache_conn = match state.cache_db.lock() {
465                    Ok(c) => c,
466                    Err(_) => break,
467                };
468                let before = games_to_update.len();
469                games_to_update
470                    .retain(|(game_id, _, _, _)| !is_enrich_skipped(game_id, &cache_conn));
471                before - games_to_update.len()
472            };
473
474            if games_to_update.is_empty() {
475                info!(
476                    "Nenhum jogo elegível para enriquecer (RAWG miss: {}, skip: {}).",
477                    skipped_rawg_miss, skipped_enrich
478                );
479                break;
480            }
481
482            let total_in_batch = games_to_update.len();
483
484            // 2. Processa batch - coleta todos os dados primeiro
485            let mut batch_results = Vec::new();
486
487            for (index, (game_id, name, platform, platform_game_id)) in
488                games_to_update.into_iter().enumerate()
489            {
490                let _ = app_handle.emit(
491                    "enrich_progress",
492                    EnrichProgress {
493                        current: (index + 1) as i32,
494                        total_found: total_in_batch as i32,
495                        last_game: name.clone(),
496                        status: "running".to_string(),
497                    },
498                );
499
500                // Processa metadados com cache
501                let (processed_data, raw_tags, rawg_found) = {
502                    let cache_conn = match state.cache_db.lock() {
503                        Ok(c) => c,
504                        Err(_) => continue,
505                    };
506
507                    // Executar TUDO com a conexão disponível e fazer await dentro do block_in_place
508                    let result = tokio::task::block_in_place(|| {
509                        let rt = tokio::runtime::Handle::current();
510                        rt.block_on(async {
511                            enrich_game_metadata(
512                                &api_key,
513                                &game_id,
514                                &name,
515                                &platform,
516                                platform_game_id.clone(),
517                                &cache_conn,
518                            )
519                            .await
520                        })
521                    });
522                    result
523                };
524
525                let should_skip = !rawg_found
526                    || (processed_data.genres.is_empty()
527                        && processed_data.developer.is_none()
528                        && processed_data.tags.is_empty());
529
530                if should_skip {
531                    if let Ok(cache_conn) = state.cache_db.lock() {
532                        mark_enrich_skipped(&game_id, &cache_conn);
533                    }
534                }
535
536                // Coleta tags da sessão
537                for tag in raw_tags {
538                    all_session_tags.insert(tag);
539                }
540
541                // Armazena resultado para salvar em batch
542                batch_results.push((name.clone(), processed_data));
543            }
544
545            // 3. Salva todos os resultados do batch numa única transação
546            {
547                if let Ok(mut conn) = state.games_db.lock() {
548                    match conn.transaction() {
549                        Ok(tx) => {
550                            let mut success_count = 0;
551                            let mut error_count = 0;
552
553                            for (game_name, processed_data) in batch_results {
554                                if let Err(e) = save_game_details(&tx, processed_data) {
555                                    warn!("Erro ao preparar salvamento de {}: {}", game_name, e);
556                                    error_count += 1;
557                                } else {
558                                    success_count += 1;
559                                }
560                            }
561
562                            // Commit de todas as alterações do batch de uma vez
563                            match tx.commit() {
564                                Ok(_) => {
565                                    info!(
566                                        "Batch salvo com sucesso: {} jogos (erros: {})",
567                                        success_count, error_count
568                                    );
569                                }
570                                Err(e) => {
571                                    warn!("Erro ao commitar transação do batch: {}", e);
572                                }
573                            }
574                        }
575                        Err(e) => {
576                            warn!("Erro ao iniciar transação: {}", e);
577                        }
578                    }
579                }
580            }
581
582            // 4. Rate limit entre batches
583            sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
584        }
585
586        let _ = crate::services::tags::generate_analysis_report(&app_handle, all_session_tags);
587        let _ = app_handle.emit("enrich_complete", "Metadados atualizados!");
588    });
589
590    Ok(())
591}