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::{
7    CACHE_AMAZON_LUNA_TTL_DAYS, CACHE_DEFAULT_TTL_DAYS, CACHE_EA_PLAY_TTL_DAYS,
8    CACHE_GAMEBRAIN_ID_TTL_DAYS, CACHE_GAMEBRAIN_MEDIA_TTL_DAYS, CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS,
9    CACHE_GAMERPOWER_TTL_DAYS, CACHE_GAME_PASS_FULL_TTL_DAYS, CACHE_RAWG_GAME_TTL_DAYS,
10    CACHE_RAWG_LIST_TTL_DAYS, CACHE_STEAM_PLAYTIME_TTL_DAYS, CACHE_STEAM_RESOLVE_TTL_DAYS,
11    CACHE_STEAM_REVIEWS_TTL_DAYS, CACHE_STEAM_STORE_TTL_DAYS, CACHE_UBISOFT_PLUS_TTL_DAYS,
12};
13use crate::errors::AppError;
14use rusqlite::{params, Connection};
15use std::time::{SystemTime, UNIX_EPOCH};
16use tracing::{info, warn};
17
18/// Estrutura de estatísticas do cache
19#[derive(Debug, serde::Serialize)]
20pub struct CacheStats {
21    pub total_entries: i32,
22    pub rawg_entries: i32,
23    pub gamebrain_entries: i32,
24    pub steam_entries: i32,
25    pub expired_entries: i32,
26}
27
28/// Obtém timestamp atual em segundos
29fn current_timestamp() -> i64 {
30    SystemTime::now()
31        .duration_since(UNIX_EPOCH)
32        .unwrap()
33        .as_secs() as i64
34}
35
36/// Inicializa o banco de cache e cria o schema
37pub fn initialize_cache_db(conn: &Connection) -> Result<(), String> {
38    conn.execute(
39        "CREATE TABLE IF NOT EXISTS api_cache (
40            source TEXT NOT NULL,
41            external_id TEXT NOT NULL,
42            payload TEXT NOT NULL,
43            updated_at INTEGER NOT NULL,
44            PRIMARY KEY (source, external_id)
45        )",
46        [],
47    )
48    .map_err(|e| format!("Erro ao criar tabela api_cache: {}", e))?;
49
50    // Índice para facilitar queries de limpeza por data
51    conn.execute(
52        "CREATE INDEX IF NOT EXISTS idx_cache_updated
53         ON api_cache(source, updated_at)",
54        [],
55    )
56    .map_err(|e| format!("Erro ao criar índice: {}", e))?;
57
58    Ok(())
59}
60
61/// Determina o TTL baseado no tipo de dado armazenado em cache.
62fn get_ttl_for_cache_type(cache_key: &str) -> i64 {
63    // TTL de 1 dia para listas (Em Alta, Gratuitos, Lançamentos, etc.)
64    if cache_key.contains("_list_") {
65        CACHE_RAWG_LIST_TTL_DAYS * 24 * 60 * 60
66    } else if cache_key.starts_with("resolve_") {
67        CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60
68    } else if cache_key.starts_with("gamebrain_id:") {
69        CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60
70    } else if cache_key.starts_with("gamebrain_similar:") {
71        CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60
72    } else if cache_key.starts_with("gamebrain_media:") {
73        CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60
74    } else if cache_key.starts_with("catalog_amazon_luna") {
75        CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60
76    } else if cache_key.starts_with("gamerpower_list_active") {
77        CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60
78    } else if cache_key.starts_with("catalog_game_pass_full") {
79        CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60
80    } else if cache_key.starts_with("catalog_ubisoft_plus") {
81        CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60
82    } else if cache_key.starts_with("catalog_ea_play") {
83        CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60
84    } else if cache_key.starts_with("rawg_") {
85        CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60
86    } else if cache_key.starts_with("store_") {
87        CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60
88    } else if cache_key.starts_with("reviews_") {
89        CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60
90    } else if cache_key.starts_with("playtime_") {
91        CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60
92    } else {
93        CACHE_DEFAULT_TTL_DAYS * 24 * 60 * 60 // default 7 dias
94    }
95}
96
97/// Verifica se o cache está expirado baseado no TTL do tipo de dado
98fn is_cache_expired(cache_key: &str, updated_at: i64) -> bool {
99    let now = current_timestamp();
100    let ttl_seconds = get_ttl_for_cache_type(cache_key);
101
102    (now - updated_at) > ttl_seconds
103}
104
105/// Busca dados em cache
106///
107/// Retorna None se:
108/// - Dados não existem
109/// - Cache expirou
110pub fn get_cached_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
111    let result: Result<(String, i64), rusqlite::Error> = conn.query_row(
112        "SELECT payload, updated_at FROM api_cache
113         WHERE source = ?1 AND external_id = ?2",
114        params![source, external_id],
115        |row| Ok((row.get(0)?, row.get(1)?)),
116    );
117
118    match result {
119        Ok((payload, updated_at)) => {
120            // Usa a chave completa (external_id) para determinar TTL
121            let full_key = external_id;
122            if is_cache_expired(full_key, updated_at) {
123                None
124            } else {
125                Some(payload)
126            }
127        }
128        Err(rusqlite::Error::QueryReturnedNoRows) => None,
129        Err(e) => {
130            warn!("Erro ao buscar cache: {}", e);
131            None
132        }
133    }
134}
135
136/// Salva dados no cache
137pub fn save_cached_api_data(
138    conn: &Connection,
139    source: &str,
140    external_id: &str,
141    payload: &str,
142) -> Result<(), String> {
143    let now = current_timestamp();
144
145    conn.execute(
146        "INSERT OR REPLACE INTO api_cache (source, external_id, payload, updated_at)
147         VALUES (?1, ?2, ?3, ?4)",
148        params![source, external_id, payload, now],
149    )
150    .map_err(|e| format!("Erro ao salvar cache: {}", e))?;
151
152    Ok(())
153}
154
155/// Remove entradas expiradas do cache (limpeza granular)
156pub fn cleanup_expired_cache(conn: &Connection) -> Result<usize, String> {
157    let now = current_timestamp();
158
159    // Diferentes cutoffs para diferentes tipos
160    let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
161
162    let gamebrain_id_cutoff = now - (CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60);
163    let gamebrain_similar_cutoff = now - (CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60);
164    let gamebrain_media_cutoff = now - (CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60);
165    let amazon_luna_cutoff = now - (CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60);
166    let gamerpower_cutoff = now - (CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60);
167    let game_pass_full_cutoff = now - (CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60);
168    let ubisoft_plus_cutoff = now - (CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60);
169    let ea_play_cutoff = now - (CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60);
170    let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
171    let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
172    let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
173    let steam_resolve_cutoff = now - (CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60);
174
175    let deleted = conn
176        .execute(
177            "DELETE FROM api_cache
178             WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
179                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_id:%' AND updated_at < ?2)
180                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_similar:%' AND updated_at < ?3)
181                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_media:%' AND updated_at < ?4)
182                OR (source = 'amazon_luna' AND external_id LIKE 'catalog_amazon_luna%' AND updated_at < ?5)
183                OR (source = 'gamerpower' AND external_id LIKE 'gamerpower_list_active%' AND updated_at < ?6)
184                OR (source = 'game_pass_pc' AND external_id LIKE 'catalog_game_pass_full%' AND updated_at < ?7)
185                OR (source = 'ubisoft_plus' AND external_id LIKE 'catalog_ubisoft_plus%' AND updated_at < ?8)
186                OR (source = 'ea_play' AND external_id LIKE 'catalog_ea_play%' AND updated_at < ?9)
187                OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?10)
188                OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?11)
189                OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?12)
190                OR (source = 'steam_resolve' AND updated_at < ?13)",
191            params![
192                rawg_cutoff,
193                gamebrain_id_cutoff,
194                gamebrain_similar_cutoff,
195                gamebrain_media_cutoff,
196                amazon_luna_cutoff,
197                gamerpower_cutoff,
198                game_pass_full_cutoff,
199                ubisoft_plus_cutoff,
200                ea_play_cutoff,
201                store_cutoff,
202                reviews_cutoff,
203                playtime_cutoff,
204                steam_resolve_cutoff,
205            ],
206        )
207        .map_err(|e| AppError::CacheCleanupError(e.to_string()).to_string())?;
208
209    if deleted > 0 {
210        info!("Cache cleanup: {} entradas removidas", deleted);
211    }
212
213    Ok(deleted)
214}
215
216/// Busca dados em cache IGNORANDO a validade (para modo Offline)
217///
218/// Retorna Some(payload) se existir, independente da data.
219pub fn get_stale_api_data(conn: &Connection, source: &str, external_id: &str) -> Option<String> {
220    let result: Result<String, rusqlite::Error> = conn.query_row(
221        "SELECT payload FROM api_cache
222         WHERE source = ?1 AND external_id = ?2",
223        params![source, external_id],
224        |row| row.get(0),
225    );
226
227    result.ok() // Retorna o dado se existir, ou None se nunca foi salvo
228}
229
230/// Retorna estatísticas do cache
231pub fn get_cache_stats(conn: &Connection) -> Result<CacheStats, String> {
232    let total: i32 = conn
233        .query_row("SELECT COUNT(*) FROM api_cache", [], |row| row.get(0))
234        .unwrap_or(0);
235
236    let rawg: i32 = conn
237        .query_row(
238            "SELECT COUNT(*) FROM api_cache WHERE source = 'rawg'",
239            [],
240            |row| row.get(0),
241        )
242        .unwrap_or(0);
243
244    let gamebrain: i32 = conn
245        .query_row(
246            "SELECT COUNT(*) FROM api_cache WHERE source = 'gamebrain'",
247            [],
248            |row| row.get(0),
249        )
250        .unwrap_or(0);
251
252    let steam: i32 = conn
253        .query_row(
254            "SELECT COUNT(*) FROM api_cache WHERE source = 'steam'",
255            [],
256            |row| row.get(0),
257        )
258        .unwrap_or(0);
259
260    let now = current_timestamp();
261    let rawg_cutoff = now - (CACHE_RAWG_GAME_TTL_DAYS * 24 * 60 * 60);
262    let gamebrain_id_cutoff = now - (CACHE_GAMEBRAIN_ID_TTL_DAYS * 24 * 60 * 60);
263    let gamebrain_similar_cutoff = now - (CACHE_GAMEBRAIN_SIMILAR_TTL_DAYS * 24 * 60 * 60);
264    let gamebrain_media_cutoff = now - (CACHE_GAMEBRAIN_MEDIA_TTL_DAYS * 24 * 60 * 60);
265    let amazon_luna_cutoff = now - (CACHE_AMAZON_LUNA_TTL_DAYS * 24 * 60 * 60);
266    let gamerpower_cutoff = now - (CACHE_GAMERPOWER_TTL_DAYS * 24 * 60 * 60);
267    let game_pass_full_cutoff = now - (CACHE_GAME_PASS_FULL_TTL_DAYS * 24 * 60 * 60);
268    let ubisoft_plus_cutoff = now - (CACHE_UBISOFT_PLUS_TTL_DAYS * 24 * 60 * 60);
269    let ea_play_cutoff = now - (CACHE_EA_PLAY_TTL_DAYS * 24 * 60 * 60);
270    let store_cutoff = now - (CACHE_STEAM_STORE_TTL_DAYS * 24 * 60 * 60);
271    let reviews_cutoff = now - (CACHE_STEAM_REVIEWS_TTL_DAYS * 24 * 60 * 60);
272    let playtime_cutoff = now - (CACHE_STEAM_PLAYTIME_TTL_DAYS * 24 * 60 * 60);
273    let steam_resolve_cutoff = now - (CACHE_STEAM_RESOLVE_TTL_DAYS * 24 * 60 * 60);
274
275    let expired: i32 = conn
276        .query_row(
277            "SELECT COUNT(*) FROM api_cache
278             WHERE (source = 'rawg' AND external_id LIKE 'search_%' AND updated_at < ?1)
279                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_id:%' AND updated_at < ?2)
280                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_similar:%' AND updated_at < ?3)
281                OR (source = 'gamebrain' AND external_id LIKE 'gamebrain_media:%' AND updated_at < ?4)
282                OR (source = 'amazon_luna' AND external_id LIKE 'catalog_amazon_luna%' AND updated_at < ?5)
283                OR (source = 'gamerpower' AND external_id LIKE 'gamerpower_list_active%' AND updated_at < ?6)
284                OR (source = 'game_pass_pc' AND external_id LIKE 'catalog_game_pass_full%' AND updated_at < ?7)
285                OR (source = 'ubisoft_plus' AND external_id LIKE 'catalog_ubisoft_plus%' AND updated_at < ?8)
286                OR (source = 'ea_play' AND external_id LIKE 'catalog_ea_play%' AND updated_at < ?9)
287                OR (source = 'steam' AND external_id LIKE 'store_%' AND updated_at < ?10)
288                OR (source = 'steam' AND external_id LIKE 'reviews_%' AND updated_at < ?11)
289                OR (source = 'steam' AND external_id LIKE 'playtime_%' AND updated_at < ?12)
290                OR (source = 'steam_resolve' AND updated_at < ?13)",
291            params![
292                rawg_cutoff,
293                gamebrain_id_cutoff,
294                gamebrain_similar_cutoff,
295                gamebrain_media_cutoff,
296                amazon_luna_cutoff,
297                gamerpower_cutoff,
298                game_pass_full_cutoff,
299                ubisoft_plus_cutoff,
300                ea_play_cutoff,
301                store_cutoff,
302                reviews_cutoff,
303                playtime_cutoff,
304                steam_resolve_cutoff,
305            ],
306            |row| row.get(0),
307        )
308        .unwrap_or(0);
309
310    Ok(CacheStats {
311        total_entries: total,
312        rawg_entries: rawg,
313        gamebrain_entries: gamebrain,
314        steam_entries: steam,
315        expired_entries: expired,
316    })
317}