ChatGPT Codex 5.2 xhigh refactored > 45min
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
//! Local game scanning and database management.
|
||||
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
collections::{HashMap, HashSet},
|
||||
hash::{Hash, Hasher},
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_db::db::{Game, GameDB, GameFileDescription};
|
||||
use lanspread_proto::{Availability, GameSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::PeerError;
|
||||
|
||||
@@ -72,137 +76,138 @@ pub async fn local_download_available(
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Directory size calculation
|
||||
// Local library index and scanning
|
||||
// =============================================================================
|
||||
|
||||
/// 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?;
|
||||
const LIBRARY_INDEX_DIR: &str = ".lanspread";
|
||||
const LIBRARY_INDEX_FILE: &str = "library_index.json";
|
||||
|
||||
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)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct LibraryIndex {
|
||||
revision: u64,
|
||||
games: HashMap<String, GameIndexEntry>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Game database loading
|
||||
// =============================================================================
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct GameIndexEntry {
|
||||
summary: GameSummary,
|
||||
fingerprint: GameFingerprint,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
struct GameFingerprint {
|
||||
eti_size: Option<u64>,
|
||||
eti_mtime: Option<u64>,
|
||||
version_mtime: Option<u64>,
|
||||
local_dir_present: bool,
|
||||
}
|
||||
|
||||
let metadata = match tokio::fs::metadata(&game_path).await {
|
||||
Ok(metadata) => metadata,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalLibraryScan {
|
||||
pub game_db: GameDB,
|
||||
pub summaries: HashMap<String, GameSummary>,
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
fn library_index_path(game_dir: &str) -> PathBuf {
|
||||
PathBuf::from(game_dir)
|
||||
.join(LIBRARY_INDEX_DIR)
|
||||
.join(LIBRARY_INDEX_FILE)
|
||||
}
|
||||
|
||||
async fn load_library_index(path: &Path) -> LibraryIndex {
|
||||
let data = match tokio::fs::read_to_string(path).await {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
if err.kind() == ErrorKind::NotFound {
|
||||
log::warn!(
|
||||
"Local game directory {} missing; reporting empty game database",
|
||||
game_path.display()
|
||||
);
|
||||
return Ok(GameDB::empty());
|
||||
if err.kind() != ErrorKind::NotFound {
|
||||
log::warn!("Failed to read library index {}: {err}", path.display());
|
||||
}
|
||||
return Err(err.into());
|
||||
return LibraryIndex {
|
||||
revision: 0,
|
||||
games: HashMap::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if !metadata.is_dir() {
|
||||
log::warn!(
|
||||
"Configured game directory {} is not a directory; reporting empty game database",
|
||||
game_path.display()
|
||||
);
|
||||
return Ok(GameDB::empty());
|
||||
match serde_json::from_str(&data) {
|
||||
Ok(index) => index,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to parse library index {}: {err}", path.display());
|
||||
LibraryIndex {
|
||||
revision: 0,
|
||||
games: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let data = serde_json::to_vec_pretty(index)?;
|
||||
tokio::fs::write(path, data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn system_time_to_secs(time: SystemTime) -> u64 {
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
async fn fingerprint_game_dir(game_path: &Path, game_id: &str) -> eyre::Result<GameFingerprint> {
|
||||
let eti_path = game_path.join(format!("{game_id}.eti"));
|
||||
let (eti_size, eti_mtime) = match tokio::fs::metadata(&eti_path).await {
|
||||
Ok(metadata) => (
|
||||
Some(metadata.len()),
|
||||
metadata.modified().ok().map(system_time_to_secs),
|
||||
),
|
||||
Err(_) => (None, None),
|
||||
};
|
||||
|
||||
let version_path = game_path.join("version.ini");
|
||||
let version_mtime = match tokio::fs::metadata(&version_path).await {
|
||||
Ok(metadata) => metadata.modified().ok().map(system_time_to_secs),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let local_dir_present = local_dir_has_content(game_path).await;
|
||||
|
||||
Ok(GameFingerprint {
|
||||
eti_size,
|
||||
eti_mtime,
|
||||
version_mtime,
|
||||
local_dir_present,
|
||||
})
|
||||
}
|
||||
|
||||
fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
|
||||
if entry.depth() != 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut games = Vec::new();
|
||||
if entry.file_type().is_dir() && entry.file_name().to_str().is_some_and(is_local_dir_name) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if entry.file_type().is_dir() && name == ".sync" {
|
||||
return true;
|
||||
}
|
||||
if entry.file_type().is_file() && name == ".softlan_game_installed" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(GameDB::from(games))
|
||||
false
|
||||
}
|
||||
|
||||
/// 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(
|
||||
async fn scan_game_descriptions(
|
||||
game_id: &str,
|
||||
game_dir: &str,
|
||||
game_dir: &Path,
|
||||
) -> Result<Vec<GameFileDescription>, PeerError> {
|
||||
let base_dir = PathBuf::from(game_dir);
|
||||
let base_dir = game_dir;
|
||||
let game_path = base_dir.join(game_id);
|
||||
|
||||
if !game_path.exists() {
|
||||
@@ -216,30 +221,10 @@ pub async fn get_game_file_descriptions(
|
||||
|
||||
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_entry(|entry| !should_skip_root_entry(entry))
|
||||
.filter_map(std::result::Result::ok)
|
||||
{
|
||||
let relative_path = match entry.path().strip_prefix(&base_dir) {
|
||||
let relative_path = match entry.path().strip_prefix(base_dir) {
|
||||
Ok(path) => path.to_string_lossy().to_string(),
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
@@ -279,3 +264,236 @@ pub async fn get_game_file_descriptions(
|
||||
|
||||
Ok(file_descriptions)
|
||||
}
|
||||
|
||||
fn manifest_hash(file_descriptions: &[GameFileDescription]) -> u64 {
|
||||
let mut entries: Vec<_> = file_descriptions
|
||||
.iter()
|
||||
.filter(|desc| !desc.is_dir)
|
||||
.map(|desc| (&desc.relative_path, desc.size, desc.is_dir))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(&b.1)));
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
for (path, size, is_dir) in entries {
|
||||
path.hash(&mut hasher);
|
||||
size.hash(&mut hasher);
|
||||
is_dir.hash(&mut hasher);
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result<GameSummary, PeerError> {
|
||||
let game_path = game_dir.join(game_id);
|
||||
let eti_path = game_path.join(format!("{game_id}.eti"));
|
||||
let downloaded = tokio::fs::metadata(&eti_path).await.is_ok();
|
||||
if !downloaded {
|
||||
return Err(PeerError::Other(eyre::eyre!(
|
||||
"Game is not downloaded: {game_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
let installed = local_dir_has_content(&game_path).await;
|
||||
let eti_version = if installed {
|
||||
match lanspread_db::db::read_version_from_ini(&game_path) {
|
||||
Ok(version) => version,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read version.ini for installed game {game_id}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let file_descriptions = scan_game_descriptions(game_id, game_dir).await?;
|
||||
let total_size = file_descriptions
|
||||
.iter()
|
||||
.filter(|desc| !desc.is_dir)
|
||||
.map(|desc| desc.size)
|
||||
.sum();
|
||||
let manifest_hash = manifest_hash(&file_descriptions);
|
||||
|
||||
Ok(GameSummary {
|
||||
id: game_id.to_string(),
|
||||
name: game_id.to_string(),
|
||||
size: total_size,
|
||||
downloaded,
|
||||
installed,
|
||||
eti_version,
|
||||
manifest_hash,
|
||||
availability: Availability::Ready,
|
||||
})
|
||||
}
|
||||
|
||||
fn game_from_summary(summary: &GameSummary) -> Game {
|
||||
Game {
|
||||
id: summary.id.clone(),
|
||||
name: summary.name.clone(),
|
||||
description: String::new(),
|
||||
release_year: String::new(),
|
||||
publisher: String::new(),
|
||||
max_players: 1,
|
||||
version: "1.0".to_string(),
|
||||
genre: String::new(),
|
||||
size: summary.size,
|
||||
downloaded: summary.downloaded,
|
||||
installed: summary.installed,
|
||||
eti_game_version: summary.eti_version.clone(),
|
||||
local_version: summary.eti_version.clone(),
|
||||
peer_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
struct IndexUpdate {
|
||||
summary: Option<GameSummary>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
async fn update_index_for_game(
|
||||
game_root: &Path,
|
||||
game_id: &str,
|
||||
index: &mut LibraryIndex,
|
||||
) -> eyre::Result<IndexUpdate> {
|
||||
let game_path = game_root.join(game_id);
|
||||
let fingerprint = fingerprint_game_dir(&game_path, game_id).await?;
|
||||
|
||||
if fingerprint.eti_size.is_none() {
|
||||
return Ok(IndexUpdate {
|
||||
summary: None,
|
||||
changed: index.games.remove(game_id).is_some(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
let summary = match index.games.get(game_id) {
|
||||
Some(entry) if entry.fingerprint == fingerprint => entry.summary.clone(),
|
||||
_ => {
|
||||
changed = true;
|
||||
build_game_summary(game_root, game_id).await?
|
||||
}
|
||||
};
|
||||
|
||||
if index
|
||||
.games
|
||||
.get(game_id)
|
||||
.is_some_and(|entry| entry.summary.manifest_hash != summary.manifest_hash)
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
|
||||
index.games.insert(
|
||||
game_id.to_string(),
|
||||
GameIndexEntry {
|
||||
summary: summary.clone(),
|
||||
fingerprint,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(IndexUpdate {
|
||||
summary: Some(summary),
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
fn empty_scan() -> LocalLibraryScan {
|
||||
LocalLibraryScan {
|
||||
game_db: GameDB::empty(),
|
||||
summaries: HashMap::new(),
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Game database loading
|
||||
// =============================================================================
|
||||
|
||||
/// Scans the local game directory and returns summaries plus a game database.
|
||||
pub async fn scan_local_library(game_dir: &str) -> eyre::Result<LocalLibraryScan> {
|
||||
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(empty_scan());
|
||||
}
|
||||
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(empty_scan());
|
||||
}
|
||||
|
||||
let index_path = library_index_path(game_dir);
|
||||
let mut index = load_library_index(&index_path).await;
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut summaries = HashMap::new();
|
||||
let mut games = Vec::new();
|
||||
let mut changed = false;
|
||||
|
||||
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() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let update = update_index_for_game(&game_path, game_id, &mut index).await?;
|
||||
changed |= update.changed;
|
||||
|
||||
let Some(summary) = update.summary else {
|
||||
continue;
|
||||
};
|
||||
|
||||
seen_ids.insert(game_id.to_string());
|
||||
summaries.insert(game_id.to_string(), summary.clone());
|
||||
games.push(game_from_summary(&summary));
|
||||
}
|
||||
|
||||
let before = index.games.len();
|
||||
index.games.retain(|game_id, _| seen_ids.contains(game_id));
|
||||
if index.games.len() != before {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
index.revision = index.revision.saturating_add(1);
|
||||
if let Err(err) = save_library_index(&index_path, &index).await {
|
||||
log::warn!(
|
||||
"Failed to persist library index {}: {err}",
|
||||
index_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LocalLibraryScan {
|
||||
game_db: GameDB::from(games),
|
||||
summaries,
|
||||
revision: index.revision,
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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> {
|
||||
scan_game_descriptions(game_id, &PathBuf::from(game_dir)).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user