Skip to content

Commit 861e26f

Browse files
authored
Add files via upload
1 parent d046f69 commit 861e26f

10 files changed

Lines changed: 309 additions & 63 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "zgamelib"
3-
version = "0.6.0"
3+
version = "0.7.0"
44
description = "ZGameLib - Personal Game Library"
55
authors = ["TheHolyOneZ"]
66
license = "MIT"

src-tauri/src/commands/games.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use tauri::State;
22
use chrono::Utc;
33
use uuid::Uuid;
44
use crate::db::{DbState, queries};
5-
use crate::models::{Game, Note, Session, CreateGamePayload, UpdateGamePayload, HltbData};
5+
use crate::models::{Game, Note, Session, CreateGamePayload, UpdateGamePayload, HltbData, WeeklyPlaytime};
66

77
#[tauri::command]
88
pub fn get_all_games(state: State<DbState>) -> Result<Vec<Game>, String> {
@@ -65,6 +65,12 @@ pub fn update_game(state: State<DbState>, payload: UpdateGamePayload) -> Result<
6565
if tags.len() > 100 { return Err("Maximum 100 tags allowed".to_string()); }
6666
if tags.iter().any(|t| t.len() > 50) { return Err("Each tag must be 50 characters or fewer".to_string()); }
6767
}
68+
if let Some(ref status) = payload.status {
69+
const VALID_STATUSES: &[&str] = &["none", "backlog", "playing", "completed", "dropped", "on_hold"];
70+
if !VALID_STATUSES.contains(&status.as_str()) {
71+
return Err(format!("Invalid status '{}'. Must be one of: none, backlog, playing, completed, dropped, on_hold", status));
72+
}
73+
}
6874

6975
if let Some(name) = payload.name { game.name = name; }
7076
if let Some(cover) = payload.cover_path { game.cover_path = Some(cover); }
@@ -321,3 +327,49 @@ pub fn get_sessions(state: State<DbState>, game_id: String) -> Result<Vec<Sessio
321327
.collect();
322328
Ok(sessions)
323329
}
330+
331+
#[tauri::command]
332+
pub fn get_weekly_playtime(state: State<DbState>) -> Result<Vec<WeeklyPlaytime>, String> {
333+
let conn = state.0.lock().map_err(|e| e.to_string())?;
334+
let mut stmt = conn.prepare(
335+
"SELECT strftime('%Y-%W', started_at) as week, SUM(duration_mins) as mins FROM sessions GROUP BY week ORDER BY week DESC LIMIT 12"
336+
).map_err(|e| e.to_string())?;
337+
let mut rows: Vec<WeeklyPlaytime> = stmt.query_map([], |row| {
338+
Ok(WeeklyPlaytime {
339+
week: row.get(0)?,
340+
mins: row.get(1)?,
341+
})
342+
}).map_err(|e| e.to_string())?
343+
.filter_map(|r| r.ok())
344+
.collect();
345+
rows.reverse();
346+
Ok(rows)
347+
}
348+
349+
#[tauri::command]
350+
pub fn batch_update_games(
351+
state: State<DbState>,
352+
ids: Vec<String>,
353+
status: Option<String>,
354+
rating: Option<f64>,
355+
tags_to_add: Option<Vec<String>>,
356+
) -> Result<(), String> {
357+
let conn = state.0.lock().map_err(|e| e.to_string())?;
358+
let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
359+
for id in &ids {
360+
let mut game = match queries::get_game_by_id(&conn, id).map_err(|e| e.to_string())? {
361+
Some(g) => g,
362+
None => continue,
363+
};
364+
if let Some(ref s) = status { game.status = s.clone(); }
365+
if let Some(r) = rating { game.rating = Some(r); }
366+
if let Some(ref new_tags) = tags_to_add {
367+
for t in new_tags {
368+
if !game.tags.contains(t) { game.tags.push(t.clone()); }
369+
}
370+
}
371+
queries::update_game(&conn, &game).map_err(|e| e.to_string())?;
372+
}
373+
tx.commit().map_err(|e| e.to_string())?;
374+
Ok(())
375+
}

src-tauri/src/commands/launcher.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,30 @@ fn is_pid_running(pid: u32) -> bool {
5151
out.contains('"')
5252
}
5353

