game_manager_lib/sources/
indiegala.rs1use 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#[derive(Debug, Clone)]
21pub struct IndiegalaGame {
22 pub source: SourceGame,
23 pub description_raw: Option<String>,
24 pub tags: Option<Vec<GameTag>>,
28}
29
30#[derive(Debug, Deserialize)]
33struct InstalledEntry {
34 target: Target,
35 path: Vec<String>,
38 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#[derive(Debug, Deserialize)]
71struct ConfigJson {
72 gala_data: GalaData,
73 #[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#[derive(Debug, Deserialize)]
100struct OwnedGame {
101 prod_name: String,
102 prod_slugged_name: String,
103 prod_id_key_name: String,
104}
105
106#[derive(Debug, Deserialize)]
109struct ConfigGameEntry {
110 description_short: Option<String>,
111 #[serde(default)]
112 tags: Vec<String>,
113}
114
115fn slugify_indiegala_tag(tag: &str) -> String {
119 tag.trim().to_lowercase().replace(' ', "-")
120}
121
122pub struct IndiegalaSource {
126 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 #[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 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 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 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 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 #[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 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 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 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 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#[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}