refactor (Opus 4.5): modularize and split
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
//! 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<String>,
|
||||
) -> 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<u64> {
|
||||
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<GameDB> {
|
||||
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<GameDB> {
|
||||
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<Vec<GameFileDescription>, 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)
|
||||
}
|
||||
Reference in New Issue
Block a user