Skip to main content

game_manager_lib/database/
core.rs

1//! Módulo de gerenciamento do banco de dados da aplicação.
2//!
3//! Gerencia a criação e inicialização do banco SQLite para a biblioteca de jogos e wishlist,
4//! além do armazenamento seguro de secrets (API keys, tokens) com criptografia.
5//!
6//! **Bancos de Dados:**
7//! - games.db: armazena jogos, wishlist, dados técnicos (pcgw_data) e subscriptions.
8//! - secrets.db: armazena secrets encriptados com AES-256-GCM.
9//! - cache.db: cache para respostas de APIs externas (RAWG, Steam).
10
11use crate::constants::{
12    DB_FILENAME_CACHE, DB_FILENAME_GAMES, DB_FILENAME_SECRETS, DB_JOURNAL_MODE,
13};
14use crate::errors::AppError;
15use crate::security;
16use crate::services::integration::pcgamingwiki::db::initialize_pcgamingwiki_tables;
17use crate::services::playtime::PlaytimeRegistry;
18use rusqlite::{params, Connection};
19use std::sync::Mutex;
20use tauri::State;
21use tauri::{AppHandle, Manager};
22
23/// Define o estado global da aplicação com ambas as conexões
24pub struct AppState {
25    pub games_db: Mutex<Connection>,
26    pub secrets_db: Mutex<Connection>,
27    pub cache_db: Mutex<Connection>,
28    pub playtime_registry: PlaytimeRegistry,
29}
30
31/// Retorna a versão atual do schema armazenada no banco
32pub fn current_schema_version(conn: &Connection) -> Result<u32, AppError> {
33    let version: i32 = conn
34        .query_row("PRAGMA user_version", [], |row| row.get(0))
35        .unwrap_or(0);
36
37    Ok(version.max(0) as u32)
38}
39
40/// Retorna a versão do schema esperada para esta versão do app
41pub fn expected_schema_version(app: &AppHandle) -> u32 {
42    // Usa o MAJOR da versão do app
43    let version = app.package_info().version.clone();
44    version.major as u32
45}
46
47// === INICIALIZAÇÃO CENTRALIZADA ===
48
49/// Inicializa ambos os bancos de dados e retorna o estado da aplicação
50///
51/// **Erros:**
52/// - Se não conseguir criar os diretórios
53/// - Se não conseguir abrir as conexões
54/// - Se falhar ao configurar WAL mode
55pub fn initialize_databases(app: &AppHandle) -> Result<AppState, String> {
56    let app_data_dir = app
57        .path()
58        .app_data_dir()
59        .map_err(|e| format!("Falha ao obter app_data_dir: {}", e))?;
60
61    std::fs::create_dir_all(&app_data_dir)
62        .map_err(|e| format!("Falha ao criar diretório: {}", e))?;
63
64    // Conexão para games.db
65    let games_path = app_data_dir.join(DB_FILENAME_GAMES);
66    let games_conn = Connection::open(&games_path)
67        .map_err(|e| format!("Erro ao abrir {}: {}", DB_FILENAME_GAMES, e))?;
68
69    games_conn
70        .pragma_update(None, "journal_mode", DB_JOURNAL_MODE)
71        .map_err(|e| format!("Erro ao configurar WAL no library.db: {}", e))?;
72
73    // Conexão para cache.db
74    let cache_path = app_data_dir.join(DB_FILENAME_CACHE);
75    let cache_conn = Connection::open(&cache_path)
76        .map_err(|e| format!("Erro ao abrir {}: {}", DB_FILENAME_CACHE, e))?;
77
78    cache_conn
79        .pragma_update(None, "journal_mode", DB_JOURNAL_MODE)
80        .map_err(|e| {
81            AppError::DatabaseWalConfigError("cache.db".to_string(), e.to_string()).to_string()
82        })?;
83
84    // Inicializa schema do cache
85    crate::services::cache::initialize_cache_db(&cache_conn)?;
86
87    // Conexão para secrets.db
88    let secrets_path = app_data_dir.join(DB_FILENAME_SECRETS);
89    let secrets_conn = Connection::open(&secrets_path)
90        .map_err(|e| format!("Erro ao abrir {}: {}", DB_FILENAME_SECRETS, e))?;
91
92    secrets_conn
93        .pragma_update(None, "journal_mode", DB_JOURNAL_MODE)
94        .map_err(|e| format!("Erro ao configurar WAL no secrets.db: {}", e))?;
95
96    // Executa migrations
97    crate::database::migrations::run_migrations(app, &games_conn)?;
98
99    // Cria schema completo
100    let schema_version = app.package_info().version.major as u32;
101    create_schema(&games_conn, schema_version)?;
102
103    Ok(AppState {
104        games_db: Mutex::new(games_conn),
105        secrets_db: Mutex::new(secrets_conn),
106        cache_db: Mutex::new(cache_conn),
107        playtime_registry: PlaytimeRegistry::default(),
108    })
109}
110
111// === BANCO DE DADOS DE GERENCIAMENTO DE BIBLIOTECAS E WISHLIST  ===
112
113/// Cria o schema completo do banco de dados (versão v4)
114///
115/// **Schema v4:**
116/// - Campos HLTB removidos
117/// - URLs legadas removidas (agora em external_links JSON)
118/// - users_score removido (substituído por steam_review_*)
119/// - Adicionadas as tabelas:
120///     - subscriptions
121///     - game_extras (detalhes técnicos)
122///     - game_data_paths
123///     - system_requirements
124fn create_schema(conn: &Connection, schema_version: u32) -> Result<(), String> {
125    conn.execute(
126        "CREATE TABLE IF NOT EXISTS games (
127            id TEXT PRIMARY KEY,
128            name TEXT NOT NULL,
129            slug TEXT NOT NULL DEFAULT '',
130            cover_url TEXT,
131            platform TEXT NOT NULL,
132            platform_game_id TEXT NOT NULL,
133            installed BOOLEAN DEFAULT 0,
134            import_confidence TEXT,
135            install_path TEXT,
136            executable_path TEXT,
137            launch_args TEXT,
138            user_rating INTEGER,
139            favorite BOOLEAN DEFAULT 0,
140            status TEXT,
141            playtime INTEGER,
142            playtime_source TEXT,
143            last_played TEXT,
144            added_at TEXT NOT NULL
145        )",
146        [],
147    )
148        .map_err(|e| e.to_string())?;
149
150    conn.execute(
151        "CREATE TABLE IF NOT EXISTS game_details (
152            game_id TEXT PRIMARY KEY,
153            steam_app_id TEXT,
154            developer TEXT,
155            publisher TEXT,
156            release_date TEXT,
157            genres TEXT,
158            tags TEXT,
159            series TEXT,
160            description_raw TEXT,
161            description_ptbr TEXT,
162            background_image TEXT,
163            critic_score INTEGER,
164            steam_review_label TEXT,
165            steam_review_count INTEGER,
166            steam_review_score REAL,
167            steam_review_updated_at TEXT,
168            esrb_rating TEXT,
169            is_adult BOOLEAN DEFAULT 0,
170            adult_tags TEXT,
171            external_links TEXT,
172            median_playtime INTEGER,
173            estimated_playtime REAL,
174            FOREIGN KEY(game_id) REFERENCES games(id) ON DELETE CASCADE
175        )",
176        [],
177    )
178        .map_err(|e| e.to_string())?;
179
180    conn.execute(
181        "CREATE TABLE IF NOT EXISTS wishlist (
182            id TEXT PRIMARY KEY,
183            name TEXT NOT NULL,
184            cover_url TEXT,
185            store_url TEXT,
186            store_platform TEXT,
187            current_price REAL,
188            normal_price REAL,
189            lowest_price REAL,
190            currency TEXT,
191            on_sale BOOLEAN DEFAULT 0,
192            voucher TEXT,
193            added_at TEXT,
194            itad_id TEXT
195        )",
196        [],
197    )
198        .map_err(|e| e.to_string())?;
199
200    conn.execute(
201        "CREATE TABLE IF NOT EXISTS subscriptions (
202        service TEXT PRIMARY KEY,   -- 'prime_gaming', 'game_pass', etc.
203        enabled BOOLEAN DEFAULT 0,
204        last_synced TEXT            -- ISO timestamp do último fetch
205    )",
206        [],
207    )
208        .map_err(|e| e.to_string())?;
209
210    // Índices
211    conn.execute(
212        "CREATE INDEX IF NOT EXISTS idx_name ON games(name COLLATE NOCASE)",
213        [],
214    )
215        .map_err(|e| e.to_string())?;
216
217    conn.execute(
218        "CREATE INDEX IF NOT EXISTS idx_platform ON games(platform)",
219        [],
220    )
221        .map_err(|e| e.to_string())?;
222
223    conn.execute(
224        "CREATE INDEX IF NOT EXISTS idx_favorite ON games(favorite)",
225        [],
226    )
227        .map_err(|e| e.to_string())?;
228
229    conn.execute("CREATE INDEX IF NOT EXISTS idx_status ON games(status)", [])
230        .map_err(|e| e.to_string())?;
231
232    conn.execute("CREATE INDEX IF NOT EXISTS idx_slug ON games(slug)", [])
233        .map_err(|e| e.to_string())?;
234
235    // Tabelas extras - PCGamingWiki e scrapers relacionados
236    initialize_pcgamingwiki_tables(conn).map_err(|e| e.to_string())?;
237
238    // Marca versão do schema
239    conn.pragma_update(None, "user_version", schema_version)
240        .map_err(|e| format!("Erro ao definir versão do schema: {}", e))?;
241
242    Ok(())
243}
244
245/// Inicializa o banco de dados e verifica a versão do schema.
246///
247/// Se o banco estiver desatualizado, retorna erro com instruções para o usuário.
248#[tauri::command]
249pub fn init_db(app: AppHandle, state: State<AppState>) -> Result<String, String> {
250    let conn = state
251        .games_db
252        .lock()
253        .map_err(|_| "Falha ao bloquear mutex do games_db")?;
254
255    let current_version = current_schema_version(&conn).unwrap_or(0) as i32;
256    let expected_version = expected_schema_version(&app) as i32;
257
258    if current_version == 0 {
259        let schema_version = expected_schema_version(&app);
260        return Ok(format!("Banco de dados novo criado (v{})", schema_version));
261    }
262
263    if current_version != expected_version {
264        let app_data_dir = app
265            .path()
266            .app_data_dir()
267            .map_err(|e| format!("Falha ao obter app_data_dir: {}", e))?;
268
269        return Err(format!(
270            "Banco desatualizado: Schema atual: v{}, esperado: v{}. Faça backup, exclua o diretório da aplicação em: {:?} e reinicie para recriar o banco.",
271            current_version, expected_version, app_data_dir
272        ));
273    }
274
275    Ok(format!("Banco de dados OK (v{})", current_version))
276}
277
278/// Serializa tags para salvar no banco
279pub fn serialize_tags(tags: &[crate::models::GameTag]) -> Result<String, String> {
280    serde_json::to_string(tags).map_err(|e| e.to_string())
281}
282
283/// Deserializa tags do banco (com fallback para formato antigo)
284pub fn deserialize_tags(tags_json: &str) -> Vec<crate::models::GameTag> {
285    use crate::utils::tag_utils::{TagCategory, TagRole};
286
287    // Tenta deserializar como novo formato
288    if let Ok(tags) = serde_json::from_str::<Vec<crate::models::GameTag>>(tags_json) {
289        return tags;
290    }
291
292    // Fallback: formato antigo (string separada por vírgulas)
293    tags_json
294        .split(',')
295        .map(|s| s.trim())
296        .filter(|s| !s.is_empty())
297        .map(|slug| crate::models::GameTag {
298            slug: slug.to_string(),
299            name: slug.to_string(),
300            category: TagCategory::Meta,
301            role: TagRole::Context,
302            relevance: 5.0,
303        })
304        .collect()
305}
306
307// === BANCO DE DADOS PARA GERENCIAMENTO DE API KEYS (secrets.db) ===
308
309/// Obtém conexão com o banco de secrets a partir do AppState.
310/// Cria automaticamente a tabela `encrypted_keys` se não existir.
311fn get_secrets_connection<'a>(
312    state: &'a State<AppState>,
313) -> Result<std::sync::MutexGuard<'a, Connection>, String> {
314    let conn = state
315        .secrets_db
316        .lock()
317        .map_err(|_| "Falha ao bloquear mutex do secrets_db".to_string())?;
318
319    conn.execute(
320        r#"
321        CREATE TABLE IF NOT EXISTS encrypted_keys (
322            key TEXT PRIMARY KEY,
323            value TEXT NOT NULL
324        )
325        "#,
326        [],
327    )
328        .map_err(|e: rusqlite::Error| e.to_string())?;
329
330    Ok(conn)
331}
332
333/// Salva um secret encriptado no banco. Se a chave já existir, o valor é substituído (upsert).
334pub fn set_secret(app: &AppHandle, key_name: &str, value: &str) -> Result<(), AppError> {
335    let state: tauri::State<AppState> = app.state();
336    let conn = get_secrets_connection(&state)?;
337
338    let encrypted = security::encrypt(app, value)?;
339
340    conn.execute(
341        "INSERT OR REPLACE INTO encrypted_keys (key, value) VALUES (?1, ?2)",
342        params![key_name, encrypted],
343    )?;
344
345    Ok(())
346}
347
348/// Recupera e decripta um secret do banco. Se a chave não existir, retorna string vazia ao invés de erro.
349pub fn get_secret(app: &AppHandle, key_name: &str) -> Result<String, AppError> {
350    let state: tauri::State<AppState> = app.state();
351    let conn = get_secrets_connection(&state)?;
352
353    let result: Result<String, rusqlite::Error> = conn.query_row(
354        "SELECT value FROM encrypted_keys WHERE key = ?1",
355        params![key_name],
356        |row| row.get::<_, String>(0),
357    );
358
359    match result {
360        Ok(encrypted) => {
361            let decrypted = security::decrypt(app, &encrypted)?;
362            Ok(decrypted)
363        }
364        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(String::new()),
365        Err(e) => Err(AppError::DatabaseError(e.to_string())),
366    }
367}
368
369/// Remove um secret do banco permanentemente.
370pub fn delete_secret(app: &AppHandle, key_name: &str) -> Result<(), AppError> {
371    let state: tauri::State<AppState> = app.state();
372    let conn = get_secrets_connection(&state)?;
373
374    conn.execute(
375        "DELETE FROM encrypted_keys WHERE key = ?1",
376        params![key_name],
377    )?;
378
379    Ok(())
380}
381
382/// Retorna lista de chaves de secrets suportadas pela aplicação.
383pub fn list_supported_keys() -> Vec<&'static str> {
384    vec![
385        "steam_id",
386        "steam_api_key",
387        "rawg_api_key",
388        "gemini_api_key",
389        "gamebrain_api_key",
390        "nexus_api_key",
391    ]
392}