game_manager_lib/sources/
itch.rs1use 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#[derive(Debug, Clone)]
27pub struct ItchioGame {
28 pub source: SourceGame,
29 pub description_raw: Option<String>,
30 pub cover_url: Option<String>,
31}
32
33struct GameRow {
36 id: i64,
37 title: String,
38 short_text: Option<String>,
39 cover_url: Option<String>,
40}
41
42struct CaveRow {
46 game_id: i64,
47 install_folder_name: Option<String>,
48 verdict: Option<String>,
49 install_location_path: Option<String>,
50}
51
52#[derive(Default, Clone, Copy)]
54struct PlaytimeAgg {
55 total_seconds: i64,
56 last_touched_unix: Option<i64>,
57}
58
59#[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
96fn 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
120fn 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
132pub 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 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 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 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)?; 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 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 fn build_installed_game(
263 game: &GameRow,
264 cave: &CaveRow,
265 playtime_by_game: &HashMap<i64, PlaytimeAgg>, ) -> 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 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 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 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#[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}