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 crate::commands::metadata::shared::{
13    fetch_rawg_metadata_fresh, fetch_steam_reviews, fetch_steam_store_data, resolve_steam_app_id,
14    save_game_details, EnrichCompletePayload, EnrichProgress, ProcessedGameDetails,
15};
16use crate::constants::{RAWG_RATE_LIMIT_MS, RAWG_REQUISITIONS_PER_BATCH};
17use crate::database;
18use crate::database::AppState;
19use crate::errors::AppError;
20use crate::services::integration::nexus::{find_best_nexus_match, NexusGame};
21use crate::services::integration::steam_api;
22use crate::utils::series;
23use rusqlite::Connection;
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/// Retorna um lote de jogos que possuem campos vazios, ignorando os IDs que já foram processados.
31fn get_games_to_fill(
32    conn: &Connection,
33    processed_ids: &HashSet<String>,
34    limit_val: u32,
35) -> Vec<(String, String, String, Option<String>)> {
36    let exclusions = if processed_ids.is_empty() {
37        String::new()
38    } else {
39        let placeholders: Vec<String> = processed_ids
40            .iter()
41            .enumerate()
42            .map(|(i, _)| format!("?{}", i + 2)) // +2 porque ?1 = LIMIT
43            .collect();
44        format!(" AND g.id NOT IN ({})", placeholders.join(", "))
45    };
46
47    let sql = format!(
48        "SELECT g.id, g.name, g.platform, g.platform_game_id
49         FROM games g
50         LEFT JOIN game_details gd ON g.id = gd.game_id
51         WHERE (
52             gd.game_id IS NULL
53             OR gd.genres           IS NULL OR gd.genres           = ''
54             OR gd.developer        IS NULL OR gd.developer        = ''
55             OR gd.tags             IS NULL OR gd.tags             = '' OR gd.tags = '[]'
56             OR gd.description_raw  IS NULL OR gd.description_raw  = ''
57             OR gd.release_date     IS NULL OR gd.release_date     = ''
58             OR gd.background_image IS NULL OR gd.background_image = ''
59         ){}
60         LIMIT ?1",
61        exclusions
62    );
63
64    let mut stmt = match conn.prepare(&sql) {
65        Ok(s) => s,
66        Err(_) => return vec![],
67    };
68
69    let excluded_ids: Vec<String> = processed_ids.iter().cloned().collect();
70
71    if excluded_ids.is_empty() {
72        if let Ok(rows) = stmt.query_map(rusqlite::params![limit_val], |row| {
73            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
74        }) {
75            rows.flatten().collect()
76        } else {
77            vec![]
78        }
79    } else {
80        use rusqlite::types::ToSql;
81        let mut bind: Vec<Box<dyn ToSql>> = vec![Box::new(limit_val)];
82        for id in &excluded_ids {
83            bind.push(Box::new(id.clone()));
84        }
85        let refs: Vec<&dyn ToSql> = bind.iter().map(|b| b.as_ref()).collect();
86        if let Ok(rows) = stmt.query_map(refs.as_slice(), |row| {
87            Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
88        }) {
89            rows.flatten().collect()
90        } else {
91            vec![]
92        }
93    }
94}
95
96/// Processa um único jogo buscando dados novos na RAWG (sem cache) e na Steam.
97async fn process_missing_metadata(
98    api_key: &str,
99    game_id: String,
100    name: String,
101    platform: String,
102    platform_game_id: Option<String>,
103    cache_conn: &Connection,
104    nexus_games: &[NexusGame],
105) -> (ProcessedGameDetails, Vec<String>) {
106    let series_name = series::infer_series(&name);
107    let mut details = ProcessedGameDetails {
108        game_id: game_id.clone(),
109        description_raw: None,
110        description_ptbr: None,
111        release_date: None,
112        genres: String::new(),
113        tags: Vec::new(),
114        developer: None,
115        publisher: None,
116        critic_score: None,
117        alternative_names: None,
118        background_image: None,
119        series: series_name,
120        steam_review_label: None,
121        steam_review_count: None,
122        steam_review_score: None,
123        steam_review_updated_at: None,
124        esrb_rating: None,
125        is_adult: false,
126        adult_tags: None,
127        external_links: None,
128        steam_app_id: None,
129        median_playtime: None,
130        estimated_playtime: None,
131        updated_at: Some(chrono::Utc::now().to_rfc3339()),
132    };
133
134    let mut links_map: HashMap<String, String> = HashMap::new();
135    let mut found_raw_tags: Vec<String> = Vec::new();
136
137    if let Some(nexus_match) = find_best_nexus_match(&name, nexus_games) {
138        let url = format!("https://www.nexusmods.com/{}", nexus_match.domain_name);
139        links_map.insert("nexus".to_string(), url);
140    }
141
142    let rawg_future = fetch_rawg_metadata_fresh(api_key, &name, cache_conn);
143
144    let steam_future = async {
145        let target_steam_id =
146            resolve_steam_app_id(&name, &platform, platform_game_id.as_deref(), cache_conn)
147                .await
148                .map(|resolution| resolution.app_id);
149
150        match &target_steam_id {
151            Some(steam_id) => {
152                let (store_data, reviews) = tokio::join!(
153                    fetch_steam_store_data(steam_id, cache_conn),
154                    fetch_steam_reviews(steam_id, cache_conn)
155                );
156                (target_steam_id, store_data, reviews)
157            }
158            None => (target_steam_id, None, None),
159        }
160    };
161
162    let (rawg_result, (target_steam_id, store_data, reviews)) =
163        tokio::join!(rawg_future, steam_future);
164
165    if let Some(rawg_det) = rawg_result {
166        found_raw_tags = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
167        let raw_tag_slugs: Vec<String> = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
168
169        details.description_raw = rawg_det.description_raw;
170        details.release_date = rawg_det.released;
171        details.genres = rawg_det
172            .genres
173            .into_iter()
174            .map(|g| g.name)
175            .collect::<Vec<_>>()
176            .join(", ");
177        details.tags = crate::services::tags::classify_and_sort_tags(raw_tag_slugs, 10);
178        details.developer = rawg_det.developers.first().map(|d| d.name.clone());
179        details.publisher = rawg_det.publishers.first().map(|p| p.name.clone());
180        details.critic_score = rawg_det.metacritic;
181        details.background_image = rawg_det.background_image;
182        details.esrb_rating = rawg_det.esrb_rating.map(|r| r.name);
183
184        if !rawg_det.alternative_names.is_empty() {
185            details.alternative_names = Some(rawg_det.alternative_names.clone());
186        }
187
188        if let Some(url) = rawg_det.website {
189            links_map.insert("website".to_string(), url);
190        }
191        if let Some(url) = rawg_det.reddit_url {
192            links_map.insert("reddit".to_string(), url);
193        }
194        if let Some(url) = rawg_det.metacritic_url {
195            links_map.insert("metacritic".to_string(), url);
196        }
197        links_map.insert(
198            "rawg".to_string(),
199            format!("https://rawg.io/games/{}", rawg_det.id),
200        );
201    }
202
203    if let Some(steam_id) = &target_steam_id {
204        links_map
205            .entry("steam".to_string())
206            .or_insert_with(|| format!("https://store.steampowered.com/app/{}", steam_id));
207        details.steam_app_id = Some(steam_id.clone());
208
209        if let Some(store_data) = store_data {
210            let (detected_adult, flags) = steam_api::detect_adult_content(&store_data);
211            details.is_adult = detected_adult;
212            if !flags.is_empty() {
213                details.adult_tags = serde_json::to_string(&flags).ok();
214            }
215            if details.description_raw.is_none() {
216                details.description_raw = Some(store_data.short_description);
217            }
218            if details.release_date.is_none() {
219                details.release_date = store_data.release_date;
220            }
221            if details.background_image.is_none() {
222                details.background_image = Some(store_data.header_image);
223            }
224        }
225
226        if let Some(reviews) = reviews {
227            details.steam_review_label = Some(reviews.review_score_desc);
228            details.steam_review_count = Some(reviews.total_reviews as i32);
229            let total = reviews.total_positive + reviews.total_negative;
230            if total > 0 {
231                details.steam_review_score =
232                    Some((reviews.total_positive as f32 / total as f32) * 100.0);
233            }
234            details.steam_review_updated_at = Some(chrono::Utc::now().to_rfc3339());
235        }
236    }
237
238    if !links_map.is_empty() {
239        details.external_links = serde_json::to_string(&links_map).ok();
240    }
241
242    (details, found_raw_tags)
243}
244
245#[tauri::command]
246pub async fn fill_missing_metadata(app: AppHandle) -> Result<(), AppError> {
247    let app_handle = app.clone();
248    let api_key = database::get_secret(&app, "rawg_api_key")?;
249
250    if api_key.is_empty() {
251        return Err(AppError::ValidationError(
252            "API Key da RAWG não configurada.".to_string(),
253        ));
254    }
255
256    tauri::async_runtime::spawn(async move {
257        info!("Iniciando preenchimento de campos vazios (fresh RAWG)...");
258
259        let state: State<AppState> = app_handle.state();
260        let mut all_session_tags: HashSet<String> = HashSet::new();
261        let mut processed_ids: HashSet<String> = HashSet::new();
262
263        // Carrega o catálogo do Nexus do cache
264        let nexus_games: Vec<NexusGame> = state
265            .cache_db
266            .lock()
267            .ok()
268            .and_then(|conn| crate::services::cache::get_cached_nexus_games(&conn).ok())
269            .unwrap_or_default();
270
271        loop {
272            // 1. Busca os jogos usando nossa nova função de extração
273            let games_to_fill = match state.games_db.lock() {
274                Ok(conn) => get_games_to_fill(&conn, &processed_ids, RAWG_REQUISITIONS_PER_BATCH),
275                Err(_) => break,
276            };
277
278            if games_to_fill.is_empty() {
279                break;
280            }
281
282            let total_in_batch = games_to_fill.len();
283            let mut batch_results = Vec::new();
284
285            // 2. Processa cada jogo
286            for (index, (game_id, name, platform, platform_game_id)) in
287                games_to_fill.into_iter().enumerate()
288            {
289                processed_ids.insert(game_id.clone());
290
291                let _ = app_handle.emit(
292                    "enrich_progress",
293                    EnrichProgress {
294                        current: (index + 1) as i32,
295                        total_found: total_in_batch as i32,
296                        last_game: name.clone(),
297                        status: "running".to_string(),
298                        platform: None,
299                    },
300                );
301
302                let cache_conn = match state.cache_db.lock() {
303                    Ok(c) => c,
304                    Err(_) => continue,
305                };
306
307                // Executa a requisição assíncrona usando o novo helper
308                let (processed_data, raw_tags) = tokio::task::block_in_place(|| {
309                    let rt = tokio::runtime::Handle::current();
310                    rt.block_on(async {
311                        process_missing_metadata(
312                            &api_key,
313                            game_id,
314                            name.clone(),
315                            platform,
316                            platform_game_id,
317                            &cache_conn,
318                            &nexus_games,
319                        )
320                            .await
321                    })
322                });
323
324                for tag in raw_tags {
325                    all_session_tags.insert(tag);
326                }
327                batch_results.push((name, processed_data));
328            }
329
330            // 3. Persiste o batch numa única transação usando save_game_details
331            if let Ok(mut conn) = state.games_db.lock() {
332                if let Ok(tx) = conn.transaction() {
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            }
354
355            sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
356        }
357
358        let _ = crate::services::tags::generate_analysis_report(&app_handle, all_session_tags);
359        let _ = app_handle.emit(
360            "enrich_complete",
361            EnrichCompletePayload {
362                platform: None,
363                message: "Campos vazios preenchidos!".to_string(),
364            },
365        );
366
367        info!("fill_missing_metadata concluído.");
368    });
369
370    Ok(())
371}