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::models::ImportConfidence;
7use crate::services::cache;
8use crate::services::integration::{rawg, steam_api, steamspy};
9use crate::utils::text::{is_likely_non_base_game, normalize_for_matching, strip_edition_suffix};
10
11// === ESTRUTURAS COMPARTILHADAS ===
12
13/// Progresso de enriquecimento de metadados
14#[derive(serde::Serialize, Clone)]
15pub struct EnrichProgress {
16    pub current: i32,
17    pub total_found: i32,
18    pub last_game: String,
19    pub status: String,
20}
21
22/// Resultado da resolução de `steam_app_id` a partir do nome de um jogo.
23pub struct SteamIdResolution {
24    pub app_id: String,
25    pub confidence: ImportConfidence,
26}
27
28// === HELPERS LOCAIS ===
29
30fn rawg_cache_key(name: &str) -> String {
31    format!("search_{}", name.to_lowercase())
32}
33
34pub(in crate::commands::metadata) fn rawg_not_found_cached(
35    name: &str,
36    cache_conn: &rusqlite::Connection,
37) -> bool {
38    let cache_key = rawg_cache_key(name);
39    cache::get_cached_api_data(cache_conn, "rawg", &cache_key)
40        .is_some_and(|cached| cached == NOT_FOUND_MARKER)
41}
42
43async fn fetch_rawg_metadata_inner(
44    api_key: &str,
45    name: &str,
46    cache_conn: &rusqlite::Connection,
47    bypass_cache: bool,
48) -> Option<rawg::GameDetails> {
49    let cache_key = rawg_cache_key(name);
50
51    if !bypass_cache {
52        if let Some(cached) = cache::get_cached_api_data(cache_conn, "rawg", &cache_key) {
53            if cached == NOT_FOUND_MARKER {
54                return None;
55            }
56            if let Ok(details) = serde_json::from_str::<rawg::GameDetails>(&cached) {
57                return Some(details);
58            }
59        }
60    }
61
62    match rawg::search_games(api_key, name).await {
63        Ok(results) => {
64            if let Some(best_match) = results.first() {
65                match rawg::fetch_game_details(api_key, best_match.id.to_string()).await {
66                    Ok(details) => {
67                        if let Ok(json) = serde_json::to_string(&details) {
68                            let _ =
69                                cache::save_cached_api_data(cache_conn, "rawg", &cache_key, &json);
70                        }
71                        Some(details)
72                    }
73                    Err(err) => {
74                        if err.contains("não encontrado") || err.contains("404") {
75                            let _ = cache::save_cached_api_data(
76                                cache_conn,
77                                "rawg",
78                                &cache_key,
79                                NOT_FOUND_MARKER,
80                            );
81                        }
82                        None
83                    }
84                }
85            } else {
86                let _ =
87                    cache::save_cached_api_data(cache_conn, "rawg", &cache_key, NOT_FOUND_MARKER);
88                None
89            }
90        }
91        Err(_) => None,
92    }
93}
94
95// === FUNÇÕES COMPARTILHADAS ===
96
97/// Busca metadados RAWG com cache
98///
99/// Esta função é compartilhada entre enrichment e covers
100/// para buscar informações de jogos na API RAWG com suporte a cache SQLite.
101pub async fn fetch_rawg_metadata(
102    api_key: &str,
103    name: &str,
104    cache_conn: &rusqlite::Connection,
105) -> Option<rawg::GameDetails> {
106    fetch_rawg_metadata_inner(api_key, name, cache_conn, false).await
107}
108
109/// Variante que ignora o cache e sempre consulta a RAWG ao vivo.
110///
111/// Usada pelo comando `fill_missing_metadata` para garantir que dados
112/// possivelmente atualizados na RAWG sejam buscados mesmo para jogos
113/// cujo cache ainda é válido.
114pub async fn fetch_rawg_metadata_fresh(
115    api_key: &str,
116    name: &str,
117    cache_conn: &rusqlite::Connection,
118) -> Option<rawg::GameDetails> {
119    fetch_rawg_metadata_inner(api_key, name, cache_conn, true).await
120}
121
122pub async fn resolve_steam_app_id(
123    name: &str,
124    platform: &str,
125    platform_game_id: Option<&str>,
126    cache_conn: &rusqlite::Connection,
127) -> Option<SteamIdResolution> {
128    if platform.to_lowercase() == "steam" {
129        if let Some(id) = platform_game_id {
130            return Some(SteamIdResolution {
131                app_id: id.to_string(),
132                confidence: ImportConfidence::High,
133            });
134        }
135    }
136
137    let cache_key = format!("resolve_{}", normalize_for_matching(name));
138    if cache::get_cached_api_data(cache_conn, "steam_resolve", &cache_key)
139        .is_some_and(|v| v == NOT_FOUND_MARKER)
140    {
141        return None;
142    }
143
144    let candidates = steam_api::search_app_by_name(name).await.ok()?;
145    let target = normalize_for_matching(name);
146
147    // 1. Match exato de nome normalizado → confiança alta
148    let resolution = candidates
149        .iter()
150        .find(|item| normalize_for_matching(&item.name) == target)
151        .map(|item| SteamIdResolution {
152            app_id: item.id.to_string(),
153            confidence: ImportConfidence::High,
154        })
155        // 2. Nome sem sufixo de edição, contra candidatos também sem sufixo
156        .or_else(|| {
157            let stripped_target = normalize_for_matching(&strip_edition_suffix(name));
158            candidates
159                .iter()
160                .find(|item| {
161                    normalize_for_matching(&strip_edition_suffix(&item.name)) == stripped_target
162                })
163                .map(|item| SteamIdResolution {
164                    app_id: item.id.to_string(),
165                    confidence: ImportConfidence::Medium,
166                })
167        })
168        // 3. Sem match exato: pega o primeiro candidato que não pareça DLC/edição/trilha sonora.
169        // Evita cair numa correlação claramente errada quando o nome divergir entre plataformas (subtítulo, edição regional etc.)
170        .or_else(|| {
171            candidates
172                .iter()
173                .find(|item| !is_likely_non_base_game(&item.name))
174                .map(|item| SteamIdResolution {
175                    app_id: item.id.to_string(),
176                    confidence: ImportConfidence::Low,
177                })
178        });
179
180    if resolution.is_none() {
181        let _ =
182            cache::save_cached_api_data(cache_conn, "steam_resolve", &cache_key, NOT_FOUND_MARKER);
183    }
184
185    resolution
186}
187
188// === ENRIQUECIMENTO COM CACHE ===
189
190/// Busca dados Steam Store com cache
191pub(crate) async fn fetch_steam_store_data(
192    steam_id: &str,
193    cache_conn: &rusqlite::Connection,
194) -> Option<steam_api::SteamStoreData> {
195    let cache_key = format!("store_{}", steam_id);
196
197    if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
198        if let Ok(data) = serde_json::from_str::<steam_api::SteamStoreData>(&cached) {
199            return Some(data);
200        }
201    }
202
203    match steam_api::get_app_details(steam_id).await {
204        Ok(Some(data)) => {
205            if let Ok(json) = serde_json::to_string(&data) {
206                let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
207            }
208            Some(data)
209        }
210        _ => None,
211    }
212}
213
214/// Busca reviews Steam com cache
215pub(crate) async fn fetch_steam_reviews(
216    steam_id: &str,
217    cache_conn: &rusqlite::Connection,
218) -> Option<steam_api::SteamReviewSummary> {
219    let cache_key = format!("reviews_{}", steam_id);
220
221    if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
222        if let Ok(reviews) = serde_json::from_str::<steam_api::SteamReviewSummary>(&cached) {
223            return Some(reviews);
224        }
225    }
226
227    match steam_api::get_app_reviews(steam_id).await {
228        Ok(Some(reviews)) => {
229            if let Ok(json) = serde_json::to_string(&reviews) {
230                let _ = cache::save_cached_api_data(cache_conn, "steam", &cache_key, &json);
231            }
232            Some(reviews)
233        }
234        _ => None,
235    }
236}
237
238/// Busca median playtime com cache
239pub(crate) async fn fetch_steam_playtime(
240    steam_id: &str,
241    cache_conn: &rusqlite::Connection,
242) -> Option<u32> {
243    let cache_key = format!("playtime_{}", steam_id);
244
245    if let Some(cached) = cache::get_cached_api_data(cache_conn, "steam", &cache_key) {
246        if let Ok(hours) = cached.parse::<u32>() {
247            return Some(hours);
248        }
249    }
250
251    match steamspy::get_median_playtime(steam_id).await {
252        Ok(Some(hours)) => {
253            let _ =
254                cache::save_cached_api_data(cache_conn, "steam", &cache_key, &hours.to_string());
255            Some(hours)
256        }
257        _ => None,
258    }
259}