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::{fetch_rawg_metadata, EnrichProgress};
13use crate::constants::{RAWG_RATE_LIMIT_MS, RAWG_REQUISITIONS_PER_BATCH};
14use crate::database;
15use crate::database::AppState;
16use crate::errors::AppError;
17use crate::services::{cache, playtime, steam};
18use crate::utils::series;
19use rusqlite::params;
20use std::collections::{HashMap, HashSet};
21use std::time::Duration;
22use tauri::{AppHandle, Emitter, Manager, State};
23use tokio::time::sleep;
24use tracing::{info, warn};
25
26// === ESTRUTURAS DE DADOS ===
27
28#[derive(serde::Serialize)]
29pub struct ImportSummary {
30    pub success_count: i32,
31    pub error_count: i32,
32    pub total_processed: i32,
33    pub message: String,
34    pub errors: Vec<String>,
35}
36
37/// Estrutura intermediária
38struct ProcessedGameDetails {
39    game_id: String,
40    description_raw: Option<String>,
41    description_ptbr: Option<String>,
42    release_date: Option<String>,
43    genres: String,
44    tags: Vec<crate::models::GameTag>,
45    developer: Option<String>,
46    publisher: Option<String>,
47    critic_score: Option<i32>,
48    background_image: Option<String>,
49    series: Option<String>,
50    steam_review_label: Option<String>,
51    steam_review_count: Option<i32>,
52    steam_review_score: Option<f32>,
53    steam_review_updated_at: Option<String>,
54    esrb_rating: Option<String>,
55    is_adult: bool,
56    adult_tags: Option<String>,
57    external_links: Option<String>,
58    steam_app_id: Option<String>,
59    median_playtime: Option<i32>,
60    estimated_playtime: Option<f32>,
61}
62
63// === FUNÇÕES AUXILIARES ===
64
65fn extract_steam_id_from_url(url: &str) -> Option<String> {
66    if url.contains("store.steampowered.com/app/") {
67        let parts: Vec<&str> = url.split("/app/").collect();
68        if let Some(right_part) = parts.get(1) {
69            let id_part: String = right_part.chars().take_while(|c| c.is_numeric()).collect();
70            if !id_part.is_empty() {
71                return Some(id_part);
72            }
73        }
74    }
75    None
76}
77
78// === ENRIQUECIMENTO COM CACHE ===
79
80/// Busca dados Steam Store com cache
81async fn fetch_steam_store_data(
82    steam_id: &str,
83    cache_conn: &rusqlite::Connection,
84) -> Option<steam::SteamStoreData> {
85    let cache_key = format!("store_{}", steam_id);
86
87    if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
88        if let Ok(data) = serde_json::from_str::<steam::SteamStoreData>(&cached) {
89            return Some(data);
90        }
91    }
92
93    match steam::get_app_details(steam_id).await {
94        Ok(Some(data)) => {
95            if let Ok(json) = serde_json::to_string(&data) {
96                let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
97            }
98            Some(data)
99        }
100        _ => None,
101    }
102}
103
104/// Busca reviews Steam com cache
105async fn fetch_steam_reviews(
106    steam_id: &str,
107    cache_conn: &rusqlite::Connection,
108) -> Option<steam::SteamReviewSummary> {
109    let cache_key = format!("reviews_{}", steam_id);
110
111    if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
112        if let Ok(reviews) = serde_json::from_str::<steam::SteamReviewSummary>(&cached) {
113            return Some(reviews);
114        }
115    }
116
117    match steam::get_app_reviews(steam_id).await {
118        Ok(Some(reviews)) => {
119            if let Ok(json) = serde_json::to_string(&reviews) {
120                let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
121            }
122            Some(reviews)
123        }
124        _ => None,
125    }
126}
127
128/// Busca median playtime com cache
129async fn fetch_steam_playtime(steam_id: &str, cache_conn: &rusqlite::Connection) -> Option<u32> {
130    let cache_key = format!("playtime_{}", steam_id);
131
132    if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
133        if let Ok(hours) = cached.parse::<u32>() {
134            return Some(hours);
135        }
136    }
137
138    match steam::get_median_playtime(steam_id).await {
139        Ok(Some(hours)) => {
140            let _ =
141                cache::save_cached_api_data(cache_conn, "steam", &cache_key, &hours.to_string());
142            Some(hours)
143        }
144        _ => None,
145    }
146}
147
148// === LÓGICA CORE (REFATORADA) ===
149
150/// Processa um único jogo com cache integrado (sem manter lock)
151async fn enrich_game_metadata(
152    api_key: &str,
153    game_id: &str,
154    name: &str,
155    platform: &str,
156    platform_id: Option<String>,
157    cache_conn: &rusqlite::Connection,
158) -> (ProcessedGameDetails, Vec<String>) {
159    let series_name = series::infer_series(name);
160    let mut details = ProcessedGameDetails {
161        game_id: game_id.to_string(),
162        description_raw: None,
163        description_ptbr: None,
164        release_date: None,
165        genres: String::new(),
166        tags: Vec::new(),
167        developer: None,
168        publisher: None,
169        critic_score: None,
170        background_image: None,
171        series: series_name,
172        steam_review_label: None,
173        steam_review_count: None,
174        steam_review_score: None,
175        steam_review_updated_at: None,
176        esrb_rating: None,
177        is_adult: false,
178        adult_tags: None,
179        external_links: None,
180        steam_app_id: None,
181        median_playtime: None,
182        estimated_playtime: None,
183    };
184
185    let mut links_map: HashMap<String, String> = HashMap::new();
186    let mut found_raw_tags: Vec<String> = Vec::new();
187
188    // 1. Estratégia de Steam ID
189    let mut target_steam_id = if platform.to_lowercase() == "steam" {
190        platform_id
191    } else {
192        None
193    };
194
195    // 2. Busca na RAWG (com cache)
196    if let Some(rawg_det) = fetch_rawg_metadata(api_key, name, cache_conn).await {
197        found_raw_tags = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
198
199        let raw_tag_slugs: Vec<String> = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
200
201        details.description_raw = rawg_det.description_raw;
202        details.release_date = rawg_det.released;
203        details.genres = rawg_det
204            .genres
205            .iter()
206            .map(|g| g.name.clone())
207            .collect::<Vec<_>>()
208            .join(", ");
209        details.tags = crate::services::tags::classify_and_sort_tags(raw_tag_slugs, 10);
210        details.developer = rawg_det.developers.first().map(|d| d.name.clone());
211        details.publisher = rawg_det.publishers.first().map(|p| p.name.clone());
212        details.critic_score = rawg_det.metacritic;
213        details.background_image = rawg_det.background_image;
214        details.esrb_rating = rawg_det.esrb_rating.as_ref().map(|r| r.name.clone());
215
216        // Links
217        if let Some(url) = &rawg_det.website {
218            links_map.insert("website".to_string(), url.clone());
219        }
220        if let Some(url) = &rawg_det.reddit_url {
221            links_map.insert("reddit".to_string(), url.clone());
222        }
223        if let Some(url) = &rawg_det.metacritic_url {
224            links_map.insert("metacritic".to_string(), url.clone());
225        }
226        links_map.insert(
227            "rawg".to_string(),
228            format!("https://rawg.io/api/games/{}", rawg_det.id),
229        );
230
231        // Descobre Steam ID via RAWG
232        if target_steam_id.is_none() {
233            for store_data in &rawg_det.stores {
234                if store_data.store.slug == "steam" {
235                    if let Some(extracted_id) = extract_steam_id_from_url(&store_data.url) {
236                        target_steam_id = Some(extracted_id);
237                        links_map.insert("steam".to_string(), store_data.url.clone());
238                    }
239                }
240            }
241        }
242    }
243
244    // 3. Busca na Steam (com cache)
245    if let Some(steam_id) = &target_steam_id {
246        if !links_map.contains_key("steam") {
247            links_map.insert(
248                "steam".to_string(),
249                format!("https://store.steampowered.com/app/{}", steam_id),
250            );
251        }
252        details.steam_app_id = Some(steam_id.clone());
253
254        // A. Store data
255        if let Some(store_data) = fetch_steam_store_data(steam_id, cache_conn).await {
256            let (detected_adult, flags) = steam::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
262            // Fallbacks
263            if details.description_raw.is_none() {
264                details.description_raw = Some(store_data.short_description);
265            }
266            if details.release_date.is_none() {
267                details.release_date = store_data.release_date;
268            }
269            if details.background_image.is_none() {
270                details.background_image = Some(store_data.header_image);
271            }
272        }
273
274        // B. Reviews
275        if let Some(reviews) = fetch_steam_reviews(steam_id, cache_conn).await {
276            details.steam_review_label = Some(reviews.review_score_desc);
277            details.steam_review_count = Some(reviews.total_reviews as i32);
278            let total = reviews.total_positive + reviews.total_negative;
279            if total > 0 {
280                details.steam_review_score =
281                    Some((reviews.total_positive as f32 / total as f32) * 100.0);
282            }
283            details.steam_review_updated_at = Some(chrono::Utc::now().to_rfc3339());
284        }
285
286        // C. Playtime
287        if let Some(hours) = fetch_steam_playtime(steam_id, cache_conn).await {
288            details.median_playtime = Some(hours as i32);
289
290            let genre_list: Vec<String> = details
291                .genres
292                .split(',')
293                .map(|s| s.trim().to_lowercase())
294                .collect();
295
296            if let Some(estimated_hours) =
297                playtime::estimate_playtime(Some(hours), &genre_list, &details.tags)
298            {
299                details.estimated_playtime = Some(estimated_hours as f32);
300            }
301        }
302    }
303
304    if !links_map.is_empty() {
305        details.external_links = serde_json::to_string(&links_map).ok();
306    }
307
308    (details, found_raw_tags)
309}
310
311// === PERSISTÊNCIA ===
312
313fn save_game_details(
314    conn: &rusqlite::Connection,
315    d: ProcessedGameDetails,
316) -> Result<(), rusqlite::Error> {
317    let tags_json = database::serialize_tags(&d.tags).unwrap_or_else(|_| "[]".to_string());
318
319    conn.execute(
320        "INSERT OR REPLACE INTO game_details (
321            game_id, description_raw, description_ptbr, release_date, genres, tags,
322            developer, publisher, critic_score, background_image, series,
323            steam_review_label, steam_review_count, steam_review_score, steam_review_updated_at,
324            esrb_rating, is_adult, adult_tags, external_links, steam_app_id, median_playtime,
325            estimated_playtime
326         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)",
327        params![
328            d.game_id, d.description_raw, d.description_ptbr, d.release_date, d.genres, tags_json,
329            d.developer, d.publisher, d.critic_score, d.background_image, d.series,
330            d.steam_review_label, d.steam_review_count, d.steam_review_score, d.steam_review_updated_at,
331            d.esrb_rating, d.is_adult, d.adult_tags, d.external_links, d.steam_app_id, d.median_playtime,
332            d.estimated_playtime
333        ],
334    )?;
335
336    if let Some(img) = d.background_image {
337        conn.execute(
338            "UPDATE games SET cover_url = ?1 WHERE id = ?2 AND (cover_url IS NULL OR cover_url = '')",
339            params![img, d.game_id],
340        )?;
341    }
342
343    Ok(())
344}
345
346// === COMANDOS PRINCIPAIS ===
347
348/// Atualiza metadados de jogos na biblioteca (OTIMIZADO)
349#[tauri::command]
350pub async fn update_metadata(app: AppHandle) -> Result<(), AppError> {
351    let app_handle = app.clone();
352    let api_key = database::get_secret(&app, "rawg_api_key")?;
353    if api_key.is_empty() {
354        return Err(AppError::ValidationError(
355            "API Key da RAWG não configurada.".to_string(),
356        ));
357    }
358
359    tauri::async_runtime::spawn(async move {
360        info!("Iniciando enriquecimento com cache...");
361
362        let state: State<AppState> = app_handle.state();
363        let mut all_session_tags: HashSet<String> = HashSet::new();
364
365        // Limpeza de cache expirado no início
366        {
367            let cache_conn = state.metadata_db.lock().unwrap();
368            let _ = cache::cleanup_expired_cache(&cache_conn);
369        }
370
371        loop {
372            // 1. Busca batch de jogos
373            let games_to_update: Vec<(String, String, String, Option<String>)> = {
374                let conn = match state.library_db.lock() {
375                    Ok(c) => c,
376                    Err(_) => break,
377                };
378                let mut stmt = conn
379                    .prepare(
380                        "SELECT g.id, g.name, g.platform, g.platform_id
381                         FROM games g
382                         LEFT JOIN game_details gd ON g.id = gd.game_id
383                         WHERE gd.game_id IS NULL
384                         LIMIT ?",
385                    )
386                    .unwrap();
387
388                stmt.query_map(params![RAWG_REQUISITIONS_PER_BATCH], |row| {
389                    Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
390                })
391                .unwrap()
392                .flatten()
393                .collect()
394            };
395
396            if games_to_update.is_empty() {
397                break;
398            }
399
400            let total_in_batch = games_to_update.len();
401
402            // 2. Processa batch
403            for (index, (game_id, name, platform, platform_id)) in
404                games_to_update.into_iter().enumerate()
405            {
406                let _ = app_handle.emit(
407                    "enrich_progress",
408                    EnrichProgress {
409                        current: (index + 1) as i32,
410                        total_found: total_in_batch as i32,
411                        last_game: name.clone(),
412                        status: "running".to_string(),
413                    },
414                );
415
416                // Primeiro fazer o processamento SEM async que precisa do cache
417                let (processed_data, raw_tags) = {
418                    let cache_conn = match state.metadata_db.lock() {
419                        Ok(c) => c,
420                        Err(_) => continue,
421                    };
422
423                    // Executar TUDO com a conexão disponível e fazer await dentro do block_in_place
424                    let result = tokio::task::block_in_place(|| {
425                        let rt = tokio::runtime::Handle::current();
426                        rt.block_on(async {
427                            enrich_game_metadata(
428                                &api_key,
429                                &game_id,
430                                &name,
431                                &platform,
432                                platform_id.clone(),
433                                &cache_conn,
434                            )
435                            .await
436                        })
437                    });
438                    result
439                };
440
441                for tag in raw_tags {
442                    all_session_tags.insert(tag);
443                }
444
445                {
446                    if let Ok(conn) = state.library_db.lock() {
447                        if let Err(e) = save_game_details(&conn, processed_data) {
448                            warn!("Erro ao salvar metadados para {}: {}", name, e);
449                        }
450                    }
451                }
452            }
453
454            // 3. Rate limit por batch
455            sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
456        }
457
458        let _ = crate::services::tags::generate_analysis_report(&app_handle, all_session_tags);
459        let _ = app_handle.emit("enrich_complete", "Metadados atualizados!");
460    });
461
462    Ok(())
463}