54-
fn finish_session(app: &AppHandle, game_id: &str, started_at: &str, elapsed_mins: i64) {
54+
fn finish_session(app: &AppHandle, game_id: &str, started_at: &str, elapsed_secs: u64) {
55+
let elapsed_mins = (elapsed_secs / 60) as i64;
5556
let now = Utc::now().to_rfc3339();
5657
{
5758
let db = app.state::<DbState>();
5859
let lock = db.0.lock();
5960
if let Ok(conn) = lock {
60-
let _ = conn.execute(
61-
"UPDATE games SET playtime_mins = playtime_mins + ?1, last_played = ?2 WHERE id = ?3",
62-
rusqlite::params![elapsed_mins, now, game_id],
63-
);
6461
if elapsed_mins > 0 {
62+
let _ = conn.execute(
63+
"UPDATE games SET playtime_mins = playtime_mins + ?1, last_played = ?2 WHERE id = ?3",
64+
rusqlite::params![elapsed_mins, now, game_id],
65+
);
66+
} else {
67+
let _ = conn.execute(
68+
"UPDATE games SET last_played = ?1 WHERE id = ?2",
69+
rusqlite::params![now, game_id],
70+
);
71+
}
72+
if elapsed_secs >= 30 {
6573
let session_id = uuid::Uuid::new_v4().to_string();
74+
let mins_to_save = elapsed_mins.max(1);
6675
let _ = conn.execute(
6776
"INSERT INTO sessions (id, game_id, started_at, ended_at, duration_mins) VALUES (?1, ?2, ?3, ?4, ?5)",
68-
rusqlite::params![session_id, game_id, started_at, now, elapsed_mins],
77+
rusqlite::params![session_id, game_id, started_at, now, mins_to_save],
6978
);
7079
}
7180
}
@@ -120,9 +129,19 @@ pub fn launch_game(app: AppHandle, state: State<DbState>, id: String) -> Result<
120129
let mut child = child;
121130

122131
std::thread::spawn(move || {
123-
let _ = child.wait();
124-
let elapsed_mins = start.elapsed().as_secs() as i64 / 60;
125-
finish_session(&app_clone, &game_id, &started_at, elapsed_mins);
132+
loop {
133+
std::thread::sleep(std::time::Duration::from_secs(5));
134+
if start.elapsed().as_secs() > 86400 {
135+
let _ = child.kill();
136+
break;
137+
}
138+
match child.try_wait() {
139+
Ok(Some(_)) => break,
140+
Ok(None) => continue,
141+
Err(_) => break,
142+
}
143+
}
144+
finish_session(&app_clone, &game_id, &started_at, start.elapsed().as_secs());
126145
});
127146

128147
Ok(())
@@ -202,13 +221,14 @@ pub fn launch_steam_game(
202221
return;
203222
}
204223
}
224+
let game_start = std::time::Instant::now();
205225
loop {
206226
std::thread::sleep(std::time::Duration::from_secs(5));
227+
if game_start.elapsed().as_secs() > 86400 { break; }
207228
if !is_pid_running(pid) { break; }
208229
}
209230
pids.lock().unwrap_or_else(|e| e.into_inner()).remove(&pid);
210-
let elapsed_mins = start.elapsed().as_secs() as i64 / 60;
211-
finish_session(&app_clone, &gid, &started_at, elapsed_mins);
231+
finish_session(&app_clone, &gid, &started_at, game_start.elapsed().as_secs());
212232
} else {
213233
finish_session(&app_clone, &gid, &started_at, 0);
214234
}
@@ -297,13 +317,14 @@ pub fn launch_epic_game(
297317
return;
298318
}
299319
}
320+
let game_start = std::time::Instant::now();
300321
loop {
301322
std::thread::sleep(std::time::Duration::from_secs(5));
323+
if game_start.elapsed().as_secs() > 86400 { break; }
302324
if !is_pid_running(pid) { break; }
303325
}
304326
pids.lock().unwrap_or_else(|e| e.into_inner()).remove(&pid);
305-
let elapsed_mins = start.elapsed().as_secs() as i64 / 60;
306-
finish_session(&app_clone, &gid, &started_at, elapsed_mins);
327+
finish_session(&app_clone, &gid, &started_at, game_start.elapsed().as_secs());
307328
} else {
308329
finish_session(&app_clone, &gid, &started_at, 0);
309330
}

