1use 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
33fn 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#[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
60pub(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
86pub 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
165async 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 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 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 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
305pub(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 conn.execute(
321 "INSERT OR IGNORE INTO game_details (game_id) VALUES (?1)",
322 params![d.game_id],
323 )?;
324
325 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#[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 {
408 let cache_conn = state.cache_db.lock().unwrap();
409 let _ = cache::cleanup_expired_cache(&cache_conn);
410 }
411
412 loop {
413 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 "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 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 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 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 for tag in raw_tags {
538 all_session_tags.insert(tag);
539 }
540
541 batch_results.push((name.clone(), processed_data));
543 }
544
545 {
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 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 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}