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
18#[derive(Debug, Clone)]
22pub struct IndiegalaGame {
23 pub source: SourceGame,
24 pub description_raw: Option<String>,
25 pub tags: Option<Vec<GameTag>>,
29}
30
31#[derive(Debug, Deserialize)]
34struct InstalledEntry {
35 target: Target,
36 path: Vec<String>,
39 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#[derive(Debug, Deserialize)]
72struct ConfigJson {
73 gala_data: GalaData,
74 #[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#[derive(Debug, Deserialize)]
101struct OwnedGame {
102 prod_name: String,
103 prod_slugged_name: String,
104 prod_id_key_name: String,
105}
106
107#[derive(Debug, Deserialize)]
110struct ConfigGameEntry {
111 description_short: Option<String>,
112 #[serde(default)]
113 tags: Vec<String>,
114}
115
116fn slugify_indiegala_tag(tag: &str) -> String {
120 tag.trim().to_lowercase().replace(' ', "-")
121}
122
123pub struct IndiegalaSource {
127 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 #[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 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 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 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 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 #[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 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 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 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 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#[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}