1use crate::constants;
7use crate::database;
8use crate::database::AppState;
9use crate::errors::AppError;
10use crate::models;
11use crate::models::Platform;
12use crate::utils::status_logic;
13use crate::utils::text::slugify;
14use chrono::Utc;
15use rusqlite::{params, OptionalExtension};
16use serde::Deserialize;
17use tauri::State;
18use url::Url;
19use uuid::Uuid;
20
21#[derive(Debug, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct GameInput {
27 pub id: String,
28 pub name: String,
29 pub platform: Platform,
30 pub platform_game_id: String,
31 pub cover_url: Option<String>,
32 pub installed: bool,
33 pub import_confidence: Option<String>,
34 pub playtime: Option<i32>,
35 pub user_rating: Option<i32>,
36 pub status: Option<String>,
37 pub install_path: Option<String>,
38 pub executable_path: Option<String>,
39 pub launch_args: Option<String>,
40}
41
42#[derive(serde::Deserialize)]
46pub struct UpdateGameDetailsInput {
47 pub id: String,
48 pub description: Option<String>, pub developer: Option<String>,
50 pub publisher: Option<String>,
51 pub released: Option<String>,
52}
53
54fn validate_input(game: &GameInput) -> Result<(), AppError> {
59 if game.name.trim().is_empty() {
60 return Err(AppError::ValidationError(
61 "Nome do jogo não pode ser vazio".to_string(),
62 ));
63 }
64
65 if game.name.len() > constants::MAX_NAME_LENGTH {
66 return Err(AppError::ValidationError(format!(
67 "Nome muito longo (max {})",
68 constants::MAX_NAME_LENGTH
69 )));
70 }
71
72 if let Some(ref url_str) = game.cover_url {
73 if url_str.len() > constants::MAX_URL_LENGTH {
74 return Err(AppError::ValidationError(format!(
75 "URL da capa muito longa (máximo {} caracteres)",
76 constants::MAX_URL_LENGTH
77 )));
78 }
79 if !url_str.starts_with("http") && !url_str.starts_with("asset://") {
81 let url = Url::parse(url_str)
82 .map_err(|_| AppError::ValidationError("URL inválida.".to_string()))?;
83 if url.scheme() != "http" && url.scheme() != "https" {
84 return Err(AppError::ValidationError(
85 "A URL deve ser HTTP, HTTPS ou Asset local.".to_string(),
86 ));
87 }
88 }
89 }
90
91 if let Some(time) = game.playtime {
92 if time < 0 {
93 return Err(AppError::ValidationError(
94 "Tempo jogado não pode ser negativo".to_string(),
95 ));
96 }
97 if time > constants::MAX_PLAYTIME {
98 return Err(AppError::ValidationError(
99 "Tempo jogado excessivo".to_string(),
100 ));
101 }
102 }
103
104 if let Some(r) = game.user_rating {
105 if !(constants::MIN_RATING..=constants::MAX_RATING).contains(&r) {
106 return Err(AppError::ValidationError(format!(
107 "Avaliação deve estar entre {} e {}",
108 constants::MIN_RATING,
109 constants::MAX_RATING
110 )));
111 }
112 }
113
114 Ok(())
115}
116
117#[tauri::command]
121pub fn add_game(state: State<AppState>, game: GameInput) -> Result<(), AppError> {
122 validate_input(&game)?;
123
124 let conn = state.games_db.lock()?;
125
126 let exists: bool = conn.query_row(
128 "SELECT EXISTS(SELECT 1 FROM games WHERE id = ?1)",
129 params![game.id],
130 |row| row.get(0),
131 )?;
132
133 if exists {
134 return Err(AppError::AlreadyExists(
135 "Já existe um jogo com este ID".to_string(),
136 ));
137 }
138
139 let final_status = game
141 .status
142 .unwrap_or_else(|| status_logic::calculate_status(game.playtime.unwrap_or(0)));
143
144 let added_at = Utc::now().to_rfc3339();
145 let platform = game.platform;
146
147 let platform_game_id = if matches!(platform, Platform::Outra) {
148 format!("manual-{}", Uuid::new_v4())
149 } else {
150 game.platform_game_id.clone()
151 };
152
153 conn.execute(
154 "INSERT INTO games (
155 id, name, slug, cover_url, platform, platform_game_id,
156 installed, import_confidence, install_path, executable_path, launch_args,
157 user_rating, status, playtime, added_at
158 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
159 params![
160 game.id,
161 game.name,
162 slugify(&game.name),
163 game.cover_url,
164 platform.to_string(),
165 platform_game_id,
166 game.installed,
167 game.import_confidence,
168 game.install_path,
169 game.executable_path,
170 game.launch_args,
171 game.user_rating,
172 final_status,
173 game.playtime.unwrap_or(0),
174 added_at
175 ],
176 )?;
177
178 Ok(())
179}
180
181#[tauri::command]
188pub fn update_game(state: State<AppState>, game: GameInput) -> Result<(), AppError> {
189 validate_input(&game)?;
190
191 let conn = state.games_db.lock()?;
192
193 conn.execute(
194 "UPDATE games SET
195 name = ?1,
196 slug = ?2,
197 cover_url = ?3,
198 platform = ?4,
199 platform_game_id = ?5,
200 installed = ?6,
201 import_confidence = ?7,
202 playtime = ?8,
203 user_rating = ?9,
204 status = ?10,
205 install_path = ?11,
206 executable_path = ?12,
207 launch_args = ?13
208 WHERE id = ?14",
209 params![
210 game.name,
211 slugify(&game.name),
212 game.cover_url,
213 game.platform.to_string(),
214 game.platform_game_id,
215 game.installed,
216 game.import_confidence,
217 game.playtime,
218 game.user_rating,
219 game.status,
220 game.install_path,
221 game.executable_path,
222 game.launch_args,
223 game.id
224 ],
225 )?;
226
227 Ok(())
228}
229
230#[tauri::command]
235pub fn get_games(state: State<AppState>) -> Result<Vec<models::Game>, AppError> {
236 let conn = state.games_db.lock()?;
237
238 let mut stmt = conn.prepare(
239 "SELECT
240 g.id, g.name, g.slug, g.cover_url, g.platform, g.platform_game_id, g.installed, g.import_confidence, g.install_path, g.executable_path,
241 g.launch_args, g.user_rating, g.favorite, g.status, g.playtime, g.playtime_source, g.last_played, g.added_at, g.alternative_names,
242 gd.genres, gd.developer, COALESCE(gd.is_adult, 0) as is_adult
243 FROM games g
244 LEFT JOIN game_details gd ON g.id = gd.game_id
245 ORDER BY g.name ASC"
246 )?;
247
248 let games = stmt
249 .query_map([], |row| {
250 let alt_names_json: Option<String> = row.get(18)?;
251 let alternative_names = alt_names_json.and_then(|s| serde_json::from_str(&s).ok());
252
253 Ok(models::Game {
254 id: row.get(0)?,
255 name: row.get(1)?,
256 slug: row.get(2)?,
257 cover_url: row.get(3)?,
258 platform: row.get::<_, String>(4)?.parse().unwrap_or(Platform::Outra),
259 platform_game_id: row.get(5)?,
260 installed: row.get(6)?,
261 import_confidence: row
262 .get::<_, Option<String>>(7)?
263 .and_then(|s| s.parse().ok()),
264 install_path: row.get(8)?,
265 executable_path: row.get(9)?,
266 launch_args: row.get(10)?,
267 user_rating: row.get(11)?,
268 favorite: row.get(12)?,
269 status: row.get(13)?,
270 playtime: row.get(14)?,
271 playtime_source: row
272 .get::<_, Option<String>>(15)?
273 .and_then(|s| s.parse().ok()),
274 last_played: row.get(16)?,
275 added_at: row.get(17)?,
276 alternative_names,
277 genres: row.get(19)?,
278 developer: row.get(20)?,
279 is_adult: row.get(21)?,
280 })
281 })?
282 .collect::<Result<Vec<_>, _>>()?;
283
284 Ok(games)
285}
286
287#[tauri::command]
293pub fn get_library_game_details(
294 state: State<AppState>,
295 game_id: String,
296) -> Result<Option<models::GameDetails>, AppError> {
297 let conn = state.games_db.lock()?;
298
299 let mut stmt = conn.prepare(
300 "SELECT
301 game_id, steam_app_id, developer, publisher, release_date, genres, tags, series,
302 description_raw, description_ptbr, background_image, critic_score,
303 steam_review_label, steam_review_count, steam_review_score, steam_review_updated_at,
304 esrb_rating, is_adult, adult_tags, external_links, median_playtime,
305 estimated_playtime, updated_at
306 FROM game_details
307 WHERE game_id = ?1",
308 )?;
309
310 let mut rows = stmt.query_map(params![game_id], |row| {
311 let links_json: Option<String> = row.get(19)?;
312 let external_links = links_json.and_then(|json| serde_json::from_str(&json).ok());
313
314 let tags_json: Option<String> = row.get(6)?;
315 let tags = tags_json.map(|s| database::deserialize_tags(&s));
316
317 Ok(models::GameDetails {
318 game_id: row.get(0)?,
319 steam_app_id: row.get(1)?,
320 developer: row.get(2)?,
321 publisher: row.get(3)?,
322 release_date: row.get(4)?,
323 genres: row.get(5)?,
324 tags,
325 series: row.get(7)?,
326 description_raw: row.get(8)?,
327 description_ptbr: row.get(9)?,
328 background_image: row.get(10)?,
329 critic_score: row.get(11)?,
330 steam_review_label: row.get(12)?,
331 steam_review_count: row.get(13)?,
332 steam_review_score: row.get(14)?,
333 steam_review_updated_at: row.get(15)?,
334 esrb_rating: row.get(16)?,
335 is_adult: row.get(17).unwrap_or(false),
336 adult_tags: row.get(18)?,
337 external_links,
338 median_playtime: row.get(20)?,
339 estimated_playtime: row.get(21)?,
340 updated_at: row.get(22)?,
341 })
342 })?;
343
344 if let Some(row) = rows.next() {
345 Ok(Some(row?))
346 } else {
347 Ok(None)
348 }
349}
350
351#[tauri::command]
355pub fn get_game_by_id(
356 state: State<AppState>,
357 id: String,
358) -> Result<Option<models::Game>, AppError> {
359 let conn = state.games_db.lock()?;
360
361 let mut stmt = conn.prepare(
362 "SELECT
363 g.id, g.name, g.slug, g.cover_url, g.platform, g.platform_game_id, g.installed, g.import_confidence, g.install_path, g.executable_path,
364 g.launch_args, g.user_rating, g.favorite, g.status, g.playtime, g.playtime_source, g.last_played, g.added_at, g.alternative_names,
365 gd.genres, gd.developer, COALESCE(gd.is_adult, 0) as is_adult
366 FROM games g
367 LEFT JOIN game_details gd ON g.id = gd.game_id
368 WHERE g.id = ?1",
369 )?;
370
371 let game = stmt
372 .query_row(params![id], |row| {
373 let alt_names_json: Option<String> = row.get(18)?;
374 let alternative_names = alt_names_json.and_then(|s| serde_json::from_str(&s).ok());
375
376 Ok(models::Game {
377 id: row.get(0)?,
378 name: row.get(1)?,
379 slug: row.get(2)?,
380 cover_url: row.get(3)?,
381 platform: row.get::<_, String>(4)?.parse().unwrap_or(Platform::Outra),
382 platform_game_id: row.get(5)?,
383 installed: row.get(6)?,
384 import_confidence: row
385 .get::<_, Option<String>>(7)?
386 .and_then(|s| s.parse().ok()),
387 install_path: row.get(8)?,
388 executable_path: row.get(9)?,
389 launch_args: row.get(10)?,
390 user_rating: row.get(11)?,
391 favorite: row.get(12)?,
392 status: row.get(13)?,
393 playtime: row.get(14)?,
394 playtime_source: row
395 .get::<_, Option<String>>(15)?
396 .and_then(|s| s.parse().ok()),
397 last_played: row.get(16)?,
398 added_at: row.get(17)?,
399 alternative_names,
400 genres: row.get(19)?,
401 developer: row.get(20)?,
402 is_adult: row.get(21)?,
403 })
404 })
405 .optional()?;
406
407 Ok(game)
408}
409
410#[tauri::command]
417pub fn toggle_favorite(state: State<AppState>, id: String) -> Result<(), AppError> {
418 let conn = state.games_db.lock()?;
419
420 conn.execute(
421 "UPDATE games SET favorite = NOT favorite WHERE id = ?1",
422 params![id],
423 )?;
424
425 Ok(())
426}
427
428#[tauri::command]
434pub fn set_game_status(state: State<AppState>, id: String, status: String) -> Result<(), AppError> {
435 let conn = state.games_db.lock()?;
436 conn.execute(
437 "UPDATE games SET status = ?1 WHERE id = ?2",
438 params![status, id],
439 )?;
440 Ok(())
441}
442
443#[tauri::command]
448pub fn set_game_rating(state: State<AppState>, id: String, rating: i32) -> Result<(), AppError> {
449 if !(0..=5).contains(&rating) {
451 return Err(AppError::ValidationError("Rating inválido".to_string()));
452 }
453
454 let conn = state.games_db.lock()?;
455
456 let val = if rating == 0 { None } else { Some(rating) };
458
459 conn.execute(
460 "UPDATE games SET user_rating = ?1 WHERE id = ?2",
461 params![val, id],
462 )?;
463 Ok(())
464}
465
466#[tauri::command]
470pub fn delete_game(state: State<AppState>, id: String) -> Result<(), AppError> {
471 let conn = state.games_db.lock()?;
472
473 conn.execute("DELETE FROM games WHERE id = ?1", params![id])?;
474
475 Ok(())
476}
477
478#[tauri::command]
484pub fn update_game_details(
485 state: State<AppState>,
486 payload: UpdateGameDetailsInput,
487) -> Result<(), AppError> {
488 let conn = state.games_db.lock().map_err(|_| AppError::MutexError)?;
489
490 let current_state: Option<(Option<String>, Option<String>)> = conn
492 .query_row(
493 "SELECT description_ptbr, description_raw FROM game_details WHERE game_id = ?1",
494 params![payload.id],
495 |row| Ok((row.get(0)?, row.get(1)?)),
496 )
497 .optional()?; match current_state {
500 Some((description_ptbr, description_raw)) => {
502 if description_ptbr.is_none() && description_raw.is_some() {
504 return Err(AppError::ValidationError(
505 "A descrição precisa ser traduzida (ou gerada) antes de ser editada manualmente.".to_string()
506 ));
507 }
508 conn.execute(
509 "UPDATE game_details SET
510 description_ptbr = ?1,
511 developer = ?2,
512 publisher = ?3,
513 release_date = ?4
514 WHERE game_id = ?5",
515 params![
516 payload.description,
517 payload.developer,
518 payload.publisher,
519 payload.released,
520 payload.id
521 ],
522 )?;
523 }
524
525 None => {
527 conn.execute(
528 "INSERT INTO game_details (game_id, description_ptbr, developer, publisher, release_date)
529 VALUES (?1, ?2, ?3, ?4, ?5)",
530 params![
531 payload.id,
532 payload.description, payload.developer,
534 payload.publisher,
535 payload.released
536 ],
537 )?;
538 }
539 }
540
541 Ok(())
542}