Skip to main content

game_manager_lib/commands/metadata/
shared.rs

1//! Módulo compartilhado para enriquecimento de metadados
2//!
3//! Contém estruturas e funções reutilizadas por enrichment e covers.
4
5use 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// === ESTRUTURAS COMPARTILHADAS ===
15
16/// Progresso de enriquecimento de metadados
17#[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/// Payload do evento `enrich_complete`.
27///
28/// `platform` é `Some` quando o enrichment é escopado a uma importação
29/// específica (`enrich_newly_imported`), e `None` quando é uma varredura
30/// geral da biblioteca (`update_metadata`, `fill_missing_metadata`).
31#[derive(serde::Serialize, Clone)]
32pub struct EnrichCompletePayload {
33    pub platform: Option<String>,
34    pub message: String,
35}
36
37/// Estrutura intermediária de metadados dos jogos
38pub 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
65/// Resultado da resolução de `steam_app_id` a partir do nome de um jogo.
66pub struct SteamIdResolution {
67    pub app_id: String,
68    pub confidence: ImportConfidence,
69}
70
71// === HELPERS LOCAIS ===
72
73fn 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                        // Só marca como NOT_FOUND em 404 real — 429 já foi
117                        // tentado via backoff e não deve ser confundido com ausência.
118                        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 // erro de rede/429 esgotado — NÃO cacheia como not-found
143        }
144    }
145}
146
147// === FUNÇÕES COMPARTILHADAS - RAWG ===
148
149/// Busca metadados RAWG com cache
150///
151/// Esta função é compartilhada entre enrichment e covers para buscar informações de jogos na API
152/// RAWG com suporte a cache SQLite.
153pub 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
161/// Variante que ignora o cache e sempre consulta a RAWG ao vivo.
162///
163/// Usada pelo comando `fill_missing_metadata` para garantir que dados possivelmente atualizados na
164/// RAWG sejam buscados mesmo para jogos cujo cache ainda é válido.
165pub 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
173// === FUNÇÕES COMPARTILHADAS - STEAM ===
174
175pub 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    // 1. Match exato de nome normalizado → confiança alta
208    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        // 2. Nome sem sufixo de edição, contra candidatos também sem sufixo
216        .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        // 3. Sem match exato: pega o primeiro candidato que não pareça DLC/edição/trilha sonora.
229        // Evita cair numa correlação claramente errada quando o nome divergir entre plataformas (subtítulo, edição regional etc.)
230        .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
248/// Busca dados Steam Store com cache
249pub(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
272/// Busca reviews Steam com cache
273pub(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
296// === PERSISTÊNCIA ===
297
298/// Salva detalhes do jogo no banco. Aceita tanto Connection quanto Transaction (via Deref trait)
299pub 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    // Garante que a linha existe antes do UPDATE (para jogos que já têm
306    // description_raw da Legacy Games, o INSERT OR IGNORE preserva o valor).
307    conn.execute(
308        "INSERT OR IGNORE INTO game_details (game_id) VALUES (?1)",
309        params![d.game_id],
310    )?;
311
312    // Atualiza todos os campos usando COALESCE nos campos de texto para nunca
313    // sobrescrever um valor existente com NULL vindo da RAWG.
314    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}