1use crate::constants::NOT_FOUND_MARKER;
6use crate::database;
7use crate::models::ImportConfidence;
8use crate::services::cache;
9use crate::services::integration::{rawg, steam_api};
10use crate::utils::text::{is_likely_non_base_game, normalize_for_matching, strip_edition_suffix};
11use rusqlite::params;
12use tracing::warn;
13
14#[derive(serde::Serialize, Clone)]
18pub struct EnrichProgress {
19 pub current: i32,
20 pub total_found: i32,
21 pub last_game: String,
22 pub status: String,
23 pub platform: Option<String>,
24}
25
26#[derive(serde::Serialize, Clone)]
32pub struct EnrichCompletePayload {
33 pub platform: Option<String>,
34 pub message: String,
35}
36
37pub struct ProcessedGameDetails {
39 pub game_id: String,
40 pub description_raw: Option<String>,
41 pub description_ptbr: Option<String>,
42 pub release_date: Option<String>,
43 pub genres: String,
44 pub tags: Vec<crate::models::GameTag>,
45 pub developer: Option<String>,
46 pub publisher: Option<String>,
47 pub critic_score: Option<i32>,
48 pub background_image: Option<String>,
49 pub series: Option<String>,
50 pub steam_review_label: Option<String>,
51 pub steam_review_count: Option<i32>,
52 pub steam_review_score: Option<f32>,
53 pub steam_review_updated_at: Option<String>,
54 pub esrb_rating: Option<String>,
55 pub is_adult: bool,
56 pub adult_tags: Option<String>,
57 pub external_links: Option<String>,
58 pub steam_app_id: Option<String>,
59 pub median_playtime: Option<i32>,
60 pub estimated_playtime: Option<f32>,
61 pub alternative_names: Option<Vec<String>>,
62 pub updated_at: Option<String>,
63}
64
65pub struct SteamIdResolution {
67 pub app_id: String,
68 pub confidence: ImportConfidence,
69}
70
71fn rawg_cache_key(name: &str) -> String {
74 format!("search_{}", name.to_lowercase())
75}
76
77async fn fetch_rawg_metadata_inner(
78 api_key: &str,
79 name: &str,
80 cache_conn: &rusqlite::Connection,
81 bypass_cache: bool,
82) -> Option<rawg::RawgGameDetails> {
83 let cache_key = rawg_cache_key(name);
84
85 if !bypass_cache {
86 if let Some(cached) = cache::get_cached_api_data(cache_conn, "rawg", &cache_key) {
87 if cached == NOT_FOUND_MARKER {
88 return None;
89 }
90 if let Ok(details) = serde_json::from_str::<rawg::RawgGameDetails>(&cached) {
91 return Some(details);
92 }
93 }
94 }
95
96 let search_result = crate::services::rate_limiter::RAWG_LIMITER
97 .run(|| rawg::search_games(api_key, name))
98 .await;
99
100 match search_result {
101 Ok(results) => {
102 if let Some(best_match) = results.first() {
103 let details_result = crate::services::rate_limiter::RAWG_LIMITER
104 .run(|| rawg::fetch_game_details(api_key, best_match.id.to_string()))
105 .await;
106
107 match details_result {
108 Ok(details) => {
109 if let Ok(json) = serde_json::to_string(&details) {
110 let _ =
111 cache::save_cached_api_data(cache_conn, "rawg", &cache_key, &json);
112 }
113 Some(details)
114 }
115 Err(err) => {
116 if err.contains("não encontrado") || err.contains("404") {
119 let _ = cache::save_cached_api_data(
120 cache_conn,
121 "rawg",
122 &cache_key,
123 NOT_FOUND_MARKER,
124 );
125 } else {
126 warn!(
127 "RAWG fetch_game_details falhou (não-404) para '{}': {}",
128 name, err
129 );
130 }
131 None
132 }
133 }
134 } else {
135 let _ =
136 cache::save_cached_api_data(cache_conn, "rawg", &cache_key, NOT_FOUND_MARKER);
137 None
138 }
139 }
140 Err(e) => {
141 warn!("RAWG search_games falhou para '{}': {}", name, e);
142 None }
144 }
145}
146
147pub async fn fetch_rawg_metadata(
154 api_key: &str,
155 name: &str,
156 cache_conn: &rusqlite::Connection,
157) -> Option<rawg::RawgGameDetails> {
158 fetch_rawg_metadata_inner(api_key, name, cache_conn, false).await
159}
160
161pub async fn fetch_rawg_metadata_fresh(
166 api_key: &str,
167 name: &str,
168 cache_conn: &rusqlite::Connection,
169) -> Option<rawg::RawgGameDetails> {
170 fetch_rawg_metadata_inner(api_key, name, cache_conn, true).await
171}
172
173pub async fn resolve_steam_app_id(
176 name: &str,
177 platform: &str,
178 platform_game_id: Option<&str>,
179 cache_conn: &rusqlite::Connection,
180) -> Option<SteamIdResolution> {
181 if platform.to_lowercase() == "steam" {
182 if let Some(id) = platform_game_id {
183 return Some(SteamIdResolution {
184 app_id: id.to_string(),
185 confidence: ImportConfidence::High,
186 });
187 }
188 }
189
190 let cache_key = format!("resolve_{}", normalize_for_matching(name));
191 if cache::get_cached_api_data(cache_conn, "steam_resolve", &cache_key)
192 .is_some_and(|v| v == NOT_FOUND_MARKER)
193 {
194 return None;
195 }
196
197 let candidates = match steam_api::search_app_by_name(name).await {
198 Ok(c) => c,
199 Err(e) => {
200 warn!("search_app_by_name falhou para '{}': {}", name, e);
201 return None;
202 }
203 };
204
205 let target = normalize_for_matching(name);
206
207 let resolution = candidates
209 .iter()
210 .find(|item| normalize_for_matching(&item.name) == target)
211 .map(|item| SteamIdResolution {
212 app_id: item.id.to_string(),
213 confidence: ImportConfidence::High,
214 })
215 .or_else(|| {
217 let stripped_target = normalize_for_matching(&strip_edition_suffix(name));
218 candidates
219 .iter()
220 .find(|item| {
221 normalize_for_matching(&strip_edition_suffix(&item.name)) == stripped_target
222 })
223 .map(|item| SteamIdResolution {
224 app_id: item.id.to_string(),
225 confidence: ImportConfidence::Medium,
226 })
227 })
228 .or_else(|| {
231 candidates
232 .iter()
233 .find(|item| !is_likely_non_base_game(&item.name))
234 .map(|item| SteamIdResolution {
235 app_id: item.id.to_string(),
236 confidence: ImportConfidence::Low,
237 })
238 });
239
240 if resolution.is_none() {
241 let _ =
242 cache::save_cached_api_data(cache_conn, "steam_resolve", &cache_key, NOT_FOUND_MARKER);
243 }
244
245 resolution
246}
247
248pub(crate) async fn fetch_steam_store_data(
250 steam_id: &str,
251 cache_conn: &rusqlite::Connection,
252) -> Option<steam_api::SteamStoreData> {
253 let cache_key = format!("store_{}", steam_id);
254
255 if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
256 if let Ok(data) = serde_json::from_str::<steam_api::SteamStoreData>(&cached) {
257 return Some(data);
258 }
259 }
260
261 match steam_api::get_app_details(steam_id).await {
262 Ok(Some(data)) => {
263 if let Ok(json) = serde_json::to_string(&data) {
264 let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
265 }
266 Some(data)
267 }
268 _ => None,
269 }
270}
271
272pub(crate) async fn fetch_steam_reviews(
274 steam_id: &str,
275 cache_conn: &rusqlite::Connection,
276) -> Option<steam_api::SteamReviewSummary> {
277 let cache_key = format!("reviews_{}", steam_id);
278
279 if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
280 if let Ok(reviews) = serde_json::from_str::<steam_api::SteamReviewSummary>(&cached) {
281 return Some(reviews);
282 }
283 }
284
285 match steam_api::get_app_reviews(steam_id).await {
286 Ok(Some(reviews)) => {
287 if let Ok(json) = serde_json::to_string(&reviews) {
288 let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
289 }
290 Some(reviews)
291 }
292 _ => None,
293 }
294}
295
296pub fn save_game_details<C>(conn: &C, d: ProcessedGameDetails) -> Result<(), rusqlite::Error>
300where
301 C: std::ops::Deref<Target=rusqlite::Connection>,
302{
303 let tags_json = database::serialize_tags(&d.tags).unwrap_or_else(|_| "[]".to_string());
304
305 conn.execute(
308 "INSERT OR IGNORE INTO game_details (game_id) VALUES (?1)",
309 params![d.game_id],
310 )?;
311
312 conn.execute(
315 "UPDATE game_details SET
316 description_raw = COALESCE(?2, description_raw),
317 description_ptbr = COALESCE(?3, description_ptbr),
318 release_date = COALESCE(?4, release_date),
319 genres = COALESCE(NULLIF(?5, ''), genres),
320 tags = COALESCE(NULLIF(?6, '[]'), tags),
321 developer = COALESCE(?7, developer),
322 publisher = COALESCE(?8, publisher),
323 critic_score = COALESCE(?9, critic_score),
324 background_image = COALESCE(?10, background_image),
325 series = COALESCE(?11, series),
326 steam_review_label = COALESCE(?12, steam_review_label),
327 steam_review_count = COALESCE(?13, steam_review_count),
328 steam_review_score = COALESCE(?14, steam_review_score),
329 steam_review_updated_at = COALESCE(?15, steam_review_updated_at),
330 esrb_rating = COALESCE(?16, esrb_rating),
331 is_adult = ?17,
332 adult_tags = COALESCE(?18, adult_tags),
333 external_links = COALESCE(?19, external_links),
334 steam_app_id = COALESCE(?20, steam_app_id),
335 median_playtime = COALESCE(?21, median_playtime),
336 estimated_playtime = COALESCE(?22, estimated_playtime),
337 updated_at = ?23
338 WHERE game_id = ?1",
339 params![
340 d.game_id,
341 d.description_raw,
342 d.description_ptbr,
343 d.release_date,
344 d.genres,
345 tags_json,
346 d.developer,
347 d.publisher,
348 d.critic_score,
349 d.background_image.clone(),
350 d.series,
351 d.steam_review_label,
352 d.steam_review_count,
353 d.steam_review_score,
354 d.steam_review_updated_at,
355 d.esrb_rating,
356 d.is_adult,
357 d.adult_tags,
358 d.external_links,
359 d.steam_app_id,
360 d.median_playtime,
361 d.estimated_playtime,
362 d.updated_at
363 ],
364 )?;
365
366 if let Some(img) = d.background_image {
367 conn.execute(
368 "UPDATE games SET cover_url = ?1 WHERE id = ?2 AND (cover_url IS NULL OR cover_url = '')",
369 params![img, d.game_id],
370 )?;
371 }
372
373 if let Some(alt_names) = d.alternative_names {
374 if !alt_names.is_empty() {
375 let alt_json = serde_json::to_string(&alt_names).unwrap_or_default();
376 conn.execute(
377 "UPDATE games SET alternative_names = COALESCE(?1, alternative_names) WHERE id = ?2",
378 params![alt_json, d.game_id],
379 )?;
380 }
381 }
382
383 Ok(())
384}