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