Skip to main content

game_manager_lib/services/integration/gamebrain/
similar.rs

1//! Busca de jogos similares via GameBrain.
2
3use crate::constants::GAMEBRAIN_SIMILAR_REQUEST_LIMIT;
4use crate::database;
5use crate::utils::http_client::HTTP_CLIENT;
6
7use tauri::AppHandle;
8
9use super::cache::{
10    gamebrain_id_cache_key, gamebrain_similar_cache_key, read_cached_json, read_stale_gamebrain_id,
11    read_stale_similar_games, save_cached_json, take_similar_limit,
12};
13use super::helpers::{parse_gamebrain_id, resolve_gamebrain_id};
14use super::models::SimilarGame;
15use super::raw::RawSimilarResponse;
16
17/// Busca jogos similares ao jogo identificado pelo UUID interno do Playlite.
18///
19/// **Fluxo:**
20/// ```text
21/// playlite_game_id (UUID)
22///   → cache sqlite gamebrain_id:{UUID}? → gamebrain_id (u64)
23///   → se não: database::get_game_name(playlite_game_id)
24///             → /suggestions?query={nome} → gamebrain_id
25///             → salva no cache persistente
26///   → cache sqlite gamebrain_similar:{id}? → Vec<SimilarGame>
27///   → se não: /v1/games/{gamebrain_id}/similar
28///             → salva no cache persistente
29/// ```
30///
31/// **Cache:**
32/// - gamebrain_id e similares: TTL de 30 dias (estável)
33///
34/// **Parâmetros:**
35/// - `app`: AppHandle do Tauri, necessário para acessar database e secrets
36/// - `playlite_game_id`: UUID interno do jogo na biblioteca do Playlite
37/// - `game_name`: Nome do jogo, usado como query para /suggestions. Passar diretamente evita uma chamada ao banco.
38/// - `limit`: Número de similares a retornar (padrão da API: 10, máx recomendado: 12)
39///
40/// Retorna `Err` se: API key não estiver configurada, falha de rede ou jogo não encontrado na GameBrain.
41pub async fn fetch_similar_games(
42    app: &AppHandle,
43    playlite_game_id: &str,
44    game_name: &str,
45    limit: Option<u32>,
46) -> Result<Vec<SimilarGame>, String> {
47    let api_key = database::get_secret(app, "gamebrain_api_key").map_err(|e| e.to_string())?;
48
49    if api_key.trim().is_empty() {
50        return Err("GameBrain API key não configurada".into());
51    }
52
53    let requested_limit = limit.unwrap_or(10);
54
55    // Etapa 1: resolver gamebrain_id
56    let gamebrain_id = {
57        let id_cache_key = gamebrain_id_cache_key(playlite_game_id);
58
59        if let Some(id) = read_cached_json::<u64>(app, "gamebrain", &id_cache_key, false)? {
60            tracing::debug!(
61                "GameBrain ID cache hit => game_id='{}' gamebrain_id={}",
62                playlite_game_id,
63                id
64            );
65            id
66        } else {
67            // Cache miss: resolve via /suggestions
68            tracing::debug!(
69                "GameBrain ID cache miss => resolvendo '{}' via suggestions",
70                game_name
71            );
72
73            match resolve_gamebrain_id(&api_key, game_name).await {
74                Ok(Some(id)) => {
75                    let _ = save_cached_json(app, "gamebrain", &id_cache_key, &id);
76                    id
77                }
78                Ok(None) => {
79                    if let Some(stale_id) =
80                        read_stale_gamebrain_id(app, &id_cache_key, playlite_game_id)?
81                    {
82                        stale_id
83                    } else {
84                        return Err(format!("Jogo '{}' não encontrado na GameBrain", game_name));
85                    }
86                }
87                Err(err) => {
88                    if let Some(stale_id) =
89                        read_stale_gamebrain_id(app, &id_cache_key, playlite_game_id)?
90                    {
91                        stale_id
92                    } else {
93                        return Err(err);
94                    }
95                }
96            }
97        }
98    };
99
100    // Etapa 2: buscar similares usando gamebrain_id
101    let similar_cache_key = gamebrain_similar_cache_key(gamebrain_id);
102
103    if let Some(cached_results) =
104        read_cached_json::<Vec<SimilarGame>>(app, "gamebrain", &similar_cache_key, false)?
105    {
106        tracing::debug!(
107            "GameBrain similar cache hit => gamebrain_id={}",
108            gamebrain_id
109        );
110        return Ok(take_similar_limit(cached_results, requested_limit));
111    }
112
113    // Cache miss: chama a API
114    tracing::debug!(
115        "GameBrain similar cache miss => chamando /similar para gamebrain_id={}",
116        gamebrain_id
117    );
118
119    let url = format!("https://api.gamebrain.co/v1/games/{}/similar", gamebrain_id);
120
121    let mut request = HTTP_CLIENT.get(&url).header("x-api-key", &api_key);
122
123    request = request.query(&[("limit", GAMEBRAIN_SIMILAR_REQUEST_LIMIT.to_string())]);
124
125    let response = request.send().await.map_err(|e| {
126        tracing::error!("GameBrain similar request error: {}", e);
127        e.to_string()
128    });
129
130    let response = match response {
131        Ok(response) => response,
132        Err(err) => {
133            if let Some(cached_results) =
134                read_stale_similar_games(app, &similar_cache_key, gamebrain_id, requested_limit)?
135            {
136                return Ok(cached_results);
137            }
138
139            return Err(err);
140        }
141    };
142
143    let status = response.status();
144
145    if !status.is_success() {
146        let body = response.text().await.unwrap_or_default();
147        tracing::error!(
148            "GameBrain similar HTTP Error => status={} body={}",
149            status,
150            body
151        );
152        if let Some(cached_results) =
153            read_stale_similar_games(app, &similar_cache_key, gamebrain_id, requested_limit)?
154        {
155            return Ok(cached_results);
156        }
157
158        return Err(format!("Erro GameBrain similar: {}", status));
159    }
160
161    let text = match response.text().await {
162        Ok(text) => text,
163        Err(err) => {
164            if let Some(cached_results) =
165                read_stale_similar_games(app, &similar_cache_key, gamebrain_id, requested_limit)?
166            {
167                return Ok(cached_results);
168            }
169
170            return Err(err.to_string());
171        }
172    };
173    tracing::debug!("GameBrain similar response size: {} bytes", text.len());
174
175    let raw: RawSimilarResponse = match serde_json::from_str(&text) {
176        Ok(raw) => raw,
177        Err(e) => {
178            tracing::error!("GameBrain similar JSON parse error: {}", e);
179            if let Some(cached_results) =
180                read_stale_similar_games(app, &similar_cache_key, gamebrain_id, requested_limit)?
181            {
182                return Ok(cached_results);
183            }
184
185            return Err(format!("Erro JSON GameBrain similar: {}", e));
186        }
187    };
188
189    let results: Vec<SimilarGame> = raw
190        .results
191        .into_iter()
192        .filter_map(|g| {
193            // Descarta jogos sem ID válido — não deveriam existir, mas defensive.
194            let raw_id = parse_gamebrain_id(&g.id)?;
195
196            Some(SimilarGame {
197                id: format!("gamebrain:{}", raw_id),
198                name: g.name,
199                cover_url: g.image,
200                genre: g.genre,
201                year: g.year.map(|y| y as u32),
202                rating: g.rating.and_then(|r| r.mean).map(|m| (m * 100.0).round()),
203                link: g.link,
204                screenshots: g.screenshots,
205                micro_trailer: g.micro_trailer,
206                adult_only: g.adult_only,
207            })
208        })
209        .collect();
210
211    tracing::debug!(
212        "GameBrain similar => {} jogos para gamebrain_id={}",
213        results.len(),
214        gamebrain_id
215    );
216
217    // Salva no cache persistente antes de retornar
218    let _ = save_cached_json(app, "gamebrain", &similar_cache_key, &results);
219
220    Ok(take_similar_limit(results, requested_limit))
221}