game_manager_lib/commands/metadata/
enrichment.rs1use super::shared::{
13 fetch_rawg_metadata, fetch_steam_reviews, fetch_steam_store_data, resolve_steam_app_id,
14 save_game_details, EnrichCompletePayload, EnrichProgress, ProcessedGameDetails,
15};
16use crate::commands::platforms::core::NewlyImportedGame;
17use crate::constants::RAWG_RATE_LIMIT_MS;
18use crate::database;
19use crate::database::AppState;
20use crate::services::cache;
21use crate::services::integration::nexus::{find_best_nexus_match, NexusGame};
22use crate::services::integration::steam_api;
23use crate::utils::series;
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#[derive(serde::Serialize)]
33pub struct ImportSummary {
34 pub success_count: i32,
35 pub error_count: i32,
36 pub total_processed: i32,
37 pub message: String,
38 pub errors: Vec<String>,
39}
40
41pub async fn enrich_newly_imported(app: AppHandle, games: Vec<NewlyImportedGame>) {
44 let api_key = match database::get_secret(&app, "rawg_api_key") {
45 Ok(key) if !key.is_empty() => key,
46 _ => {
47 warn!("Enrichment pós-import ignorado: API Key da RAWG não configurada.");
48 return;
49 }
50 };
51
52 let platform_label = games.first().map(|g| g.platform.clone());
54
55 let state: State<AppState> = app.state();
56 let total = games.len();
57 let mut all_session_tags: HashSet<String> = HashSet::new();
58 let mut batch_results = Vec::new();
59
60 let nexus_games: Vec<NexusGame> = state
62 .cache_db
63 .lock()
64 .ok()
65 .and_then(|conn| cache::get_cached_nexus_games(&conn).ok())
66 .unwrap_or_default();
67
68 for (index, game) in games.into_iter().enumerate() {
70 let _ = app.emit(
71 "enrich_progress",
72 EnrichProgress {
73 current: (index + 1) as i32,
74 total_found: total as i32,
75 last_game: game.name.clone(),
76 status: "running".to_string(),
77 platform: platform_label.clone(),
78 },
79 );
80
81 let (processed_data, raw_tags, _rawg_found) = {
82 let cache_conn = match state.cache_db.lock() {
83 Ok(c) => c,
84 Err(_) => continue,
85 };
86 tokio::task::block_in_place(|| {
87 let rt = tokio::runtime::Handle::current();
88 rt.block_on(async {
89 enrich_game_metadata(
90 &api_key,
91 &game.game_id,
92 &game.name,
93 &game.platform,
94 Some(game.platform_game_id.clone()),
95 &cache_conn,
96 &nexus_games,
97 )
98 .await
99 })
100 })
101 };
102
103 for tag in raw_tags {
104 all_session_tags.insert(tag);
105 }
106 batch_results.push((game.name, processed_data));
107
108 sleep(Duration::from_millis(RAWG_RATE_LIMIT_MS)).await;
109 }
110
111 if let Ok(mut conn) = state.games_db.lock() {
113 if let Ok(tx) = conn.transaction() {
114 let mut success = 0;
115 let mut errors = 0;
116 for (name, data) in batch_results {
117 if let Err(e) = save_game_details(&tx, data) {
118 warn!("enrich_newly_imported: erro ao salvar {}: {}", name, e);
119 errors += 1;
120 } else {
121 success += 1;
122 }
123 }
124 match tx.commit() {
125 Ok(_) => info!("enrich_newly_imported: {} ok, {} erros", success, errors),
126 Err(e) => warn!("enrich_newly_imported: commit falhou: {}", e),
127 }
128 }
129 }
130
131 let _ = crate::services::tags::generate_analysis_report(&app, all_session_tags);
132
133 let message = match &platform_label {
134 Some(platform) => format!("{}: enriquecimento concluído.", platform),
135 None => "Enriquecimento pós-import concluído.".to_string(),
136 };
137 let _ = app.emit(
138 "enrich_complete",
139 EnrichCompletePayload {
140 platform: platform_label,
141 message,
142 },
143 );
144}
145
146async fn enrich_game_metadata(
148 api_key: &str,
149 game_id: &str,
150 name: &str,
151 platform: &str,
152 platform_game_id: Option<String>,
153 cache_conn: &rusqlite::Connection,
154 nexus_games: &[NexusGame],
155) -> (ProcessedGameDetails, Vec<String>, bool) {
156 let series_name = series::infer_series(name);
157 let mut details = ProcessedGameDetails {
158 game_id: game_id.to_string(),
159 description_raw: None,
160 description_ptbr: None,
161 release_date: None,
162 genres: String::new(),
163 tags: Vec::new(),
164 developer: None,
165 publisher: None,
166 critic_score: None,
167 background_image: None,
168 series: series_name,
169 steam_review_label: None,
170 steam_review_count: None,
171 steam_review_score: None,
172 steam_review_updated_at: None,
173 esrb_rating: None,
174 is_adult: false,
175 adult_tags: None,
176 external_links: None,
177 steam_app_id: None,
178 median_playtime: None,
179 estimated_playtime: None,
180 alternative_names: None,
181 updated_at: Some(chrono::Utc::now().to_rfc3339()),
182 };
183
184 let mut links_map: HashMap<String, String> = HashMap::new();
185
186 if let Some(nexus_match) = find_best_nexus_match(name, nexus_games) {
187 let url = format!("https://www.nexusmods.com/{}", nexus_match.domain_name);
188 links_map.insert("nexus".to_string(), url);
189 }
190
191 let rawg_future = fetch_rawg_metadata(api_key, name, cache_conn);
195
196 let steam_future = async {
197 let target_steam_id =
198 resolve_steam_app_id(name, platform, platform_game_id.as_deref(), cache_conn)
199 .await
200 .map(|resolution| resolution.app_id);
201
202 match &target_steam_id {
203 Some(steam_id) => {
204 let (store_data, reviews) = tokio::join!(
205 fetch_steam_store_data(steam_id, cache_conn),
206 fetch_steam_reviews(steam_id, cache_conn)
207 );
208 (target_steam_id, store_data, reviews)
209 }
210 None => (target_steam_id, None, None),
211 }
212 };
213
214 let (rawg_result, (target_steam_id, store_data, reviews)) =
215 tokio::join!(rawg_future, steam_future);
216
217 let mut found_raw_tags: Vec<String> = Vec::new();
218 let mut rawg_found = false;
219
220 if let Some(rawg_det) = rawg_result {
221 rawg_found = true;
222 found_raw_tags = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
223
224 let raw_tag_slugs: Vec<String> = rawg_det.tags.iter().map(|t| t.slug.clone()).collect();
225
226 details.description_raw = rawg_det.description_raw;
227 details.release_date = rawg_det.released;
228 details.genres = rawg_det
229 .genres
230 .iter()
231 .map(|g| g.name.clone())
232 .collect::<Vec<_>>()
233 .join(", ");
234 details.tags = crate::services::tags::classify_and_sort_tags(raw_tag_slugs, 10);
235 details.developer = rawg_det.developers.first().map(|d| d.name.clone());
236 details.publisher = rawg_det.publishers.first().map(|p| p.name.clone());
237 details.critic_score = rawg_det.metacritic;
238 details.background_image = rawg_det.background_image;
239 details.esrb_rating = rawg_det.esrb_rating.as_ref().map(|r| r.name.clone());
240
241 if !rawg_det.alternative_names.is_empty() {
242 details.alternative_names = Some(rawg_det.alternative_names.clone());
243 }
244
245 if let Some(url) = &rawg_det.website {
246 links_map.insert("website".to_string(), url.clone());
247 }
248 if let Some(url) = &rawg_det.reddit_url {
249 links_map.insert("reddit".to_string(), url.clone());
250 }
251 if let Some(url) = &rawg_det.metacritic_url {
252 links_map.insert("metacritic".to_string(), url.clone());
253 }
254 links_map.insert(
255 "rawg".to_string(),
256 format!("https://rawg.io/games/{}", rawg_det.id),
257 );
258 }
259
260 if let Some(steam_id) = &target_steam_id {
261 links_map
262 .entry("steam".to_string())
263 .or_insert_with(|| format!("https://store.steampowered.com/app/{}", steam_id));
264 details.steam_app_id = Some(steam_id.clone());
265
266 if let Some(store_data) = store_data {
267 let (detected_adult, flags) = steam_api::detect_adult_content(&store_data);
268 details.is_adult = detected_adult;
269 if !flags.is_empty() {
270 details.adult_tags = serde_json::to_string(&flags).ok();
271 }
272 if details.description_raw.is_none() {
273 details.description_raw = Some(store_data.short_description);
274 }
275 if details.release_date.is_none() {
276 details.release_date = store_data.release_date;
277 }
278 if details.background_image.is_none() {
279 details.background_image = Some(store_data.header_image);
280 }
281 }
282
283 if let Some(reviews) = reviews {
284 details.steam_review_label = Some(reviews.review_score_desc);
285 details.steam_review_count = Some(reviews.total_reviews as i32);
286 let total = reviews.total_positive + reviews.total_negative;
287 if total > 0 {
288 details.steam_review_score =
289 Some((reviews.total_positive as f32 / total as f32) * 100.0);
290 }
291 details.steam_review_updated_at = Some(chrono::Utc::now().to_rfc3339());
292 }
293 }
294
295 if !links_map.is_empty() {
296 details.external_links = serde_json::to_string(&links_map).ok();
297 }
298
299 (details, found_raw_tags, rawg_found)
300}