Skip to main content

game_manager_lib/sources/
indiegala.rs

1//! Importação de jogos instalados da IndieGala (via IGClient).
2//!
3//! Lê `installed.json` do IGClient, que contém só os jogos atualmente instalados,
4//! já com metadados completos (descrição, categorias, executável) embutidos e `config.json`
5//! que possui a lista de todos os jogos adquiridos, mas sem metadados para todos os jogos.
6
7use crate::errors::AppError;
8use crate::models::GameTag;
9use crate::services::tags::classify_and_sort_tags;
10use crate::sources::providers::SourceGame;
11use crate::utils::executable_heuristics::guess_main_executable;
12use async_trait::async_trait;
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18// === JOGO IMPORTADO COM METADADOS EXTRAS (além do SourceGame padrão) ===
19
20/// Jogo importado da IndieGala com campos adicionais além do `SourceGame` padrão.
21#[derive(Debug, Clone)]
22pub struct IndiegalaGame {
23    pub source: SourceGame,
24    pub description_raw: Option<String>,
25    /// Tags já classificadas via `services::tags` — a IndieGala não separa gênero de tag de forma
26    /// consistente entre `installed.json` e `config.json`, então usamos só tags (como as demais
27    /// plataformas fazem com dados vindos da RAWG) em vez de um campo de gênero em paralelo.
28    pub tags: Option<Vec<GameTag>>,
29}
30
31// === ESTRUTURAS INTERNAS PARA DESSERIALIZAÇÃO DE installed.json ===
32
33#[derive(Debug, Deserialize)]
34struct InstalledEntry {
35    target: Target,
36    /// Pasta(s) raiz configurada(s) no client (ex: `["E:\\IGClient\\games"]`).
37    /// Cada jogo costuma ficar numa subpasta com o slug dentro dela, o JSON não confirma isso diretamente.
38    path: Vec<String>,
39    /// Tempo jogado, em **segundos**.
40    playtime: f64,
41}
42
43#[derive(Debug, Deserialize)]
44struct Target {
45    item_data: ItemData,
46    game_data: GameData,
47}
48
49#[derive(Debug, Deserialize)]
50struct ItemData {
51    name: String,
52    slugged_name: String,
53    id_key_name: String,
54}
55
56#[derive(Debug, Deserialize)]
57struct GameData {
58    description_short: Option<String>,
59    #[serde(default)]
60    tags: Vec<String>,
61    exe_path: Option<String>,
62}
63
64// === ESTRUTURAS INTERNAS PARA DESSERIALIZAÇÃO DE config.json ===
65//
66// config.json tem duas partes: `gala_data` (posse — todos os jogos que a conta possui,
67// instalados ou não) e um dicionário achatado no nível raiz, chave = slug do jogo, com
68// metadado rico (descrição, tags, exe_path) — mas esse dicionário só cobre jogos que já
69// foram instalados alguma vez, não o catálogo inteiro.
70
71#[derive(Debug, Deserialize)]
72struct ConfigJson {
73    gala_data: GalaData,
74    /// Captura todas as outras chaves de nível raiz (uma por slug de jogo já instalado).
75    #[serde(flatten)]
76    games: HashMap<String, ConfigGameEntry>,
77}
78
79#[derive(Debug, Deserialize)]
80struct GalaData {
81    data: GalaDataInner,
82}
83
84#[derive(Debug, Deserialize)]
85struct GalaDataInner {
86    showcase_content: ShowcaseContent,
87}
88
89#[derive(Debug, Deserialize)]
90struct ShowcaseContent {
91    content: ShowcaseContentInner,
92}
93
94#[derive(Debug, Deserialize)]
95struct ShowcaseContentInner {
96    user_collection: Vec<OwnedGame>,
97}
98
99/// Um jogo que a conta possui, segundo `gala_data` — não indica se está instalado.
100#[derive(Debug, Deserialize)]
101struct OwnedGame {
102    prod_name: String,
103    prod_slugged_name: String,
104    prod_id_key_name: String,
105}
106
107/// Metadado rico de um jogo dentro do dicionário achatado do config.json.
108/// Só existe para jogos já instalados alguma vez — não é o catálogo completo.
109#[derive(Debug, Deserialize)]
110struct ConfigGameEntry {
111    description_short: Option<String>,
112    #[serde(default)]
113    tags: Vec<String>,
114}
115
116/// Normaliza uma tag "humana" da IndieGala (ex: "3D Platformer", "old school") para o
117/// formato slug esperado por `tag_metadata.json` (ex: "3d-platformer", "old-school").
118/// Tags que não existirem no mapa são descartadas silenciosamente por `classify_tags`.
119fn slugify_indiegala_tag(tag: &str) -> String {
120    tag.trim().to_lowercase().replace(' ', "-")
121}
122
123// === INDIEGALA SOURCE ===
124
125/// Provedor de jogos instalados da IndieGala (IGClient).
126pub struct IndiegalaSource {
127    /// Caminho para `installed.json`. Se `None`, usa o caminho padrão do Windows.
128    pub installed_json_path: Option<PathBuf>,
129}
130
131impl IndiegalaSource {
132    pub fn new(installed_json_path: Option<PathBuf>) -> Self {
133        Self {
134            installed_json_path,
135        }
136    }
137
138    /// Caminho padrão: `%APPDATA%\IGClient\storage\installed.json`.
139    /// IGClient é Windows-only por enquanto (Linux/macOS "coming soon" segundo a própria IndieGala).
140    #[cfg(target_os = "windows")]
141    fn default_installed_json_path() -> Option<PathBuf> {
142        dirs::data_dir().map(|d| d.join("IGClient").join("storage").join("installed.json"))
143    }
144
145    #[cfg(not(target_os = "windows"))]
146    fn default_installed_json_path() -> Option<PathBuf> {
147        None
148    }
149
150    /// Busca os jogos instalados com metadados completos.
151    ///
152    /// Retorna [`IndiegalaGame`] ao invés de [`SourceGame`] puro, permitindo
153    /// persistir `description_raw` e `tags` (não fazem parte do `SourceGame` padrão).
154    pub async fn fetch_installed_detailed(&self) -> Result<Vec<IndiegalaGame>, AppError> {
155        let path = self
156            .installed_json_path
157            .clone()
158            .or_else(Self::default_installed_json_path)
159            .ok_or_else(|| {
160                AppError::NotFound("Caminho do installed.json da IndieGala não encontrado.".into())
161            })?;
162
163        if !path.exists() {
164            return Err(AppError::NotFound(format!(
165                "Arquivo installed.json da IndieGala não encontrado em: {}",
166                path.display()
167            )));
168        }
169
170        let content = fs::read_to_string(&path).map_err(|e| AppError::IoError(e.to_string()))?;
171
172        let entries: Vec<InstalledEntry> = serde_json::from_str(&content)
173            .map_err(|e| AppError::SerializationError(e.to_string()))?;
174
175        let mut results = Vec::with_capacity(entries.len());
176
177        for entry in entries {
178            let item = &entry.target.item_data;
179            let game_data = &entry.target.game_data;
180
181            // Pasta específica do jogo, se existir de fato — distinto do `install_path`, que pode
182            // cair pra raiz compartilhada quando essa pasta não existe. A heurística de executável
183            // deve rodar na pasta específica; rodar na raiz compartilhada traria o .exe de outro jogo.
184            let game_folder = entry
185                .path
186                .first()
187                .map(|root| Path::new(root).join(&item.slugged_name))
188                .filter(|candidate| candidate.is_dir());
189
190            let install_path = match &game_folder {
191                Some(folder) => Some(folder.to_string_lossy().to_string()),
192                None => entry.path.first().cloned().inspect(|root| {
193                    log::warn!(
194                "IndieGala: pasta esperada '{}/{}' não existe, usando raiz configurada '{root}' como install_path",
195                root, item.slugged_name
196            );
197                }),
198            };
199
200            // exe_path costuma ser relativo (ex: "deponia.exe", "ScummVM/scummvm.exe"), mas é
201            // comumente `null` (confirmado em jogos reais) — nesse caso, tenta a heurística de
202            // maior .exe na pasta específica do jogo, se ela existir.
203            let executable_path = match (&game_data.exe_path, &install_path) {
204                (Some(exe), Some(base)) if !exe.trim().is_empty() => {
205                    Some(Path::new(base).join(exe).to_string_lossy().to_string())
206                }
207                _ => game_folder
208                    .as_deref()
209                    .and_then(guess_main_executable)
210                    .map(|p| p.to_string_lossy().to_string()),
211            };
212
213            // playtime vem em SEGUNDOS.
214            let playtime_minutes = Some((entry.playtime / 60.0).round() as u32);
215
216            let raw_tag_slugs: Vec<String> = game_data
217                .tags
218                .iter()
219                .map(|t| slugify_indiegala_tag(t))
220                .collect();
221            let tags =
222                (!raw_tag_slugs.is_empty()).then(|| classify_and_sort_tags(raw_tag_slugs, 10));
223
224            let source = SourceGame {
225                platform: "Indiegala".to_string(),
226                platform_game_id: item.id_key_name.clone(),
227                name: Some(item.name.clone()),
228                installed: true,
229                executable_path,
230                install_path,
231                playtime_minutes,
232                last_played: None,
233            };
234
235            results.push(IndiegalaGame {
236                source,
237                description_raw: game_data.description_short.clone(),
238                tags,
239            });
240        }
241
242        Ok(results)
243    }
244
245    /// Caminho padrão: `%APPDATA%\IGClient\config.json`.
246    #[cfg(target_os = "windows")]
247    fn default_config_json_path() -> Option<PathBuf> {
248        dirs::data_dir().map(|d| d.join("IGClient").join("config.json"))
249    }
250
251    #[cfg(not(target_os = "windows"))]
252    fn default_config_json_path() -> Option<PathBuf> {
253        None
254    }
255
256    /// Busca a biblioteca completa (posse), cruzando com `installed.json` pra marcar o que já está instalado.
257    ///
258    /// Jogos possuídos mas nunca instalados entram com `installed: false` e, quando existir entrada
259    /// correspondente no dicionário achatado do config.json (só cobre jogos já instalados alguma
260    /// vez — não é garantido para todos), ganham descrição/tags também. Senão, ficam só com o nome.
261    pub async fn fetch_full_library_detailed(
262        &self,
263        config_json_path: Option<PathBuf>,
264    ) -> Result<Vec<IndiegalaGame>, AppError> {
265        let path = config_json_path
266            .or_else(Self::default_config_json_path)
267            .ok_or_else(|| {
268                AppError::NotFound("Caminho do config.json da IndieGala não encontrado.".into())
269            })?;
270
271        if !path.exists() {
272            return Err(AppError::NotFound(format!(
273                "Arquivo config.json da IndieGala não encontrado em: {}",
274                path.display()
275            )));
276        }
277
278        let content = fs::read_to_string(&path).map_err(|e| AppError::IoError(e.to_string()))?;
279        let config: ConfigJson = serde_json::from_str(&content)
280            .map_err(|e| AppError::SerializationError(e.to_string()))?;
281
282        // Reaproveita o que já está instalado (metadados completos, incluindo executable_path/install_path/playtime).
283        // Se installed.json não existir ainda (usuário nunca instalou nada via IGClient), não falha a importação inteira.
284        let installed = self.fetch_installed_detailed().await.unwrap_or_else(|e| {
285            log::warn!(
286                "IndieGala: não foi possível ler installed.json ({e}), seguindo só com posse"
287            );
288            Vec::new()
289        });
290        let installed_by_id: HashMap<String, IndiegalaGame> = installed
291            .into_iter()
292            .map(|g| (g.source.platform_game_id.clone(), g))
293            .collect();
294
295        let owned_games = config
296            .gala_data
297            .data
298            .showcase_content
299            .content
300            .user_collection;
301
302        let mut results = Vec::with_capacity(owned_games.len());
303
304        for owned in owned_games {
305            // Já instalado — usa a entrada completa que já tem.
306            if let Some(installed_game) = installed_by_id.get(&owned.prod_id_key_name) {
307                results.push(installed_game.clone());
308                continue;
309            }
310
311            // Não instalado: tenta enriquecer com o dicionário achatado do config.json.
312            let extra = config.games.get(&owned.prod_slugged_name);
313
314            let raw_tag_slugs: Vec<String> = extra
315                .map(|e| e.tags.iter().map(|t| slugify_indiegala_tag(t)).collect())
316                .unwrap_or_default();
317            let tags =
318                (!raw_tag_slugs.is_empty()).then(|| classify_and_sort_tags(raw_tag_slugs, 10));
319
320            let source = SourceGame {
321                platform: "Indiegala".to_string(),
322                platform_game_id: owned.prod_id_key_name.clone(),
323                name: Some(owned.prod_name.clone()),
324                installed: false,
325                executable_path: None,
326                install_path: None,
327                playtime_minutes: None,
328                last_played: None,
329            };
330
331            results.push(IndiegalaGame {
332                source,
333                description_raw: extra.and_then(|e| e.description_short.clone()),
334                tags,
335            });
336        }
337
338        Ok(results)
339    }
340}
341
342// Implementação da trait padrão (retorna apenas SourceGame, sem os extras)
343#[async_trait]
344impl crate::sources::providers::GameSource for IndiegalaSource {
345    async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
346        let detailed = self.fetch_installed_detailed().await?;
347        Ok(detailed.into_iter().map(|g| g.source).collect())
348    }
349}