Skip to main content

game_manager_lib/commands/metadata/
get_metadata.rs

1//! Preenchimento de campos de metadados faltantes via RAWG.
2//!
3//! Diferente do fluxo de enriquecimento inicial (`enrichment.rs`), este módulo
4//! foca em jogos que já foram importados mas ainda têm lacunas nos metadados
5//! (genres, developer, tags, description, etc.).
6//!
7//! Design notes:
8//! - Ignora o cache da RAWG (`fetch_rawg_metadata_fresh`) para garantir dados atualizados.
9//! - Usa `COALESCE` via `save_game_details` — nunca sobrescreve campos existentes com NULL.
10//! - Reutiliza `ProcessedGameDetails` e `save_game_details` de `enrichment.rs`.
11
12use super::enrichment::{save_game_details, ProcessedGameDetails};
13use super::shared::{
14    fetch_rawg_metadata_fresh, fetch_steam_playtime, fetch_steam_reviews, fetch_steam_store_data,
15    resolve_steam_app_id, EnrichProgress,
16};
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::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
31/// Preenche campos de metadados vazios consultando a RAWG (sem cache).
32///
33/// - Processa **todos** os jogos que ainda têm pelo menos um campo vazio:
34///   `genres`, `developer`, `tags`, `description_raw`, `release_date` ou `background_image`.
35/// - **Ignora o cache** da RAWG para garantir dados atualizados.
36/// - **Nunca sobrescreve** campos que já têm valor — apenas preenche lacunas.
37///
38/// Ideal para rodar após importação parcial (ex: Legacy Games) ou quando a RAWG
39/// atualizar o catálogo após a última sincronização.
40#[tauri::command]
41pub async fn fill_missing_metadata(app: AppHandle) -> Result<(), AppError> {
42    let app_handle = app.clone();
43    let api_key = database::get_secret(&app, "rawg_api_key")?;
44
45    if api_key.is_empty() {
46        return Err(AppError::ValidationError(
47            "API Key da RAWG não configurada.".to_string(),
48        ));
49    }
50
51    tauri::async_runtime::spawn(async move {
52        info!("Iniciando preenchimento de campos vazios (fresh RAWG)...");
53
54        let state: State<AppState> = app_handle.state();
55        let mut all_session_tags: HashSet<String> = HashSet::new();
56        // IDs já processados nesta sessão — evita re-processar jogos cujos dados simplesmente não existem na RAWG.
57        let mut processed_ids: HashSet<String> = HashSet::new();
58
59        loop {
60            // 1. Seleciona jogos com ao menos um campo de metadados vazio, excluindo os que já foram tentados nesta sessão.
61            let games_to_fill: Vec<(String, String, String, Option<String>)> = {
62                let conn = match state.games_db.lock() {
63                    Ok(c) => c,
64                    Err(_) => break,
65                };
66
67                // Monta cláusula de exclusão dinâmica para os IDs já processados
68                let exclusions = if processed_ids.is_empty() {
69                    String::new()
70                } else {
71                    let placeholders: Vec<String> = processed_ids
72                        .iter()
73                        .enumerate()
74                        .map(|(i, _)| format!("?{}", i + 2)) // +2 porque ?1 = LIMIT
75                        .collect();
76                    format!(" AND g.id NOT IN ({})", placeholders.join(", "))
77                };
78
79                let sql = format!(
80                    "SELECT g.id, g.name, g.platform, g.platform_game_id
81                     FROM games g
82                     LEFT JOIN game_details gd ON g.id = gd.game_id
83                     WHERE (
84                         gd.game_id IS NULL
85                         OR gd.genres           IS NULL OR gd.genres           = ''
86                         OR gd.developer        IS NULL OR gd.developer        = ''
87                         OR gd.tags             IS NULL OR gd.tags             = '' OR gd.tags = '[]'
88                         OR gd.description_raw  IS NULL OR gd.description_raw  = ''
89                         OR gd.release_date     IS NULL OR gd.release_date     = ''
90                         OR gd.background_image IS NULL OR gd.background_image = ''
91                     ){}
92                     LIMIT ?1",
93                    exclusions
94                );
95
96                let mut stmt = match conn.prepare(&sql) {
97                    Ok(s) => s,
98                    Err(_) => break,
99                };
100
101                // Parâmetros: primeiro o LIMIT, depois os IDs excluídos
102                let excluded_ids: Vec<String> = processed_ids.iter().cloned().collect();
103                let limit_val = RAWG_REQUISITIONS_PER_BATCH;
104
105                let result = if excluded_ids.is_empty() {
106                    stmt.query_map(params![limit_val], |row| {
107                        Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
108                    })
109                    .unwrap()
110                    .flatten()
111                    .collect::<Vec<_>>()
112                } else {
113                    use rusqlite::types::ToSql;
114                    let mut bind: Vec<Box<dyn ToSql>> = vec![Box::new(limit_val)];
115                    for id in &excluded_ids {
116                        bind.push(Box::new(id.clone()));
117                    }
118                    let refs: Vec<&dyn ToSql> = bind.iter().map(|b| b.as_ref()).collect();
119                    stmt.query_map(refs.as_slice(), |row| {
120                        Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
121                    })
122                    .unwrap()
123                    .flatten()
124                    .collect::<Vec<_>>()
125                };
126
127                result
128            };
129
130            if games_to_fill.is_empty() {
131                break;
132            }
133
134            let total_in_batch = games_to_fill.len();
135            let mut batch_results = Vec::new();
136
137            // 2. Processa cada jogo do batch
138            for (index, (game_id, name, platform, platform_game_id)) in
139                games_to_fill.into_iter().enumerate()
140            {
141                // Marca como processado imediatamente — mesmo que a RAWRG retorne dados, este jogo não será selecionado novamente.
142                processed_ids.insert(game_id.clone());
143                let _ = app_handle.emit(
144                    "enrich_progress",
145                    EnrichProgress {
146                        current: (index + 1) as i32,
147                        total_found: total_in_batch as i32,
148                        last_game: name.clone(),
149                        status: "running".to_string(),
150                    },
151                );
152
153                let (processed_data, raw_tags) = {
154                    let cache_conn = match state.cache_db.lock() {
155                        Ok(c) => c,
156                        Err(_) => continue,
157                    };
158
159                    tokio::task::block_in_place(|| {
160                        let rt = tokio::runtime::Handle::current();
161                        rt.block_on(async {
162                            let series_name = series::infer_series(&name);
163                            let mut details = ProcessedGameDetails {
164                                game_id: game_id.clone(),
165                                description_raw: None,
166                                description_ptbr: None,
167                                release_date: None,
168                                genres: String::new(),
169                                tags: Vec::new(),
170                                developer: None,
171                                publisher: None,
172                                critic_score: None,
173                                background_image: None,
174                                series: series_name,
175                                steam_review_label: None,
176                                steam_review_count: None,
177                                steam_review_score: None,
178                                steam_review_updated_at: None,
179                                esrb_rating: None,
180                                is_adult: false,
181                                adult_tags: None,
182                                external_links: None,
183                                steam_app_id: None,
184                                median_playtime: None,
185                                estimated_playtime: None,
186                            };
187
188                            let mut links_map: HashMap<String, String> = HashMap::new();
189                            let mut found_raw_tags: Vec<String> = Vec::new();
190
191                            // 1. Resolução do Steam App ID — cobre tanto jogos Steam (direto do platform_game_id) quanto
192                            // jogos de outras plataformas (via busca por nome na Steam Store Search), com cache de tentativas sem sucesso.
193                            let target_steam_id =
194                                resolve_steam_app_id(&name, &platform, platform_game_id.as_deref(), &cache_conn)
195                                    .await
196                                    .map(|resolution| resolution.app_id);
197
198                            // 2a. Busca na RAWG ignorando o cache
199                            if let Some(rawg_det) =
200                                fetch_rawg_metadata_fresh(&api_key, &name, &cache_conn).await
201                            {
202                                found_raw_tags =
203                                    rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
204                                let raw_tag_slugs: Vec<String> =
205                                    rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
206
207                                details.description_raw = rawg_det.description_raw;
208                                details.release_date = rawg_det.released;
209                                details.genres = rawg_det
210                                    .genres
211                                    .iter()
212                                    .map(|g| g.name.clone())
213                                    .collect::<Vec<_>>()
214                                    .join(", ");
215                                details.tags = crate::services::tags::classify_and_sort_tags(
216                                    raw_tag_slugs,
217                                    10,
218                                );
219                                details.developer =
220                                    rawg_det.developers.first().map(|d| d.name.clone());
221                                details.publisher =
222                                    rawg_det.publishers.first().map(|p| p.name.clone());
223                                details.critic_score = rawg_det.metacritic;
224                                details.background_image = rawg_det.background_image;
225                                details.esrb_rating =
226                                    rawg_det.esrb_rating.as_ref().map(|r| r.name.clone());
227
228                                if let Some(url) = &rawg_det.website {
229                                    links_map.insert("website".to_string(), url.clone());
230                                }
231                                if let Some(url) = &rawg_det.reddit_url {
232                                    links_map.insert("reddit".to_string(), url.clone());
233                                }
234                                if let Some(url) = &rawg_det.metacritic_url {
235                                    links_map.insert("metacritic".to_string(), url.clone());
236                                }
237                                links_map.insert(
238                                    "rawg".to_string(),
239                                    format!("https://rawg.io/games/{}", rawg_det.id),
240                                );
241                            } else {
242                                warn!(
243                                    game = %name,
244                                    "RAWG não retornou dados — jogo será ignorado nas próximas iterações"
245                                );
246                            }
247
248                            // 2b. Steam como fallback / complemento
249                            if let Some(steam_id) = &target_steam_id {
250                                if !links_map.contains_key("steam") {
251                                    links_map.insert(
252                                        "steam".to_string(),
253                                        format!("https://store.steampowered.com/app/{}", steam_id),
254                                    );
255                                }
256                                details.steam_app_id = Some(steam_id.clone());
257
258                                if let Some(store_data) =
259                                    fetch_steam_store_data(steam_id, &cache_conn).await
260                                {
261                                    let (detected_adult, flags) =
262                                        steam_api::detect_adult_content(&store_data);
263                                    details.is_adult = detected_adult;
264                                    if !flags.is_empty() {
265                                        details.adult_tags = serde_json::to_string(&flags).ok();
266                                    }
267                                    if details.description_raw.is_none() {
268                                        details.description_raw =
269                                            Some(store_data.short_description);
270                                    }
271                                    if details.release_date.is_none() {
272                                        details.release_date = store_data.release_date;
273                                    }
274                                    if details.background_image.is_none() {
275                                        details.background_image = Some(store_data.header_image);
276                                    }
277                                }
278
279                                if let Some(reviews) =
280                                    fetch_steam_reviews(steam_id, &cache_conn).await
281                                {
282                                    details.steam_review_label = Some(reviews.review_score_desc);
283                                    details.steam_review_count = Some(reviews.total_reviews as i32);
284                                    let total = reviews.total_positive + reviews.total_negative;
285                                    if total > 0 {
286                                        details.steam_review_score = Some(
287                                            (reviews.total_positive as f32 / total as f32) * 100.0,
288                                        );
289                                    }
290                                    details.steam_review_updated_at =
291                                        Some(chrono::Utc::now().to_rfc3339());
292                                }
293
294                                if let Some(hours) =
295                                    fetch_steam_playtime(steam_id, &cache_conn).await
296                                {
297                                    details.median_playtime = Some(hours as i32);
298                                    let genre_list: Vec<String> = details
299                                        .genres
300                                        .split(',')
301                                        .map(|s| s.trim().to_lowercase())
302                                        .collect();
303                                    if let Some(estimated) = playtime::estimate_playtime(
304                                        Some(hours),
305                                        &genre_list,
306                                        &details.tags,
307                                    ) {
308                                        details.estimated_playtime = Some(estimated as f32);
309                                    }
310                                }
311                            }
312
313                            if !links_map.is_empty() {
314                                details.external_links = serde_json::to_string(&links_map).ok();
315                            }
316
317                            (details, found_raw_tags)
318                        })
319                    })
320                };
321
322                for tag in raw_tags {
323                    all_session_tags.insert(tag);
324                }
325                batch_results.push((name.clone(), processed_data));
326            }
327
328            // 3. Persiste o batch numa única transação
329            // save_game_details usa COALESCE → nunca sobrescreve campos existentes
330            if let Ok(mut conn) = state.games_db.lock() {
331                match conn.transaction() {
332                    Ok(tx) => {
333                        let mut success_count = 0;
334                        let mut error_count = 0;
335
336                        for (game_name, processed_data) in batch_results {
337                            if let Err(e) = save_game_details(&tx, processed_data) {
338                                warn!("fill_missing: erro ao salvar {}: {}", game_name, e);
339                                error_count += 1;
340                            } else {
341                                success_count += 1;
342                            }
343                        }
344
345                        match tx.commit() {
346                            Ok(_) => info!(
347                                "fill_missing batch: {} ok, {} erros",
348                                success_count, error_count
349                            ),
350                            Err(e) => warn!("fill_missing: commit falhou: {}", e),
351                        }
352                    }
353                    Err(e) => warn!("fill_missing: transação falhou: {}", e),
354                }
355            }
356
357            // 4. Rate limit entre batches
358            sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
359        }
360
361        let _ = crate::services::tags::generate_analysis_report(&app_handle, all_session_tags);
362        let _ = app_handle.emit("enrich_complete", "Campos vazios preenchidos!");
363        info!("fill_missing_metadata concluído.");
364    });
365
366    Ok(())
367}