game_manager_lib/commands/metadata/
get_metadata.rs1use 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
30fn 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)) .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
96async 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 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 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 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 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 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}