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