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_reviews, fetch_steam_store_data, resolve_steam_app_id,
14    save_game_details, EnrichCompletePayload, EnrichProgress, ProcessedGameDetails,
15};
16use crate::commands::platforms::core::NewlyImportedGame;
17use crate::constants::RAWG_RATE_LIMIT_MS;
18use crate::database;
19use crate::database::AppState;
20use crate::services::cache;
21use crate::services::integration::nexus::{find_best_nexus_match, NexusGame};
22use crate::services::integration::steam_api;
23use crate::utils::series;
24use std::collections::{HashMap, HashSet};
25use std::time::Duration;
26use tauri::{AppHandle, Emitter, Manager, State};
27use tokio::time::sleep;
28use tracing::{info, warn};
29
30// === ESTRUTURAS DE DADOS ===
31
32#[derive(serde::Serialize)]
33pub struct ImportSummary {
34    pub success_count: i32,
35    pub error_count: i32,
36    pub total_processed: i32,
37    pub message: String,
38    pub errors: Vec<String>,
39}
40
41// === LÓGICA CORE (REFATORADA) ===
42
43pub async fn enrich_newly_imported(app: AppHandle, games: Vec<NewlyImportedGame>) {
44    let api_key = match database::get_secret(&app, "rawg_api_key") {
45        Ok(key) if !key.is_empty() => key,
46        _ => {
47            warn!("Enrichment pós-import ignorado: API Key da RAWG não configurada.");
48            return;
49        }
50    };
51
52    // Todos os jogos do lote vêm da mesma importação — extrai a plataforma antes de consumir a lista no loop.
53    let platform_label = games.first().map(|g| g.platform.clone());
54
55    let state: State<AppState> = app.state();
56    let total = games.len();
57    let mut all_session_tags: HashSet<String> = HashSet::new();
58    let mut batch_results = Vec::new();
59
60    // Carrega o catálogo do Nexus uma única vez, direto do cache local
61    let nexus_games: Vec<NexusGame> = state
62        .cache_db
63        .lock()
64        .ok()
65        .and_then(|conn| cache::get_cached_nexus_games(&conn).ok())
66        .unwrap_or_default();
67
68    // loop de processamento
69    for (index, game) in games.into_iter().enumerate() {
70        let _ = app.emit(
71            "enrich_progress",
72            EnrichProgress {
73                current: (index + 1) as i32,
74                total_found: total as i32,
75                last_game: game.name.clone(),
76                status: "running".to_string(),
77                platform: platform_label.clone(),
78            },
79        );
80
81        let (processed_data, raw_tags, _rawg_found) = {
82            let cache_conn = match state.cache_db.lock() {
83                Ok(c) => c,
84                Err(_) => continue,
85            };
86            tokio::task::block_in_place(|| {
87                let rt = tokio::runtime::Handle::current();
88                rt.block_on(async {
89                    enrich_game_metadata(
90                        &api_key,
91                        &game.game_id,
92                        &game.name,
93                        &game.platform,
94                        Some(game.platform_game_id.clone()),
95                        &cache_conn,
96                        &nexus_games,
97                    )
98                        .await
99                })
100            })
101        };
102
103        for tag in raw_tags {
104            all_session_tags.insert(tag);
105        }
106        batch_results.push((game.name, processed_data));
107
108        sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
109    }
110
111    // bloco de persistência/transação
112    if let Ok(mut conn) = state.games_db.lock() {
113        if let Ok(tx) = conn.transaction() {
114            let mut success = 0;
115            let mut errors = 0;
116            for (name, data) in batch_results {
117                if let Err(e) = save_game_details(&tx, data) {
118                    warn!("enrich_newly_imported: erro ao salvar {}: {}", name, e);
119                    errors += 1;
120                } else {
121                    success += 1;
122                }
123            }
124            match tx.commit() {
125                Ok(_) => info!("enrich_newly_imported: {} ok, {} erros", success, errors),
126                Err(e) => warn!("enrich_newly_imported: commit falhou: {}", e),
127            }
128        }
129    }
130
131    let _ = crate::services::tags::generate_analysis_report(&app, all_session_tags);
132
133    let message = match &platform_label {
134        Some(platform) => format!("{}: enriquecimento concluído.", platform),
135        None => "Enriquecimento pós-import concluído.".to_string(),
136    };
137    let _ = app.emit(
138        "enrich_complete",
139        EnrichCompletePayload {
140            platform: platform_label,
141            message,
142        },
143    );
144}
145
146/// Processa um único jogo com cache integrado (sem manter lock)
147async fn enrich_game_metadata(
148    api_key: &str,
149    game_id: &str,
150    name: &str,
151    platform: &str,
152    platform_game_id: Option<String>,
153    cache_conn: &rusqlite::Connection,
154    nexus_games: &[NexusGame],
155) -> (ProcessedGameDetails, Vec<String>, bool) {
156    let series_name = series::infer_series(name);
157    let mut details = ProcessedGameDetails {
158        game_id: game_id.to_string(),
159        description_raw: None,
160        description_ptbr: None,
161        release_date: None,
162        genres: String::new(),
163        tags: Vec::new(),
164        developer: None,
165        publisher: None,
166        critic_score: None,
167        background_image: None,
168        series: series_name,
169        steam_review_label: None,
170        steam_review_count: None,
171        steam_review_score: None,
172        steam_review_updated_at: None,
173        esrb_rating: None,
174        is_adult: false,
175        adult_tags: None,
176        external_links: None,
177        steam_app_id: None,
178        median_playtime: None,
179        estimated_playtime: None,
180        alternative_names: None,
181        updated_at: Some(chrono::Utc::now().to_rfc3339()),
182    };
183
184    let mut links_map: HashMap<String, String> = HashMap::new();
185
186    if let Some(nexus_match) = find_best_nexus_match(name, nexus_games) {
187        let url = format!("https://www.nexusmods.com/{}", nexus_match.domain_name);
188        links_map.insert("nexus".to_string(), url);
189    }
190
191    // A cadeia Steam (resolve ID → store_data + reviews em paralelo) não depende da RAWG,
192    // então roda inteira em paralelo com ela. Dentro da cadeia Steam, store_data e reviews
193    // só dependem do ID resolvido, não uma da outra.
194    let rawg_future = fetch_rawg_metadata(api_key, name, cache_conn);
195
196    let steam_future = async {
197        let target_steam_id =
198            resolve_steam_app_id(name, platform, platform_game_id.as_deref(), cache_conn)
199                .await
200                .map(|resolution| resolution.app_id);
201
202        match &target_steam_id {
203            Some(steam_id) => {
204                let (store_data, reviews) = tokio::join!(
205                    fetch_steam_store_data(steam_id, cache_conn),
206                    fetch_steam_reviews(steam_id, cache_conn)
207                );
208                (target_steam_id, store_data, reviews)
209            }
210            None => (target_steam_id, None, None),
211        }
212    };
213
214    let (rawg_result, (target_steam_id, store_data, reviews)) =
215        tokio::join!(rawg_future, steam_future);
216
217    let mut found_raw_tags: Vec<String> = Vec::new();
218    let mut rawg_found = false;
219
220    if let Some(rawg_det) = rawg_result {
221        rawg_found = true;
222        found_raw_tags = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
223
224        let raw_tag_slugs: Vec<String> = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
225
226        details.description_raw = rawg_det.description_raw;
227        details.release_date = rawg_det.released;
228        details.genres = rawg_det
229            .genres
230            .iter()
231            .map(|g| g.name.clone())
232            .collect::<Vec<_>>()
233            .join(", ");
234        details.tags = crate::services::tags::classify_and_sort_tags(raw_tag_slugs, 10);
235        details.developer = rawg_det.developers.first().map(|d| d.name.clone());
236        details.publisher = rawg_det.publishers.first().map(|p| p.name.clone());
237        details.critic_score = rawg_det.metacritic;
238        details.background_image = rawg_det.background_image;
239        details.esrb_rating = rawg_det.esrb_rating.as_ref().map(|r| r.name.clone());
240
241        if !rawg_det.alternative_names.is_empty() {
242            details.alternative_names = Some(rawg_det.alternative_names.clone());
243        }
244
245        if let Some(url) = &rawg_det.website {
246            links_map.insert("website".to_string(), url.clone());
247        }
248        if let Some(url) = &rawg_det.reddit_url {
249            links_map.insert("reddit".to_string(), url.clone());
250        }
251        if let Some(url) = &rawg_det.metacritic_url {
252            links_map.insert("metacritic".to_string(), url.clone());
253        }
254        links_map.insert(
255            "rawg".to_string(),
256            format!("https://rawg.io/games/{}", rawg_det.id),
257        );
258    }
259
260    if let Some(steam_id) = &target_steam_id {
261        links_map
262            .entry("steam".to_string())
263            .or_insert_with(|| format!("https://store.steampowered.com/app/{}", steam_id));
264        details.steam_app_id = Some(steam_id.clone());
265
266        if let Some(store_data) = store_data {
267            let (detected_adult, flags) = steam_api::detect_adult_content(&store_data);
268            details.is_adult = detected_adult;
269            if !flags.is_empty() {
270                details.adult_tags = serde_json::to_string(&flags).ok();
271            }
272            if details.description_raw.is_none() {
273                details.description_raw = Some(store_data.short_description);
274            }
275            if details.release_date.is_none() {
276                details.release_date = store_data.release_date;
277            }
278            if details.background_image.is_none() {
279                details.background_image = Some(store_data.header_image);
280            }
281        }
282
283        if let Some(reviews) = reviews {
284            details.steam_review_label = Some(reviews.review_score_desc);
285            details.steam_review_count = Some(reviews.total_reviews as i32);
286            let total = reviews.total_positive + reviews.total_negative;
287            if total > 0 {
288                details.steam_review_score =
289                    Some((reviews.total_positive as f32 / total as f32) * 100.0);
290            }
291            details.steam_review_updated_at = Some(chrono::Utc::now().to_rfc3339());
292        }
293    }
294
295    if !links_map.is_empty() {
296        details.external_links = serde_json::to_string(&links_map).ok();
297    }
298
299    (details, found_raw_tags, rawg_found)
300}