game_manager_lib/commands/metadata/
get_metadata.rs1use 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#[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 let mut processed_ids: HashSet<String> = HashSet::new();
58
59 loop {
60 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 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)) .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 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 for (index, (game_id, name, platform, platform_game_id)) in
139 games_to_fill.into_iter().enumerate()
140 {
141 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 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 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 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 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 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}