src-tauri/src/commands/scanner.rs

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -459,8 +459,20 @@ fn scan_steam_games_inner(app: AppHandle, db: std::sync::Arc<std::sync::Mutex<ru
459459
let mut added = 0;
460460
let mut skipped = 0;
461461

462+
let mut steam_stmt = conn.prepare(
463+
"SELECT steam_app_id, id, cover_path FROM games WHERE steam_app_id IS NOT NULL AND deleted_at IS NULL"
464+
).map_err(|e| e.to_string())?;
465+
let steam_existing: std::collections::HashMap<String, (String, Option<String>)> =
466+
steam_stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option<String>>(2)?)))
467+
.map_err(|e| e.to_string())?
468+
.filter_map(|r| r.ok())
469+
.map(|(app_id, id, cover)| (app_id, (id, cover)))
470+
.collect();
471+
drop(steam_stmt);
472+
462473
for entry in entries {
463-
if let Some((existing_id, _)) = queries::get_steam_game_cover(&conn, &entry.app_id) {
474+
if let Some((existing_id, _)) = steam_existing.get(&entry.app_id) {
475+
let existing_id = existing_id.clone();
464476
if let Some(ref cover) = entry.cover {
465477
let _ = queries::update_cover_path(&conn, &existing_id, cover);
466478
}
@@ -587,8 +599,20 @@ fn scan_epic_games_inner(app: AppHandle, db: std::sync::Arc<std::sync::Mutex<rus
587599
let mut added = 0;
588600
let mut skipped = 0;
589601

602+
let mut epic_stmt = conn.prepare(
603+
"SELECT epic_app_name, id, cover_path FROM games WHERE epic_app_name IS NOT NULL AND deleted_at IS NULL"
604+
).map_err(|e| e.to_string())?;
605+
let epic_existing: std::collections::HashMap<String, (String, Option<String>)> =
606+
epic_stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option<String>>(2)?)))
607+
.map_err(|e| e.to_string())?
608+
.filter_map(|r| r.ok())
609+
.map(|(app_name, id, cover)| (app_name, (id, cover)))
610+
.collect();
611+
drop(epic_stmt);
612+
590613
for entry in entries {
591-
if let Some((existing_id, existing_cover)) = queries::get_epic_game_cover(&conn, &entry.app_name) {
614+
if let Some((existing_id, existing_cover)) = epic_existing.get(&entry.app_name) {
615+
let existing_id = existing_id.clone();
592616
if existing_cover.is_none() {
593617
if let Some(ref cover) = entry.cover {
594618
let _ = queries::update_cover_path(&conn, &existing_id, cover);
@@ -734,14 +758,18 @@ fn scan_gog_games_inner(app: AppHandle, db: std::sync::Arc<std::sync::Mutex<rusq
734758
let mut added = 0;
735759
let mut skipped = 0;
736760

737-
for entry in entries {
738-
let exists: bool = conn.query_row(
739-
"SELECT COUNT(*) FROM games WHERE install_dir = ?1 AND platform = 'gog'",
740-
rusqlite::params![entry.install_dir],
741-
|r| r.get::<_, i64>(0),
742-
).unwrap_or(0) > 0;
761+
let mut gog_stmt = conn.prepare(
762+
"SELECT install_dir FROM games WHERE platform = 'gog' AND install_dir IS NOT NULL AND deleted_at IS NULL"
763+
).map_err(|e| e.to_string())?;
764+
let gog_existing: std::collections::HashSet<String> =
765+
gog_stmt.query_map([], |r| r.get::<_, String>(0))
766+
.map_err(|e| e.to_string())?
767+
.filter_map(|r| r.ok())
768+
.collect();
769+
drop(gog_stmt);
743770

744-
if exists { skipped += 1; continue; }
771+
for entry in entries {
772+
if gog_existing.contains(&entry.install_dir) { skipped += 1; continue; }
745773

746774
let game = Game {
747775
id: uuid::Uuid::new_v4().to_string(),
@@ -1052,8 +1080,31 @@ fn scan_folder_for_games_inner(app: AppHandle, db: std::sync::Arc<std::sync::Mut
10521080
#[tauri::command]
10531081
pub fn set_game_cover(state: State<DbState>, game_id: String, image_path: String) -> Result<String, String> {
10541082
let src = std::path::Path::new(&image_path);
1055-
if !src.exists() {
1056-
return Err("Image file not found".to_string());
1083+
1084+
let meta = std::fs::symlink_metadata(src).map_err(|_| "Image file not found".to_string())?;
1085+
if meta.file_type().is_symlink() {
1086+
return Err("Symlinks are not allowed as cover images".to_string());
1087+
}
1088+
1089+
let ext_str = src.extension()
1090+
.and_then(|e| e.to_str())
1091+
.map(|s| s.to_lowercase())
1092+
.unwrap_or_default();
1093+
const ALLOWED_EXTS: &[&str] = &["jpg", "jpeg", "png", "webp"];
1094+
if !ALLOWED_EXTS.contains(&ext_str.as_str()) {
1095+
return Err(format!("Unsupported image format '{}'. Use jpg, png, or webp.", ext_str));
1096+
}
1097+
1098+
let mut img_file = std::fs::File::open(src).map_err(|e| e.to_string())?;
1099+
let mut magic = [0u8; 12];
1100+
let n = std::io::Read::read(&mut img_file, &mut magic).unwrap_or(0);
1101+
let magic = &magic[..n];
1102+
let valid_magic =
1103+
(magic.len() >= 3 && magic[0] == 0xFF && magic[1] == 0xD8 && magic[2] == 0xFF) ||
1104+
(magic.len() >= 4 && &magic[0..4] == b"\x89PNG") ||
1105+
(magic.len() >= 12 && &magic[0..4] == b"RIFF" && &magic[8..12] == b"WEBP");
1106+
if !valid_magic {
1107+
return Err("File does not appear to be a valid image".to_string());
10571108
}
10581109

10591110
let data_dir = dirs::data_local_dir()
@@ -1063,8 +1114,7 @@ pub fn set_game_cover(state: State<DbState>, game_id: String, image_path: String
10631114

10641115
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
10651116

1066-
let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png");
1067-
let dest_name = format!("{}.{}", game_id, ext);
1117+
let dest_name = format!("{}.{}", game_id, ext_str);
10681118
let dest = data_dir.join(&dest_name);
10691119

10701120
std::fs::copy(src, &dest).map_err(|e| e.to_string())?;

0 commit comments

Comments
 (0)