Skip to main content

game_manager_lib/sources/
itch.rs

1//! Importação de jogos da itch.io (via app itch / butler.db).
2//!
3//! Lê diretamente o banco SQLite `butler.db` que o app itch mantém localmente.
4//! Os dados vêm de um banco relacional real, então usa `rusqlite` em modo somente-leitura.
5//!
6//! Tabelas usadas:
7//! - `games`: biblioteca completa (posse), independente de estar instalado.
8//! - `caves`: instalações — cada linha é um jogo efetivamente instalado no disco (ou que já esteve, caso o app não tenha limpado a tabela).
9//! - `install_locations`: pastas raiz configuráveis no app itch onde os jogos são instalados.
10//! - `cave_historical_play_times`: histórico de sessões de jogo por `cave_id` (`seconds_run`, `last_touched_at`).
11//!
12//! Política de "instalado": usa o que está em `caves`, sem verificar se a pasta ainda existe
13//! fisicamente no disco. Se o usuário apagar a pasta manualmente por fora do app itch, o jogo
14//! continua marcado como instalado até uma reinstalação/ desinstalação real remover a linha de `caves`.
15
16use crate::errors::AppError;
17use crate::sources::providers::SourceGame;
18use async_trait::async_trait;
19use rusqlite::{Connection, OpenFlags};
20use serde::Deserialize;
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25/// Jogo importado da itch.io com campos adicionais além do `SourceGame` padrão.
26#[derive(Debug, Clone)]
27pub struct ItchioGame {
28    pub source: SourceGame,
29    pub description_raw: Option<String>,
30    pub cover_url: Option<String>,
31}
32
33// === LINHAS BRUTAS DO SQLITE ===
34
35struct GameRow {
36    id: i64,
37    title: String,
38    short_text: Option<String>,
39    cover_url: Option<String>,
40}
41
42/// Uma instalação (`caves`), já cruzada com `install_locations` pra ter o path raiz configurado,
43/// o path real e confiável é o `base_path` de dentro do`verdict` (JSON), quando presente.
44/// Se o `verdict` estiver ausente, o `install_location_path` só serve de fallback (ex: verdict corrompido/ausente).
45struct CaveRow {
46    game_id: i64,
47    install_folder_name: Option<String>,
48    verdict: Option<String>,
49    install_location_path: Option<String>,
50}
51
52/// Playtime agregado de todas as sessões históricas de uma `cave`.
53#[derive(Default, Clone, Copy)]
54struct PlaytimeAgg {
55    total_seconds: i64,
56    last_touched_unix: Option<i64>,
57}
58
59// === verdict (JSON dentro de caves.verdict) ===
60//
61// Exemplo real:
62// {"basePath":"E:\\Itch.io\\games\\project-infinity","totalSize":580173388,
63//  "candidates":[{"path":"PI Windows (v0.1 Prologue)/Game.exe","depth":2,
64//  "flavor":"windows","arch":"386","size":1604096,"windowsInfo":{"gui":true}}]}
65
66#[derive(Debug, Deserialize)]
67struct CaveVerdict {
68    #[serde(rename = "basePath")]
69    base_path: Option<String>,
70    #[serde(default)]
71    candidates: Vec<CaveVerdictCandidate>,
72}
73
74#[derive(Debug, Deserialize)]
75struct CaveVerdictCandidate {
76    path: String,
77    #[serde(default)]
78    flavor: Option<String>,
79}
80
81fn current_os_flavor() -> &'static str {
82    #[cfg(target_os = "windows")]
83    {
84        "windows"
85    }
86    #[cfg(target_os = "linux")]
87    {
88        "linux"
89    }
90    #[cfg(not(any(target_os = "windows", target_os = "linux")))]
91    {
92        "unknown"
93    }
94}
95
96/// Extrai `(install_path, executable_path)` a partir do `verdict` JSON de uma cave.
97/// Prefere o candidato cujo `flavor` bate com o SO atual; cai pro primeiro candidato
98/// disponível quando não achar (ex: jogo só-Windows detectado rodando via Wine no Linux).
99fn resolve_from_verdict(verdict_json: &str) -> Option<(String, Option<String>)> {
100    let verdict: CaveVerdict = serde_json::from_str(verdict_json).ok()?;
101    let base_path = verdict.base_path?;
102
103    let flavor = current_os_flavor();
104    let candidate = verdict
105        .candidates
106        .iter()
107        .find(|c| c.flavor.as_deref() == Some(flavor))
108        .or_else(|| verdict.candidates.first());
109
110    let executable_path = candidate.map(|c| {
111        Path::new(&base_path)
112            .join(&c.path)
113            .to_string_lossy()
114            .to_string()
115    });
116
117    Some((base_path, executable_path))
118}
119
120/// `last_touched_at` pode vir como string RFC3339 ou como número (segundos/ms) —
121/// depende de como o driver Go do butler serializou. Trata os dois formatos pra
122/// não quebrar a importação inteira por causa de um valor inesperado.
123fn parse_unix_timestamp(raw: &str) -> Option<i64> {
124    if let Ok(n) = raw.parse::<i64>() {
125        return Some(if n > 10_000_000_000 { n / 1000 } else { n });
126    }
127    chrono::DateTime::parse_from_rfc3339(raw)
128        .ok()
129        .map(|dt| dt.timestamp())
130}
131
132// === ITCHIO SOURCE ===
133
134/// Provedor de jogos da itch.io, lendo diretamente `butler.db` (SQLite) do app itch.
135pub struct ItchioSource {
136    pub butler_db_path: Option<PathBuf>,
137}
138
139impl ItchioSource {
140    pub fn new(butler_db_path: Option<PathBuf>) -> Self {
141        Self { butler_db_path }
142    }
143
144    /// Caminhos padrão do app itch (o app itch roda nativo em Windows e Linux):
145    /// - Windows: `%APPDATA%\itch\db\butler.db`
146    /// - Linux: `~/.config/itch/db/butler.db`
147    fn default_butler_db_path() -> Option<PathBuf> {
148        #[cfg(target_os = "windows")]
149        {
150            dirs::data_dir().map(|d| d.join("itch").join("db").join("butler.db"))
151        }
152        #[cfg(target_os = "linux")]
153        {
154            dirs::config_dir().map(|d| d.join("itch").join("db").join("butler.db"))
155        }
156        #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
157        {
158            None
159        }
160    }
161
162    fn resolve_path(&self) -> Result<PathBuf, AppError> {
163        let path = self
164            .butler_db_path
165            .clone()
166            .or_else(Self::default_butler_db_path)
167            .ok_or_else(|| {
168                AppError::NotFound("Caminho do butler.db da itch.io não encontrado.".into())
169            })?;
170
171        if !path.exists() {
172            return Err(AppError::NotFound(format!(
173                "Arquivo butler.db da itch.io não encontrado em: {}",
174                path.display()
175            )));
176        }
177
178        Ok(path)
179    }
180
181    /// Abre o `butler.db` em modo somente-leitura. O app itch pode estar rodando e mantendo o arquivo
182    /// aberto para escrita — abrir como somente-leitura evita corrupção e reduz o risco de "database is locked".
183    fn open_readonly(path: &Path) -> Result<Connection, AppError> {
184        let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
185            .map_err(|e| AppError::DatabaseError(format!("Falha ao abrir butler.db: {e}")))?;
186
187        conn.busy_timeout(Duration::from_secs(5))
188            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
189
190        Ok(conn)
191    }
192
193    /// Playtime agregado por `game_id`, somando diretamente da tabela `caves`.
194    fn fetch_playtime_by_game(conn: &Connection) -> Result<HashMap<i64, PlaytimeAgg>, AppError> {
195        let mut stmt = conn
196            .prepare(
197                "SELECT game_id, SUM(seconds_run) AS total_seconds, MAX(last_touched_at) AS last_touched
198                 FROM caves
199                 GROUP BY game_id",
200            )
201            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
202
203        let rows = stmt
204            .query_map([], |row| {
205                let game_id: i64 = row.get(0)?; // Pega o game_id como chave
206                let total_seconds: i64 = row.get::<_, Option<i64>>(1)?.unwrap_or(0);
207                let last_touched_raw: Option<String> = row.get(2)?;
208                Ok((game_id, total_seconds, last_touched_raw))
209            })
210            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
211
212        let mut map = HashMap::new();
213        for row in rows {
214            let (game_id, total_seconds, last_touched_raw) =
215                row.map_err(|e| AppError::DatabaseError(e.to_string()))?;
216            let last_touched_unix = last_touched_raw.as_deref().and_then(parse_unix_timestamp);
217            map.insert(
218                game_id,
219                PlaytimeAgg {
220                    total_seconds,
221                    last_touched_unix,
222                },
223            );
224        }
225
226        Ok(map)
227    }
228
229    /// Todas as instalações (`caves`), cruzadas com `install_locations` pro fallback de path.
230    /// Indexado por `game_id` — assume no máximo uma instalação ativa relevante por jogo.
231    fn fetch_caves_by_game(conn: &Connection) -> Result<HashMap<i64, CaveRow>, AppError> {
232        let mut stmt = conn
233            .prepare(
234                "SELECT c.game_id, c.install_folder_name, c.verdict, il.path
235                 FROM caves c
236                 LEFT JOIN install_locations il ON il.id = c.install_location_id",
237            )
238            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
239
240        let rows = stmt
241            .query_map([], |row| {
242                Ok(CaveRow {
243                    game_id: row.get(0)?,
244                    install_folder_name: row.get(1)?,
245                    verdict: row.get(2)?,
246                    install_location_path: row.get(3)?,
247                })
248            })
249            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
250
251        let mut map = HashMap::new();
252        for row in rows {
253            let cave = row.map_err(|e| AppError::DatabaseError(e.to_string()))?;
254            map.insert(cave.game_id, cave);
255        }
256
257        Ok(map)
258    }
259
260    /// Monta um `ItchioGame` a partir de uma `CaveRow`, resolvendo `install_path` e `executable_path`
261    /// (via `verdict`, com fallback para `install_locations.path` + `install_folder_name`) e o playtime agregado.
262    fn build_installed_game(
263        game: &GameRow,
264        cave: &CaveRow,
265        playtime_by_game: &HashMap<i64, PlaytimeAgg>, // <-- Altere o tipo e o nome aqui
266    ) -> ItchioGame {
267        let (install_path, executable_path) = cave
268            .verdict
269            .as_deref()
270            .and_then(resolve_from_verdict)
271            .unwrap_or_else(|| {
272                let fallback = match (&cave.install_location_path, &cave.install_folder_name) {
273                    (Some(root), Some(folder)) => {
274                        Some(Path::new(root).join(folder).to_string_lossy().to_string())
275                    }
276                    (Some(root), None) => Some(root.clone()),
277                    _ => None,
278                };
279                (fallback.unwrap_or_default(), None)
280            });
281
282        // <-- Altere a busca para usar &game.id
283        let playtime = playtime_by_game.get(&game.id).copied().unwrap_or_default();
284        let playtime_minutes = Some((playtime.total_seconds / 60) as u32);
285
286        let source = SourceGame {
287            platform: "Itch".to_string(),
288            platform_game_id: game.id.to_string(),
289            name: Some(game.title.clone()),
290            installed: true,
291            executable_path,
292            install_path: (!install_path.is_empty()).then_some(install_path),
293            playtime_minutes,
294            last_played: playtime.last_touched_unix,
295        };
296
297        ItchioGame {
298            source,
299            description_raw: game.short_text.clone(),
300            cover_url: game.cover_url.clone(),
301        }
302    }
303
304    /// Busca só os jogos atualmente com instalação registrada (`caves`).
305    pub async fn fetch_installed_detailed(&self) -> Result<Vec<ItchioGame>, AppError> {
306        let path = self.resolve_path()?;
307        let conn = Self::open_readonly(&path)?;
308
309        let caves_by_game = Self::fetch_caves_by_game(&conn)?;
310        let playtime_by_cave = Self::fetch_playtime_by_game(&conn)?;
311
312        if caves_by_game.is_empty() {
313            return Ok(Vec::new());
314        }
315
316        let game_ids: Vec<i64> = caves_by_game.keys().copied().collect();
317        let placeholders = vec!["?"; game_ids.len()].join(",");
318        let sql = format!(
319            "SELECT id, title, short_text, cover_url FROM games WHERE id IN ({placeholders})"
320        );
321
322        let mut stmt = conn
323            .prepare(&sql)
324            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
325        let params = rusqlite::params_from_iter(game_ids.iter());
326        let rows = stmt
327            .query_map(params, |row| {
328                Ok(GameRow {
329                    id: row.get(0)?,
330                    title: row.get(1)?,
331                    short_text: row.get(2)?,
332                    cover_url: row.get(3)?,
333                })
334            })
335            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
336
337        let mut results = Vec::with_capacity(game_ids.len());
338        for row in rows {
339            let game = row.map_err(|e| AppError::DatabaseError(e.to_string()))?;
340            if let Some(cave) = caves_by_game.get(&game.id) {
341                results.push(Self::build_installed_game(&game, cave, &playtime_by_cave));
342            }
343        }
344
345        Ok(results)
346    }
347
348    /// Busca a biblioteca completa de posse (tabela `games`, filtrando `classification = 'game'` para
349    /// não trazer assets/tools/soundtracks/etc que a itch.io também guarda como itens da conta do usuário),
350    /// cruzando com `caves` pra marcar o que está instalado e reaproveitar path/playtime desses casos.
351    pub async fn fetch_full_library_detailed(&self) -> Result<Vec<ItchioGame>, AppError> {
352        let path = self.resolve_path()?;
353        let conn = Self::open_readonly(&path)?;
354
355        let caves_by_game = Self::fetch_caves_by_game(&conn)?;
356        let playtime_by_game = Self::fetch_playtime_by_game(&conn)?;
357
358        let mut stmt = conn
359            .prepare(
360                "SELECT id, title, short_text, cover_url FROM games WHERE classification = 'game'",
361            )
362            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
363        let rows = stmt
364            .query_map([], |row| {
365                Ok(GameRow {
366                    id: row.get(0)?,
367                    title: row.get(1)?,
368                    short_text: row.get(2)?,
369                    cover_url: row.get(3)?,
370                })
371            })
372            .map_err(|e| AppError::DatabaseError(e.to_string()))?;
373
374        let mut results = Vec::new();
375        for row in rows {
376            let game = row.map_err(|e| AppError::DatabaseError(e.to_string()))?;
377
378            if let Some(cave) = caves_by_game.get(&game.id) {
379                results.push(Self::build_installed_game(&game, cave, &playtime_by_game));
380                continue;
381            }
382
383            let source = SourceGame {
384                platform: "Itch".to_string(),
385                platform_game_id: game.id.to_string(),
386                name: Some(game.title.clone()),
387                installed: false,
388                executable_path: None,
389                install_path: None,
390                playtime_minutes: None,
391                last_played: None,
392            };
393
394            results.push(ItchioGame {
395                source,
396                description_raw: game.short_text.clone(),
397                cover_url: game.cover_url.clone(),
398            });
399        }
400
401        Ok(results)
402    }
403}
404
405// Implementação da trait padrão (retorna apenas SourceGame, sem os extras)
406#[async_trait]
407impl crate::sources::providers::GameSource for ItchioSource {
408    async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
409        let detailed = self.fetch_installed_detailed().await?;
410        Ok(detailed.into_iter().map(|g| g.source).collect())
411    }
412}