Skip to main content

game_manager_lib/commands/metadata/
refresh.rs

1//! Módulo de atualização automática em background (Preços e Reviews).
2//!
3//! Executa sem travar a UI e falha silenciosamente em caso de erro.
4
5use crate::constants::{
6    BACKGROUND_TASK_INTERVAL_SECS, GAMERPOWER_CACHE_SOURCE, GAMERPOWER_LIST_ACTIVE_CACHE_KEY,
7    STARTUP_DELAY_SECS,
8};
9use crate::database::AppState;
10use crate::errors::AppError;
11use crate::services::cache;
12use crate::services::integration::{gamerpower, itad, steam_api};
13use lazy_static::lazy_static;
14use rusqlite::params;
15use std::sync::Arc;
16use std::time::Duration;
17use tauri::{AppHandle, Emitter, Manager, State};
18use tokio::sync::Semaphore;
19use tokio::time::sleep;
20use tracing::{error, info, warn};
21
22// Semaphore para evitar execuções duplicadas (previne race conditions)
23// Arc permite compartilhamento seguro entre threads
24lazy_static! {
25    static ref BACKGROUND_REFRESH_SEMAPHORE: Arc<Semaphore> = Arc::new(Semaphore::new(1));
26}
27
28/// Comando disparado ao iniciar o app.
29/// Roda numa thread separada (spawn) para não bloquear a inicialização.
30/// Protegido contra execução duplicada (React Strict Mode chama useEffect 2x).
31#[tauri::command]
32pub async fn check_and_refresh_background(app: AppHandle) -> Result<(), AppError> {
33    // Try acquire (non-blocking) - retorna erro se já está rodando
34    let permit = match BACKGROUND_REFRESH_SEMAPHORE.try_acquire() {
35        Ok(permit) => permit,
36        Err(_) => {
37            // Já existe uma instância rodando, ignora esta chamada
38            tracing::debug!("Background refresh já em execução, ignorando chamada duplicada");
39            return Ok(());
40        }
41    };
42
43    // Clone do app_handle para usar no spawn
44    let app_clone = app.clone();
45
46    // SPAWN: Isso garante que o Frontend continua fluido imediatamente
47    tauri::async_runtime::spawn(async move {
48        // Permit é dropped automaticamente ao final (RAII pattern)
49        // Mesmo em caso de panic, o semaphore é liberado
50        let _permit = permit;
51
52        // Pequeno delay inicial para não competir com o boot do banco de dados
53        sleep(Duration::from_secs(STARTUP_DELAY_SECS)).await;
54
55        let state: State<AppState> = app_clone.state();
56
57        // 1. Atualizar Reviews da Steam (Se cache > 7 dias)
58        if let Err(e) = refresh_steam_reviews_background(&app_clone, &state).await {
59            warn!("Falha ao atualizar reviews: {}", e);
60        }
61
62        // 2. Atualizar GamerPower (Se cache expirou)
63        sleep(Duration::from_secs(BACKGROUND_TASK_INTERVAL_SECS)).await;
64
65        if let Err(e) = refresh_gamerpower_background(&app_clone, &state).await {
66            warn!("Falha ao atualizar GamerPower: {}", e);
67        }
68
69        // 3. Atualizar Preços da Wishlist (Se cache > 3 dias)
70        sleep(Duration::from_secs(BACKGROUND_TASK_INTERVAL_SECS)).await;
71
72        if let Err(e) = refresh_wishlist_prices_background(&app_clone, &state).await {
73            warn!("Falha ao atualizar preços: {}", e);
74        }
75
76        // Avisa o frontend que o ciclo de background terminou
77        let _ = app_clone.emit("background_refresh_complete", ());
78
79        // _permit é automaticamente dropped aqui, liberando o semaphore
80    });
81
82    Ok(())
83}
84
85/// Atualiza reviews apenas se o cache estiver expirado.
86/// Emite `reviews_refresh_complete` ao final (mesmo que nada tenha mudado).
87async fn refresh_steam_reviews_background(
88    app: &AppHandle,
89    state: &State<'_, AppState>,
90) -> Result<(), String> {
91    // A. Ler IDs da Steam do banco (Leitura rápida)
92    let steam_games: Vec<(u32, String)> = {
93        let conn = state.games_db.lock().map_err(|_| "Falha DB Lock")?;
94
95        conn.prepare("SELECT platform_game_id, name FROM games WHERE platform = 'Steam'")
96            .and_then(|mut stmt| {
97                stmt.query_map([], |row| {
98                    let id_str: String = row.get(0)?;
99                    let name: String = row.get(1)?;
100                    Ok((id_str.parse::<u32>().unwrap_or(0), name))
101                })
102                .and_then(|mapped| mapped.collect::<Result<Vec<_>, _>>())
103            })
104            .map_err(|e| e.to_string())?
105            .into_iter()
106            .filter(|(id, _)| *id > 0)
107            .collect()
108    };
109
110    if steam_games.is_empty() {
111        return Ok(());
112    }
113
114    let mut updated_count = 0;
115
116    // B. Iterar jogos
117    for (app_id, _title) in steam_games {
118        let should_update = {
119            match state.cache_db.lock() {
120                Ok(cache_conn) => {
121                    let cache_key = format!("reviews_{}", app_id);
122                    // Verifica se o cache expirou
123                    cache::get_cached_api_data(&cache_conn, "steam", &cache_key).is_none()
124                }
125                Err(_) => false, // Se erro ao acessar cache, pula atualização
126            }
127        };
128
129        if should_update {
130            let app_id_str = app_id.to_string();
131            // C. Busca na API (Só se expirou)
132            match steam_api::get_app_reviews(&app_id_str).await {
133                Ok(Some(summary)) => {
134                    // D. Sucesso? Atualiza Library DB e Metadata Cache
135                    {
136                        // 1. Salva no Cache (para não buscar de novo por 7 dias)
137                        if let Ok(cache_conn) = state.cache_db.lock() {
138                            let cache_key = format!("reviews_{}", app_id);
139                            if let Ok(json) = serde_json::to_string(&summary) {
140                                let _ = cache::save_cached_api_data(
141                                    &cache_conn,
142                                    "steam",
143                                    &cache_key,
144                                    &json,
145                                );
146                            }
147                        }
148                    }
149
150                    updated_count += 1;
151                }
152                Ok(None) => { /* Jogo não tem reviews ou erro 404, ignora */ }
153                Err(e) => {
154                    // E. Erro de API/Conexão? IGNORA. Mantém o dado velho.
155                    warn!("Falha ao buscar review {}: {}", app_id, e);
156                }
157            }
158            // Rate limit suave
159            sleep(Duration::from_millis(200)).await;
160        }
161    }
162
163    if updated_count > 0 {
164        info!("{} avaliações atualizadas", updated_count);
165        let _ = app.emit(
166            "reviews_refresh_complete",
167            format!(
168                "{} avaliações Steam atualizadas em background.",
169                updated_count
170            ),
171        );
172    }
173
174    Ok(())
175}
176
177/// Atualiza o cache do GamerPower apenas quando o cache estiver expirado.
178async fn refresh_gamerpower_background(
179    app: &AppHandle,
180    state: &State<'_, AppState>,
181) -> Result<(), String> {
182    let should_refresh = {
183        let cache_conn = state.cache_db.lock().map_err(|_| "Falha DB Cache Lock")?;
184        cache::get_cached_api_data(
185            &cache_conn,
186            GAMERPOWER_CACHE_SOURCE,
187            GAMERPOWER_LIST_ACTIVE_CACHE_KEY,
188        )
189        .is_none()
190    };
191
192    if !should_refresh {
193        return Ok(());
194    }
195
196    match gamerpower::fetch_giveaways(app).await {
197        Ok(_) => {
198            info!("Cache GamerPower atualizado em background");
199            let _ = app.emit("gamerpower_refresh_complete", ());
200            Ok(())
201        }
202        Err(e) => {
203            warn!("Falha ao atualizar GamerPower em background: {}", e);
204            Err(e)
205        }
206    }
207}
208
209/// Atualiza preços da Wishlist se o cache expirou
210async fn refresh_wishlist_prices_background(
211    app: &AppHandle,
212    state: &State<'_, AppState>,
213) -> Result<(), String> {
214    // A. Ler Wishlist com itad_id
215    let wishlist_items: Vec<(String, String, Option<String>)> = {
216        let conn = state.games_db.lock().map_err(|_| "Falha DB")?;
217
218        conn.prepare("SELECT id, name, itad_id FROM wishlist")
219            .and_then(|mut stmt| {
220                stmt.query_map([], |row| {
221                    Ok((
222                        row.get::<_, String>(0)?,
223                        row.get::<_, String>(1)?,
224                        row.get::<_, Option<String>>(2)?,
225                    ))
226                })
227                .and_then(|mapped| mapped.collect::<Result<Vec<_>, _>>())
228            })
229            .map_err(|e| e.to_string())?
230    };
231
232    if wishlist_items.is_empty() {
233        return Ok(());
234    }
235
236    // B. Coleta IDs da ITAD que precisam atualizar
237    let mut itad_ids_to_fetch = Vec::new();
238    let mut game_map = std::collections::HashMap::new();
239
240    for (local_id, name, itad_id_opt) in wishlist_items {
241        // Verifica se tem ITAD ID
242        let itad_id = match itad_id_opt {
243            Some(id) if !id.is_empty() => id,
244            _ => {
245                // Se não tem ITAD ID, tenta buscar
246                match itad::find_game_id(&name).await {
247                    Ok(found_id) => {
248                        // Salva no banco para cachear
249                        let conn = state.games_db.lock().unwrap();
250                        let _ = conn.execute(
251                            "UPDATE wishlist SET itad_id = ?1 WHERE id = ?2",
252                            params![&found_id, &local_id],
253                        );
254                        found_id
255                    }
256                    Err(_) => {
257                        continue; // Pula se não encontrou
258                    }
259                }
260            }
261        };
262
263        // Verifica Cache
264        let should_update = {
265            let cache_conn = state.cache_db.lock().unwrap();
266            let cache_key = format!("price_{}", itad_id);
267            // Se não existe em cache ou expirou, precisa atualizar
268            cache::get_cached_api_data(&cache_conn, "itad", &cache_key).is_none()
269        };
270
271        if should_update {
272            itad_ids_to_fetch.push(itad_id.clone());
273            game_map.insert(itad_id, (local_id, name));
274        }
275    }
276
277    if itad_ids_to_fetch.is_empty() {
278        return Ok(());
279    }
280
281    // C. Busca preços em lote da ITAD
282    let overviews = match itad::get_prices(itad_ids_to_fetch).await {
283        Ok(data) => data,
284        Err(e) => {
285            error!("Erro ao buscar preços da ITAD: {}", e);
286            return Err(e);
287        }
288    };
289
290    let mut updated_count = 0;
291
292    // D. Atualiza banco e cache
293    for game_data in overviews {
294        if let Some((local_id, _game_name)) = game_map.get(&game_data.id) {
295            // Salva no cache como um JSON simplificado
296            {
297                if let Ok(cache_conn) = state.cache_db.lock() {
298                    let cache_key = format!("price_{}", game_data.id);
299
300                    // Cria um JSON manual com os dados relevantes
301                    let cache_data = serde_json::json!({
302                        "id": game_data.id,
303                        "current_price": game_data.current.as_ref().map(|d| d.price),
304                        "currency": game_data.current.as_ref().map(|d| &d.currency),
305                        "lowest_price": game_data.lowest.as_ref().map(|d| d.price),
306                    });
307
308                    let json = cache_data.to_string();
309                    let _ = cache::save_cached_api_data(&cache_conn, "itad", &cache_key, &json);
310                }
311            }
312
313            // Atualiza preços no banco de dados
314            if let Some(deal) = game_data.current {
315                let lowest = game_data.lowest.map(|l| l.price).unwrap_or(deal.price);
316
317                let cut = deal.cut.unwrap_or(0) as f64;
318                let normal_price = if cut > 0.0 {
319                    deal.price / (1.0 - (cut / 100.0))
320                } else {
321                    deal.price
322                };
323
324                if let Ok(conn) = state.games_db.lock() {
325                    match conn.execute(
326                        "UPDATE wishlist SET
327                            current_price = ?1,
328                            currency = ?2,
329                            lowest_price = ?3,
330                            store_platform = ?4,
331                            store_url = ?5,
332                            on_sale = ?6,
333                            normal_price = ?7,
334                            voucher = ?8
335                         WHERE id = ?9",
336                        params![
337                            deal.price,
338                            deal.currency,
339                            lowest,
340                            deal.shop.name,
341                            deal.url,
342                            deal.cut > Some(0),
343                            normal_price,
344                            deal.voucher,
345                            local_id
346                        ],
347                    ) {
348                        Ok(_) => updated_count += 1,
349                        Err(e) => error!("Erro ao atualizar preço: {}", e),
350                    }
351                }
352            }
353        }
354    }
355
356    if updated_count > 0 {
357        info!("{} preços atualizados", updated_count);
358        let _ = app.emit(
359            "wishlist_refresh_complete",
360            format!(
361                "{} preços da Wishlist atualizados em background.",
362                updated_count
363            ),
364        );
365    }
366
367    Ok(())
368}