//! Local game scanning and database management. use std::{ collections::HashSet, io::ErrorKind, path::{Path, PathBuf}, }; use lanspread_db::db::{Game, GameDB, GameFileDescription}; use crate::error::PeerError; // ============================================================================= // Local directory helpers // ============================================================================= #[cfg(target_os = "windows")] pub fn is_local_dir_name(name: &str) -> bool { name.eq_ignore_ascii_case("local") } #[cfg(not(target_os = "windows"))] pub fn is_local_dir_name(name: &str) -> bool { name == "local" } /// Checks if a local directory has any content. pub async fn local_dir_has_content(path: &Path) -> bool { let local_dir = path.join("local"); if tokio::fs::metadata(&local_dir).await.is_err() { return false; } let mut entries = match tokio::fs::read_dir(&local_dir).await { Ok(entries) => entries, Err(e) => { log::warn!("Failed to read local dir {}: {e}", local_dir.display()); return false; } }; match entries.next_entry().await { Ok(Some(_)) => true, Ok(None) => false, Err(e) => { log::warn!("Failed to iterate local dir {}: {e}", local_dir.display()); false } } } /// Checks if a game is available for download locally. pub async fn local_download_available( game_dir: &str, game_id: &str, downloading_games: &HashSet, ) -> bool { if downloading_games.contains(game_id) { log::debug!("Not serving game {game_id} locally because it is still downloading"); return false; } let game_path = PathBuf::from(game_dir).join(game_id); let eti_path = game_path.join(format!("{game_id}.eti")); if tokio::fs::metadata(&eti_path).await.is_err() { return false; } // Only treat as pending install if the local installation directory is empty/missing !local_dir_has_content(game_path.as_path()).await } // ============================================================================= // Directory size calculation // ============================================================================= /// Calculates the total size of a directory recursively. pub async fn calculate_directory_size(dir: &Path, is_root: bool) -> eyre::Result { let mut total_size = 0u64; let mut entries = tokio::fs::read_dir(dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); let name = entry.file_name(); let name_str = name.to_string_lossy(); if is_root { if name_str == ".sync" || name_str == ".softlan_first_start_done" { continue; } if entry.file_type().await?.is_dir() && is_local_dir_name(&name_str) { continue; } } let metadata = tokio::fs::metadata(&path).await?; if metadata.is_dir() { total_size += Box::pin(calculate_directory_size(&path, false)).await?; } else { total_size += metadata.len(); } } Ok(total_size) } // ============================================================================= // Game database loading // ============================================================================= /// Loads the local game database from the game directory. pub async fn load_local_game_db(game_dir: &str) -> eyre::Result { let game_path = PathBuf::from(game_dir); let metadata = match tokio::fs::metadata(&game_path).await { Ok(metadata) => metadata, Err(err) => { if err.kind() == ErrorKind::NotFound { log::warn!( "Local game directory {} missing; reporting empty game database", game_path.display() ); return Ok(GameDB::empty()); } return Err(err.into()); } }; if !metadata.is_dir() { log::warn!( "Configured game directory {} is not a directory; reporting empty game database", game_path.display() ); return Ok(GameDB::empty()); } let mut games = Vec::new(); // Scan game directory and create entries for installed games let mut entries = tokio::fs::read_dir(&game_path).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.is_dir() && let Some(game_id) = path.file_name().and_then(|n| n.to_str()) { let eti_path = path.join(format!("{game_id}.eti")); let downloaded = tokio::fs::metadata(&eti_path).await.is_ok(); if !downloaded { continue; } let installed = local_dir_has_content(&path).await; let local_version = if installed { match lanspread_db::db::read_version_from_ini(&path) { Ok(version) => version, Err(e) => { log::warn!("Failed to read version.ini for installed game {game_id}: {e}"); None } } } else { None }; let size = calculate_directory_size(&path, true).await?; let game = Game { id: game_id.to_string(), name: game_id.to_string(), description: String::new(), release_year: String::new(), publisher: String::new(), max_players: 1, version: "1.0".to_string(), genre: String::new(), size, downloaded, installed, eti_game_version: local_version.clone(), local_version, peer_count: 0, // Local games start with 0 peers }; games.push(game); } } Ok(GameDB::from(games)) } /// Scans the local games directory and returns a `GameDB` with current games. pub async fn scan_local_games(game_dir: &str) -> eyre::Result { load_local_game_db(game_dir).await } // ============================================================================= // Game file descriptions // ============================================================================= /// Gets file descriptions for a game from the local filesystem. pub async fn get_game_file_descriptions( game_id: &str, game_dir: &str, ) -> Result, PeerError> { let base_dir = PathBuf::from(game_dir); let game_path = base_dir.join(game_id); if !game_path.exists() { return Err(PeerError::Other(eyre::eyre!( "Game directory does not exist: {}", game_path.display() ))); } let mut file_descriptions = Vec::new(); for entry in walkdir::WalkDir::new(&game_path) .into_iter() .filter_entry(|entry| { if entry.depth() == 1 { if entry.file_type().is_dir() && entry.file_name().to_str().is_some_and(is_local_dir_name) { // Skip the local install folder entirely so WalkDir never enters it. return false; } if let Some(name) = entry.file_name().to_str() { if entry.file_type().is_dir() && name == ".sync" { return false; } if entry.file_type().is_file() && name == ".softlan_game_installed" { return false; } } } true }) .filter_map(std::result::Result::ok) { let relative_path = match entry.path().strip_prefix(&base_dir) { Ok(path) => path.to_string_lossy().to_string(), Err(e) => { log::error!( "Failed to get relative path for {}: {}", entry.path().display(), e ); continue; } }; let is_dir = entry.file_type().is_dir(); let size = if is_dir { 0 } else { match tokio::fs::metadata(entry.path()).await { Ok(metadata) => metadata.len(), Err(e) => { log::error!("Failed to read metadata for {relative_path}: {e}"); return Err(PeerError::FileSizeDetermination { path: relative_path.clone(), source: e, }); } } }; let file_desc = GameFileDescription { game_id: game_id.to_string(), relative_path, is_dir, size, }; file_descriptions.push(file_desc); } Ok(file_descriptions) }