Skip to main content

game_manager_lib/services/
cache.rs

1//! Módulo de cache para metadados de APIs externas
2//!
3//! Gerencia cache persistente em SQLite para respostas de RAWG e Steam,
4//! reduzindo chamadas desnecessárias e melhorando performance.
5
6use crate::constants::{CACHE_AMAZON_LUNA_TTL_DAYS, CACHE_DEFAULT_TTL_DAYS, CACHE_EA_PLAY_TTL_DAYS, CACHE_GAMEBRAIN_ID_TTL_DAYS, CACHE_GAMEBRAIN_MEDIA_TTL_DAYS, CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS, CACHE_GAMERPOWER_TTL_DAYS, CACHE_GAME_PASS_FULL_TTL_DAYS, CACHE_PROTON_DB_TTL_DAYS, CACHE_RAWG_GAME_TTL_DAYS, CACHE_RAWG_LIST_TTL_DAYS, CACHE_STEAM_PLAYTIME_TTL_DAYS, CACHE_STEAM_RESOLVE_TTL_DAYS, CACHE_STEAM_REVIEWS_TTL_DAYS, CACHE_STEAM_STORE_TTL_DAYS, CACHE_UBISOFT_PLUS_TTL_DAYS, NEXUS_CACHE_TTL_DAYS};
7use crate::errors::AppError;
8use crate::services::integration::nexus::NexusGame;
9use rusqlite::{params, Connection, OptionalExtension};
10use std::time::{SystemTime, UNIX_EPOCH};
11use tracing::{info, warn};
12
13/// Estrutura de estatísticas do cache
14#[derive(Debug, serde::Serialize)]
15pub struct CacheStats {
16    pub total_entries: i32,
17    pub rawg_entries: i32,
18    pub gamebrain_entries: i32,
19    pub steam_entries: i32,
20    pub expired_entries: i32,
21}
22
23/// Obtém timestamp atual em segundos
24fn current_timestamp() -> i64 {
25    SystemTime::now()
26        .duration_since(UNIX_EPOCH)
27        .unwrap()
28        .as_secs() as i64
29}
30
31// === INICIALIZAÇÃO DB ===
32
33/// Inicializa o banco de cache e cria o schema
34pub fn initialize_cache_db(conn: &Connection) -> Result<(), String> {
35    conn.execute(
36        "CREATE TABLE IF NOT EXISTS api_cache (
37            source TEXT NOT NULL,
38            external_id TEXT NOT NULL,
39            payload TEXT NOT NULL,
40            updated_at INTEGER NOT NULL,
41            PRIMARY KEY (source, external_id)
42        )",
43        [],
44    )
45        .map_err(|e| format!("Erro ao criar tabela api_cache: {}", e))?;
46
47    // Índice para facilitar queries de limpeza por data
48    conn.execute(
49        "CREATE INDEX IF NOT EXISTS idx_cache_updated
50         ON api_cache(source, updated_at)",
51        [],
52    )
53        .map_err(|e| format!("Erro ao criar índice: {}", e))?;
54
55    // Tabelas para o cache de jogos do Nexus
56    conn.execute(
57        "CREATE TABLE IF NOT EXISTS nexus_games (
58        domain_name TEXT PRIMARY KEY,
59        nexus_id    INTEGER NOT NULL,
60        name        TEXT NOT NULL,
61        genre       TEXT,
62        approved_date INTEGER
63    )",
64        [],
65    )
66        .map_err(|e| format!("Erro ao criar tabela nexus_games: {}", e))?;
67
68    conn.execute(
69        "CREATE INDEX IF NOT EXISTS idx_nexus_games_name ON nexus_games(name)",
70        [],
71    )
72        .map_err(|e| format!("Erro ao criar índice: {}", e))?;
73
74    conn.execute(
75        "CREATE TABLE IF NOT EXISTS nexus_games_cache_meta (
76            id         INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
77            fetched_at INTEGER NOT NULL
78        )",
79        [],
80    )
81        .map_err(|e| format!("Erro ao criar tabela nexus_games_cache_meta: {}", e))?;
82
83    Ok(())
84}
85
86// === FUNÇÕES DE GERENCIAMENTO DE CACHE ===
87
88/// Determina o TTL baseado no tipo de dado armazenado em cache.
89fn get_ttl_for_cache_type(cache_key: &str) -> i64 {
90    // TTL de 1 dia para listas (Em Alta, Gratuitos, Lançamentos, etc.)
91    if cache_key.contains("_list_") {
92        CACHE_RAWG_LIST_TTL_DAYS * 24 * 60 * 60
93    } else if cache_key.starts_with("resolve_") {
94        CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60
95    } else if cache_key.starts_with("protondb_") {
96        CACHE_PROTON_DB_TTL_DAYS * 24 * 60 * 60
97    } else if cache_key.starts_with("gamebrain_id:") {
98        CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60
99    } else if cache_key.starts_with("gamebrain_similar:") {
100        CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60
101    } else if cache_key.starts_with("gamebrain_media:") {
102        CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60
103    } else if cache_key.starts_with("catalog_amazon_luna") {
104        CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60
105    } else if cache_key.starts_with("gamerpower_list_active") {
106        CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60
107    } else if cache_key.starts_with("catalog_game_pass_full") {
108        CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60
109    } else if cache_key.starts_with("catalog_ubisoft_plus") {
110        CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60
111    } else if cache_key.starts_with("catalog_ea_play") {
112        CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60
113    } else if cache_key.starts_with("rawg_") {
114        CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60
115    } else if cache_key.starts_with("store_") {
116        CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60
117    } else if cache_key.starts_with("reviews_") {
118        CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60
119    } else if cache_key.starts_with("playtime_") {
120        CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60
121    } else {
122        CACHE_DEFAULT_TTL_DAYS * 24 * 60 * 60 // default 7 dias
123    }
124}
125
126/// Verifica se o cache está expirado baseado no TTL do tipo de dado
127fn is_cache_expired(cache_key: &str, updated_at: i64) -> bool {
128    let now = current_timestamp();
129    let ttl_seconds = get_ttl_for_cache_type(cache_key);
130
131    (now - updated_at) > ttl_seconds
132}
133
134/// Busca dados em cache
135///
136/// Retorna None se:
137/// - Dados não existem
138/// - Cache expirou
139pub fn get_cached_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
140    let result: Result<(String, i64), rusqlite::Error> = conn.query_row(
141        "SELECT payload, updated_at FROM api_cache
142         WHERE source = ?1 AND external_id = ?2",
143        params![source, external_id],
144        |row| Ok((row.get(0)?, row.get(1)?)),
145    );
146
147    match result {
148        Ok((payload, updated_at)) => {
149            // Usa a chave completa (external_id) para determinar TTL
150            let full_key = external_id;
151            if is_cache_expired(full_key, updated_at) {
152                None
153            } else {
154                Some(payload)
155            }
156        }
157        Err(rusqlite::Error::QueryReturnedNoRows) => None,
158        Err(e) => {
159            warn!("Erro ao buscar cache: {}", e);
160            None
161        }
162    }
163}
164
165/// Salva dados no cache
166pub fn save_cached_api_data(
167    conn: &Connection,
168    source: &str,
169    external_id: &str,
170    payload: &str,
171) -> Result<(), String> {
172    let now = current_timestamp();
173
174    conn.execute(
175        "INSERT OR REPLACE INTO api_cache (source, external_id, payload, updated_at)
176         VALUES (?1, ?2, ?3, ?4)",
177        params![source, external_id, payload, now],
178    )
179        .map_err(|e| format!("Erro ao salvar cache: {}", e))?;
180
181    Ok(())
182}
183
184/// Remove entradas expiradas do cache (limpeza granular)
185pub fn cleanup_expired_cache(conn: &Connection) -> Result<usize, String> {
186    let now = current_timestamp();
187
188    // Diferentes cutoffs para diferentes tipos
189    let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
190    let gamebrain_id_cutoff = now - (CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60);
191    let gamebrain_similar_cutoff = now - (CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60);
192    let gamebrain_media_cutoff = now - (CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60);
193    let amazon_luna_cutoff = now - (CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60);
194    let gamerpower_cutoff = now - (CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60);
195    let game_pass_full_cutoff = now - (CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60);
196    let ubisoft_plus_cutoff = now - (CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60);
197    let ea_play_cutoff = now - (CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60);
198    let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
199    let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
200    let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
201    let steam_resolve_cutoff = now - (CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60);
202    let protondb_cutoff = now - (CACHE_PROTON_DB_TTL_DAYS * 24 * 60 * 60);
203
204    let deleted = conn
205        .execute(
206            "DELETE FROM api_cache
207             WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
208                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_id:%' AND updated_at < ?2)
209                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_similar:%' AND updated_at < ?3)
210                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_media:%' AND updated_at < ?4)
211                OR (source = 'amazon_luna' AND external_id LIKE 'catalog_amazon_luna%' AND updated_at < ?5)
212                OR (source = 'gamerpower' AND external_id LIKE 'gamerpower_list_active%' AND updated_at < ?6)
213                OR (source = 'game_pass_pc' AND external_id LIKE 'catalog_game_pass_full%' AND updated_at < ?7)
214                OR (source = 'ubisoft_plus' AND external_id LIKE 'catalog_ubisoft_plus%' AND updated_at < ?8)
215                OR (source = 'ea_play' AND external_id LIKE 'catalog_ea_play%' AND updated_at < ?9)
216                OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?10)
217                OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?11)
218                OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?12)
219                OR (source = 'steam_resolve' AND updated_at < ?13)
220                OR (source = 'protondb' AND updated_at < ?14)",
221            params![
222                rawg_cutoff,
223                gamebrain_id_cutoff,
224                gamebrain_similar_cutoff,
225                gamebrain_media_cutoff,
226                amazon_luna_cutoff,
227                gamerpower_cutoff,
228                game_pass_full_cutoff,
229                ubisoft_plus_cutoff,
230                ea_play_cutoff,
231                store_cutoff,
232                reviews_cutoff,
233                playtime_cutoff,
234                steam_resolve_cutoff,
235                protondb_cutoff,
236            ],
237        )
238        .map_err(|e| AppError::CacheCleanupError(e.to_string()).to_string())?;
239
240    if deleted > 0 {
241        info!("Cache cleanup: {} entradas removidas", deleted);
242    }
243
244    Ok(deleted)
245}
246
247/// Busca dados em cache IGNORANDO a validade (para modo Offline)
248///
249/// Retorna Some(payload) se existir, independente da data.
250pub fn get_stale_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
251    let result: Result<String, rusqlite::Error> = conn.query_row(
252        "SELECT payload FROM api_cache
253         WHERE source = ?1 AND external_id = ?2",
254        params![source, external_id],
255        |row| row.get(0),
256    );
257
258    result.ok() // Retorna o dado se existir, ou None se nunca foi salvo
259}
260
261/// Retorna estatísticas do cache
262pub fn get_cache_stats(conn: &Connection) -> Result<CacheStats, String> {
263    let total: i32 = conn
264        .query_row("SELECT COUNT(*) FROM api_cache", [], |row| row.get(0))
265        .unwrap_or(0);
266
267    let rawg: i32 = conn
268        .query_row(
269            "SELECT COUNT(*) FROM api_cache WHERE source = 'rawg'",
270            [],
271            |row| row.get(0),
272        )
273        .unwrap_or(0);
274
275    let gamebrain: i32 = conn
276        .query_row(
277            "SELECT COUNT(*) FROM api_cache WHERE source = 'gamebrain'",
278            [],
279            |row| row.get(0),
280        )
281        .unwrap_or(0);
282
283    let steam: i32 = conn
284        .query_row(
285            "SELECT COUNT(*) FROM api_cache WHERE source = 'steam'",
286            [],
287            |row| row.get(0),
288        )
289        .unwrap_or(0);
290
291    let now = current_timestamp();
292    let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
293    let gamebrain_id_cutoff = now - (CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60);
294    let gamebrain_similar_cutoff = now - (CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60);
295    let gamebrain_media_cutoff = now - (CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60);
296    let amazon_luna_cutoff = now - (CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60);
297    let gamerpower_cutoff = now - (CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60);
298    let game_pass_full_cutoff = now - (CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60);
299    let ubisoft_plus_cutoff = now - (CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60);
300    let ea_play_cutoff = now - (CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60);
301    let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
302    let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
303    let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
304    let steam_resolve_cutoff = now - (CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60);
305    let protondb_cutoff = now - (CACHE_PROTON_DB_TTL_DAYS * 24 * 60 * 60);
306
307    let expired: i32 = conn
308        .query_row(
309            "SELECT COUNT(*) FROM api_cache
310             WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
311                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_id:%' AND updated_at < ?2)
312                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_similar:%' AND updated_at < ?3)
313                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_media:%' AND updated_at < ?4)
314                OR (source = 'amazon_luna' AND external_id LIKE 'catalog_amazon_luna%' AND updated_at < ?5)
315                OR (source = 'gamerpower' AND external_id LIKE 'gamerpower_list_active%' AND updated_at < ?6)
316                OR (source = 'game_pass_pc' AND external_id LIKE 'catalog_game_pass_full%' AND updated_at < ?7)
317                OR (source = 'ubisoft_plus' AND external_id LIKE 'catalog_ubisoft_plus%' AND updated_at < ?8)
318                OR (source = 'ea_play' AND external_id LIKE 'catalog_ea_play%' AND updated_at < ?9)
319                OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?10)
320                OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?11)
321                OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?12)
322                OR (source = 'steam_resolve' AND updated_at < ?13)
323                OR (source = 'protondb' AND updated_at < ?14)",
324            params![
325                rawg_cutoff,
326                gamebrain_id_cutoff,
327                gamebrain_similar_cutoff,
328                gamebrain_media_cutoff,
329                amazon_luna_cutoff,
330                gamerpower_cutoff,
331                game_pass_full_cutoff,
332                ubisoft_plus_cutoff,
333                ea_play_cutoff,
334                store_cutoff,
335                reviews_cutoff,
336                playtime_cutoff,
337                steam_resolve_cutoff,
338                protondb_cutoff,
339            ],
340            |row| row.get(0),
341        )
342        .unwrap_or(0);
343
344    Ok(CacheStats {
345        total_entries: total,
346        rawg_entries: rawg,
347        gamebrain_entries: gamebrain,
348        steam_entries: steam,
349        expired_entries: expired,
350    })
351}
352
353// === NEXUS MODS (Cache) ===
354
355pub fn save_nexus_games_cache(
356    conn: &Connection,
357    games: &[NexusGame],
358) -> Result<(), rusqlite::Error> {
359    let tx = conn.unchecked_transaction()?;
360
361    tx.execute("DELETE FROM nexus_games", [])?;
362
363    {
364        let mut stmt = tx.prepare(
365            "INSERT OR REPLACE INTO nexus_games (domain_name, nexus_id, name, genre, approved_date)
366             VALUES (?1, ?2, ?3, ?4, ?5)",
367        )?;
368
369        for game in games {
370            stmt.execute(params![
371                game.domain_name,
372                game.id,
373                game.name,
374                game.genre,
375                game.approved_date,
376            ])?;
377        }
378    }
379
380    let now = chrono::Utc::now().timestamp();
381    tx.execute(
382        "INSERT OR REPLACE INTO nexus_games_cache_meta (id, fetched_at) VALUES (1, ?1)",
383        params![now],
384    )?;
385
386    tx.commit()
387}
388
389pub fn nexus_cache_is_stale(conn: &Connection) -> Result<bool, rusqlite::Error> {
390    let fetched_at: Option<i64> = conn
391        .query_row(
392            "SELECT fetched_at FROM nexus_games_cache_meta WHERE id = 1",
393            [],
394            |row| row.get(0),
395        )
396        .optional()?;
397
398    match fetched_at {
399        None => Ok(true),
400        Some(ts) => {
401            let now = chrono::Utc::now().timestamp();
402            Ok(now - ts > NEXUS_CACHE_TTL_DAYS * 24 * 60 * 60) // TTL expirado
403        }
404    }
405}
406
407/// Carrega todos os jogos do cache local do Nexus (tabela nexus_games)
408pub fn get_cached_nexus_games(conn: &Connection) -> Result<Vec<NexusGame>, rusqlite::Error> {
409    let mut stmt = conn.prepare(
410        "SELECT nexus_id, name, domain_name, genre, approved_date FROM nexus_games",
411    )?;
412
413    let games = stmt
414        .query_map([], |row| {
415            Ok(NexusGame {
416                id: row.get(0)?,
417                name: row.get(1)?,
418                domain_name: row.get(2)?,
419                genre: row.get(3)?,
420                approved_date: row.get(4)?,
421            })
422        })?
423        .collect::<Result<Vec<_>, _>>()?;
424
425    Ok(games)
426}