use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use chrono::Utc;
use ignore::{DirEntry, Walk, WalkBuilder};
use crate::logger::{get_logger, LogEvent, LogLevel, ScanProgress};
use crate::models::game_info::GameInfo;
use crate::providers::GameDatabaseMiddleware;
use crate::scan::game_grouping::{paths_group, PathGroupResult};
use crate::scan::utils::{calculate_directory_size_async, find_icon_path};
pub struct GameScanner {
middleware: GameDatabaseMiddleware,
enable_exe_icon_extract: bool,
}
impl GameScanner {
pub fn new() -> Self {
GameScanner {
middleware: GameDatabaseMiddleware::new(),
enable_exe_icon_extract: false,
}
}
pub async fn with_dlsite_provider(self) -> Self {
use crate::providers::dlsite_provider::DLsiteProvider;
self.middleware
.register_provider(Arc::new(DLsiteProvider::new()))
.await;
self
}
pub async fn with_igdb_provider(self, client_id: String, client_secret: String) -> Self {
use crate::providers::igdb_provider::IGDBProvider;
self.middleware
.register_provider(Arc::new(IGDBProvider::with_credentials(
client_id,
client_secret,
)))
.await;
self
}
pub async fn with_thegamesdb_provider(self) -> Self {
use crate::providers::thegamesdb_provider::TheGamesDBProvider;
self.middleware
.register_provider(Arc::new(TheGamesDBProvider::new()))
.await;
self
}
pub fn with_win_exe_icon(self) -> Self {
GameScanner {
middleware: self.middleware,
enable_exe_icon_extract: true,
}
}
pub async fn with_provider(
self,
provider: Arc<dyn crate::providers::GameDatabaseProvider>,
) -> Self {
self.middleware.register_provider(provider).await;
self
}
pub async fn scan(self, scan_path: String) -> Vec<GameInfo> {
self.scan_internal(scan_path).await
}
pub async fn search(
self,
search_key: String,
) -> Result<Vec<crate::providers::GameQueryResult>, Box<dyn std::error::Error + Send + Sync>> {
self.middleware
.search_with_timeout(&search_key, std::time::Duration::from_secs(30))
.await
}
async fn scan_internal(&self, scan_path: String) -> Vec<GameInfo> {
let mut game_infos: Vec<GameInfo> = Vec::new();
let logger = get_logger();
logger.log(&LogEvent::new(
LogLevel::Info,
"开始并行扫描 .exe 文件...",
));
let exe_paths = Arc::new(Mutex::new(Vec::new()));
{
let exe_paths_clone = Arc::clone(&exe_paths);
WalkBuilder::new(&scan_path)
.threads(num_cpus::get()) .build_parallel()
.run(|| {
let exe_paths = Arc::clone(&exe_paths_clone);
Box::new(move |result| {
if let Ok(entry) = result {
if let Some(file_type) = entry.file_type() {
if file_type.is_file() {
if let Some(ext) = entry.path().extension() {
if ext == "exe" {
if let Ok(mut paths) = exe_paths.lock() {
paths.push(entry.path().to_path_buf());
}
}
}
}
}
}
ignore::WalkState::Continue
})
});
}
let exe_paths = Arc::try_unwrap(exe_paths)
.expect("Failed to unwrap Arc")
.into_inner()
.expect("Failed to unwrap Mutex");
logger.log(&LogEvent::new(
LogLevel::Success,
format!("扫描完成,找到 {} 个 .exe 文件", exe_paths.len()),
));
let mut exe_dirs: Vec<DirEntry> = Vec::new();
for path in exe_paths {
for result in Walk::new(&path) {
if let Ok(entry) = result {
if entry.path() == path {
exe_dirs.push(entry);
break;
}
}
}
}
let groups: Vec<PathGroupResult> = paths_group(exe_dirs);
let logger = get_logger();
for (idx, item) in groups.iter().enumerate() {
let progress = ScanProgress::new(idx + 1, groups.len(), &item.child_root_name);
logger.section(&format!("{} - {}", progress.format(), item.child_root_name));
if item.search_key != item.child_root_name {
logger.log(&LogEvent::new(
LogLevel::Debug,
format!("搜索关键词: {}", item.search_key),
));
}
let start_time = Instant::now();
match self.middleware.search(&item.search_key).await {
Ok(game_query_results) => {
let duration_ms = start_time.elapsed().as_millis() as u64;
if game_query_results.is_empty() {
logger.log(&LogEvent::new(LogLevel::Warning, "未找到任何结果"));
} else {
self.process_query_results(&game_query_results, duration_ms);
}
let game_info = self.build_game_info(item, game_query_results).await;
game_infos.push(game_info);
}
Err(e) => {
logger.log(
&LogEvent::new(
LogLevel::Error,
format!("查询失败: {}", item.child_root_name),
)
.with_details(e.to_string()),
);
let game_info = self.build_fallback_game_info(item).await;
game_infos.push(game_info);
}
}
}
logger.section(&format!("扫描完成!共找到 {} 个游戏", game_infos.len()));
logger.log(&LogEvent::new(
LogLevel::Success,
format!("成功扫描 {} 个游戏目录", game_infos.len()),
));
game_infos
}
fn process_query_results(
&self,
game_query_results: &[crate::providers::GameQueryResult],
duration_ms: u64,
) {
let logger = get_logger();
let mut provider_results: std::collections::HashMap<
String,
Vec<&crate::providers::GameQueryResult>,
> = std::collections::HashMap::new();
for result in game_query_results {
provider_results
.entry(result.source.clone())
.or_insert_with(Vec::new)
.push(result);
}
logger.log(&LogEvent::new(
LogLevel::Success,
format!(
"找到 {} 条结果 (耗时: {}ms)",
game_query_results.len(),
duration_ms
),
));
for (provider_name, results) in provider_results.iter() {
logger.subsection(&format!(
"📦 {} - {} 条结果",
provider_name,
results.len()
));
for (idx, result) in results.iter().enumerate() {
println!(
" [{}/{}] 置信度: {:.2}",
idx + 1,
results.len(),
result.confidence
);
if let Some(title) = &result.info.title {
println!(" 标题: {}", title);
}
if let Some(developer) = &result.info.developer {
println!(" 开发商: {}", developer);
}
if let Some(publisher) = &result.info.publisher {
println!(" 发行商: {}", publisher);
}
if let Some(release_date) = &result.info.release_date {
println!(" 发布日期: {}", release_date);
}
if let Some(genres) = &result.info.genres {
println!(" 类型: {}", genres.join(", "));
}
if let Some(cover_url) = &result.info.cover_url {
println!(" 封面: {}", cover_url);
}
println!();
}
}
}
async fn build_game_info(
&self,
item: &PathGroupResult,
game_query_results: Vec<crate::providers::GameQueryResult>,
) -> GameInfo {
let mut title = None; let mut cover_urls = Vec::new();
let mut description = None;
let mut release_date = None;
let mut developer = None;
let mut publisher = None;
let mut tabs = None;
let platform = None;
for result in game_query_results.iter() {
if title.is_none() && result.info.title.is_some() {
title = result.info.title.clone();
}
if let Some(cover_url) = &result.info.cover_url {
if !cover_urls.contains(cover_url) {
cover_urls.push(cover_url.clone());
}
}
if description.is_none() && result.info.description.is_some() {
description = result.info.description.clone();
}
if release_date.is_none() && result.info.release_date.is_some() {
release_date = result.info.release_date.clone();
}
if developer.is_none() && result.info.developer.is_some() {
developer = result.info.developer.clone();
}
if publisher.is_none() && result.info.publisher.is_some() {
publisher = result.info.publisher.clone();
}
if let Some(genres) = &result.info.genres {
let genres_str = genres.join(", ");
if tabs.is_none() {
tabs = Some(genres_str);
} else if let Some(existing_tabs) = &tabs {
let mut all_tabs: Vec<String> = existing_tabs
.split(", ")
.map(|s| s.to_string())
.collect();
for genre in genres {
if !all_tabs.contains(genre) {
all_tabs.push(genre.clone());
}
}
tabs = Some(all_tabs.join(", "));
}
}
if let Some(tags) = &result.info.tags {
let tags_str = tags.join(", ");
if tabs.is_none() {
tabs = Some(tags_str);
} else if let Some(existing_tabs) = &tabs {
let mut all_tabs: Vec<String> = existing_tabs
.split(", ")
.map(|s| s.to_string())
.collect();
for tag in tags {
if !all_tabs.contains(tag) {
all_tabs.push(tag.clone());
}
}
tabs = Some(all_tabs.join(", "));
}
}
}
let dir_path = PathBuf::from(&item.root_path);
let byte_size = calculate_directory_size_async(dir_path.clone()).await;
let start_path_defualt = {
let mut exe_paths: Vec<(usize, String)> = item
.child_path
.iter()
.filter(|p| p.to_lowercase().ends_with(".exe"))
.map(|p| {
let depth = p.matches('/').count() + p.matches('\\').count();
(depth, p.clone())
})
.collect();
exe_paths.sort_by(|a, b| a.0.cmp(&b.0));
exe_paths
.first()
.map(|(_, p)| p.clone())
.or_else(|| item.child_path.first().cloned())
.unwrap_or_default()
};
let icon_path = {
let p = start_path_defualt.clone();
find_icon_path(
dir_path.clone(),
&item.child_path,
self.enable_exe_icon_extract,
&item.child_root_name,
Some(&p),
)
.await
.unwrap_or_default()
};
let parsed_release_date = if let Some(date_str) = release_date {
chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| chrono::DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
.or_else(|| {
date_str.parse::<i32>().ok().and_then(|year| {
chrono::NaiveDate::from_ymd_opt(year, 1, 1)
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| chrono::DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
})
})
.unwrap_or_else(Utc::now)
} else {
Utc::now()
};
let final_title = title.unwrap_or_else(|| item.child_root_name.clone());
let mut sorted_child_path: Vec<String> = item.child_path.clone();
sorted_child_path.sort_by(|a, b| {
let da = a.matches('/').count() + a.matches('\\').count();
let db = b.matches('/').count() + b.matches('\\').count();
da.cmp(&db).then(a.len().cmp(&b.len()))
});
GameInfo {
title: final_title,
sub_title: item.child_root_name.clone(), version: item.version.clone(),
cover_urls,
dir_path,
start_path: sorted_child_path,
start_path_defualt,
description,
release_date: parsed_release_date,
developer,
publisher,
tabs,
platform,
byte_size,
scan_time: Utc::now(),
icon_path,
}
}
async fn build_fallback_game_info(&self, item: &PathGroupResult) -> GameInfo {
let dir_path = PathBuf::from(&item.root_path);
let byte_size = calculate_directory_size_async(dir_path.clone()).await;
let start_path_defualt = {
let mut exe_paths: Vec<(usize, String)> = item
.child_path
.iter()
.filter(|p| p.to_lowercase().ends_with(".exe"))
.map(|p| {
let depth = p.matches('/').count() + p.matches('\\').count();
(depth, p.clone())
})
.collect();
exe_paths.sort_by(|a, b| a.0.cmp(&b.0));
exe_paths
.first()
.map(|(_, p)| p.clone())
.or_else(|| item.child_path.first().cloned())
.unwrap_or_default()
};
let icon_path = {
let p = start_path_defualt.clone();
find_icon_path(
dir_path.clone(),
&item.child_path,
self.enable_exe_icon_extract,
&item.child_root_name,
Some(&p),
)
.await
.unwrap_or_default()
};
let mut sorted_child_path: Vec<String> = item.child_path.clone();
sorted_child_path.sort_by(|a, b| {
let da = a.matches('/').count() + a.matches('\\').count();
let db = b.matches('/').count() + b.matches('\\').count();
da.cmp(&db).then(a.len().cmp(&b.len()))
});
GameInfo {
title: item.child_root_name.clone(),
sub_title: item.child_root_name.clone(), version: item.version.clone(),
cover_urls: Vec::new(),
dir_path,
start_path: sorted_child_path,
start_path_defualt,
description: None,
release_date: Utc::now(),
developer: None,
publisher: None,
tabs: None,
platform: None,
byte_size,
scan_time: Utc::now(),
icon_path,
}
}
}
#[deprecated(since = "0.2.0", note = "请使用 GameScanner::new().scan(path) 代替")]
pub async fn walk_path(root_path: String) -> Vec<GameInfo> {
GameScanner::new().scan(root_path).await
}