mirror of
https://github.com/yuhkix/wuwa-downloader.git
synced 2025-06-06 17:53:44 +00:00
major downloader improvements
- Added support for multiple CDN fallbacks from index.json configuration - Implemented GZIP response handling for all requests - Added version selection menu via GitHub Gist index - Improved error handling and logging system - Added progress tracking in window title - Removed file count from console output (kept in title only) - Enhanced download reliability with retries and checksum verification - Added proper cleanup on Ctrl+C interrupt - Refactored code structure for better maintainability
This commit is contained in:
parent
35809fc1e8
commit
f71d6d5d6c
5 changed files with 653 additions and 133 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -6,9 +6,13 @@ target/
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Error / Debug Logging
|
||||||
|
*.log
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
|
|
||||||
# RustRover
|
# RustRover
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
|
21
Cargo.lock
generated
21
Cargo.lock
generated
|
@ -129,6 +129,15 @@ version = "0.8.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
|
@ -221,6 +230,16 @@ version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
@ -1737,8 +1756,10 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"colored",
|
"colored",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
|
"flate2",
|
||||||
"md-5",
|
"md-5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
"winconsole",
|
"winconsole",
|
||||||
|
|
10
Cargo.toml
10
Cargo.toml
|
@ -5,9 +5,11 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
colored = "3.0.0"
|
colored = "3.0.0"
|
||||||
|
md-5 = "0.10.6"
|
||||||
reqwest = { version = "0.12.15", features = ["blocking", "json"] }
|
reqwest = { version = "0.12.15", features = ["blocking", "json"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0.140"
|
||||||
md-5 = "0.10"
|
serde = "1.0.219"
|
||||||
ctrlc = "3.2"
|
winconsole = "0.11.1"
|
||||||
|
ctrlc = "3.4.5"
|
||||||
shellexpand = "3.1.0"
|
shellexpand = "3.1.0"
|
||||||
winconsole = "0.11"
|
flate2 = "1.1.0"
|
44
README.md
44
README.md
|
@ -5,39 +5,61 @@
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
|
## 📦 Requirements
|
||||||
|
- **Rust nightly toolchain** - 1.87.0-nightly or newer
|
||||||
|
- **Windows** - for full console feature support
|
||||||
|
|
||||||
|
Install the nightly toolchain with:
|
||||||
|
```bash
|
||||||
|
rustup toolchain install nightly
|
||||||
|
rustup default nightly
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🛠️ Installation
|
||||||
|
- **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/wuthering-waves-downloader.git
|
||||||
|
cd wuthering-waves-downloader
|
||||||
|
```
|
||||||
|
- **Build the project:**
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
- **Run the downloader:**
|
||||||
|
```bash
|
||||||
|
cargo run --release # (or run the built executable inside target/release/)
|
||||||
|
```
|
||||||
|
|
||||||
## 🚀 Features
|
## 🚀 Features
|
||||||
|
|
||||||
### 🛠️ Core Functionality
|
### 🛠️ Core Functionality
|
||||||
- **Verified Downloads** - MD5 checksum validation for every file
|
- **Verified Downloads** - MD5 checksum validation for every file
|
||||||
- **Batch Processing** - Downloads all game resources sequentially
|
- **Batch Processing** - Downloads all game resources sequentially
|
||||||
- **Network Resiliency** - Timeout protection (30s/60s) with retry logic
|
- **Network Resiliency** - Timeout protection (30s/60s) with retry logic
|
||||||
|
- **HEAD Request Verification** - Pre-checks file availability before download
|
||||||
|
- **Automatic Retries** - Configurable retry attempts for failed downloads
|
||||||
|
|
||||||
### 📂 File Management
|
### 📂 File Management
|
||||||
- **Smart Path Handling** - Cross-platform path support with tilde (~) expansion
|
- **Smart Path Handling** - Cross-platform path support with tilde (~) expansion
|
||||||
- **Auto-directory Creation** - Builds full directory trees as needed
|
- **Auto-directory Creation** - Builds full directory trees as needed
|
||||||
- **Clean Failed Downloads** - Automatically removes corrupted files
|
- **Clean Failed Downloads** - Automatically removes corrupted files
|
||||||
|
- **Comprehensive Logging** - Detailed error logging with timestamps
|
||||||
|
|
||||||
### 🌈 User Interface
|
### 💻 User Interface
|
||||||
- **Color-coded Output** - Instant visual feedback (success/warning/error)
|
- **Color-coded Output** - Instant visual feedback (success/warning/error)
|
||||||
- **Progress Tracking** - Real-time counters (`[X/Y]`) for batch downloads
|
- **Progress Tracking** - Real-time counters (`[X/Y]`) for batch downloads
|
||||||
- **Interactive Prompts** - Guided directory selection with validation
|
- **Interactive Prompts** - Guided directory selection with validation
|
||||||
|
- **Dynamic Title Updates** - Real-time progress in window title
|
||||||
|
- **Formatted Duration Display** - Clear elapsed time tracking (HH:MM:SS)
|
||||||
|
|
||||||
### ⚡ Performance & Safety
|
### ⚡ Performance & Safety
|
||||||
- **Streaming Downloads** - Chunked transfers for memory efficiency
|
- **Streaming Downloads** - Chunked transfers for memory efficiency
|
||||||
- **Atomic Operations** - Thread-safe progress tracking
|
- **Atomic Operations** - Thread-safe progress tracking
|
||||||
- **Graceful Interrupt** - CTRL-C handling with summary display
|
- **Graceful Interrupt** - CTRL-C handling with summary display
|
||||||
|
- **Memory Efficiency** - Minimal allocations during downloads
|
||||||
|
|
||||||
### 🔒 Reliability
|
### 🔒 Reliability
|
||||||
- **Pre-flight Checks** - HEAD requests verify availability before download
|
- **Pre-flight Checks** - HEAD requests verify availability before download
|
||||||
- **Comprehensive Error Handling** - Network, filesystem, and validation errors
|
- **Comprehensive Error Handling** - Network, filesystem, and validation errors
|
||||||
- **Consistent State** - Never leaves partial downloads on failure
|
- **Consistent State** - Never leaves partial downloads on failure
|
||||||
|
- **Validation Failures** - Auto-removes files with checksum mismatches
|
||||||
## 📦 Requirements
|
|
||||||
|
|
||||||
- Rust nightly toolchain (1.87.0-nightly or newer)
|
|
||||||
- Windows (for full console feature support)
|
|
||||||
|
|
||||||
Install the nightly toolchain with:
|
|
||||||
```bash
|
|
||||||
rustup toolchain install nightly
|
|
||||||
rustup default nightly
|
|
643
src/main.rs
643
src/main.rs
|
@ -1,34 +1,327 @@
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
fs::{self, OpenOptions},
|
||||||
io::{self, Write},
|
io::{self, Read, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
time::Duration,
|
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
use colored::*;
|
use colored::*;
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
use reqwest::{blocking::Client, StatusCode};
|
use reqwest::{StatusCode, blocking::Client};
|
||||||
use serde_json::{Value, from_str};
|
use serde_json::{Value, from_reader, from_str};
|
||||||
use winconsole::console::{clear, set_title};
|
use winconsole::console::{clear, set_title};
|
||||||
|
|
||||||
const INDEX_URL: &str = "https://hw-pcdownload-aws.aki-game.net/launcher/game/G153/2.2.0/onnOqcAkPIKgfEoFdwJcgRzLRNLohWAm/resource/50004/2.2.0/indexFile.json";
|
const INDEX_URL: &str = "https://gist.githubusercontent.com/yuhkix/b8796681ac2cd3bab11b7e8cdc022254/raw/30a8e747debe9e333d5f4ec5d8700dab500594a2/wuwa.json";
|
||||||
const ZIP_BASE: &str = "https://hw-pcdownload-aws.aki-game.net/launcher/game/G153/2.2.0/onnOqcAkPIKgfEoFdwJcgRzLRNLohWAm/zip/";
|
const MAX_RETRIES: usize = 3;
|
||||||
|
const DOWNLOAD_TIMEOUT: u64 = 300; // minutes in seconds
|
||||||
|
|
||||||
struct Status;
|
struct Status;
|
||||||
|
|
||||||
impl Status {
|
impl Status {
|
||||||
fn info() -> ColoredString { "[*]".cyan() }
|
fn info() -> ColoredString {
|
||||||
fn success() -> ColoredString { "[+]".green() }
|
"[*]".cyan()
|
||||||
fn warning() -> ColoredString { "[!]".yellow() }
|
}
|
||||||
fn error() -> ColoredString { "[-]".red() }
|
fn success() -> ColoredString {
|
||||||
fn question() -> ColoredString { "[?]".blue() }
|
"[+]".green()
|
||||||
fn progress() -> ColoredString { "[→]".magenta() }
|
}
|
||||||
|
fn warning() -> ColoredString {
|
||||||
|
"[!]".yellow()
|
||||||
|
}
|
||||||
|
fn error() -> ColoredString {
|
||||||
|
"[-]".red()
|
||||||
|
}
|
||||||
|
fn question() -> ColoredString {
|
||||||
|
"[?]".blue()
|
||||||
|
}
|
||||||
|
fn progress() -> ColoredString {
|
||||||
|
"[→]".magenta()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DownloadConfig {
|
||||||
|
index_url: String,
|
||||||
|
zip_bases: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_logging() -> fs::File {
|
||||||
|
let log_file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open("logs.log")
|
||||||
|
.expect("Failed to create/open log file");
|
||||||
|
|
||||||
|
log_file
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_error(mut log_file: &fs::File, message: &str) {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
writeln!(log_file, "[{}] ERROR: {}", timestamp, message).expect("Failed to write to log file");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_gist(client: &Client) -> Result<String, String> {
|
||||||
|
|
||||||
|
let mut response = client
|
||||||
|
.get(INDEX_URL)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("Network error: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Server error: HTTP {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_encoding = response
|
||||||
|
.headers()
|
||||||
|
.get("content-encoding")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let gist_data: Value = if content_encoding.contains("gzip") {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
response
|
||||||
|
.copy_to(&mut buffer)
|
||||||
|
.map_err(|e| format!("Error reading response bytes: {}", e))?;
|
||||||
|
|
||||||
|
let mut gz = GzDecoder::new(&buffer[..]);
|
||||||
|
let mut decompressed = String::new();
|
||||||
|
gz.read_to_string(&mut decompressed)
|
||||||
|
.map_err(|e| format!("Error decompressing content: {}", e))?;
|
||||||
|
|
||||||
|
from_str(&decompressed).map_err(|e| format!("Invalid JSON: {}", e))?
|
||||||
|
} else {
|
||||||
|
from_reader(response).map_err(|e| format!("Invalid JSON: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{} Available versions:", Status::info());
|
||||||
|
println!("1. Preload - OS");
|
||||||
|
println!("2. Live - CN (Needs Update)");
|
||||||
|
println!("3. Beta - OS (Needs Update)");
|
||||||
|
println!("4. Beta - CN (Needs Update)");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print!("{} Select version to download: ", Status::question());
|
||||||
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input).unwrap();
|
||||||
|
let choice = input.trim();
|
||||||
|
|
||||||
|
match choice {
|
||||||
|
"1" => {
|
||||||
|
return gist_data["live"]["os-live"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or("Missing os-live URL".to_string());
|
||||||
|
}
|
||||||
|
"2" => {
|
||||||
|
return gist_data["live"]["cn-live"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or("Missing cn-live URL".to_string());
|
||||||
|
}
|
||||||
|
"3" => {
|
||||||
|
return gist_data["beta"]["os-beta"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or("Missing os-beta URL".to_string());
|
||||||
|
}
|
||||||
|
"4" => {
|
||||||
|
return gist_data["beta"]["cn-beta"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or("Missing cn-beta URL".to_string());
|
||||||
|
}
|
||||||
|
_ => println!("{} Invalid selection, please try again", Status::error()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_predownload(client: &Client) -> Result<DownloadConfig, String> {
|
||||||
|
let selected_index_url = fetch_gist(client)?;
|
||||||
|
println!("{} Fetching download configuration...", Status::info());
|
||||||
|
|
||||||
|
let mut response = client
|
||||||
|
.get(&selected_index_url)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("Network error: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Server error: HTTP {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_encoding = response
|
||||||
|
.headers()
|
||||||
|
.get("content-encoding")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let config: Value = if content_encoding.contains("gzip") {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
response
|
||||||
|
.copy_to(&mut buffer)
|
||||||
|
.map_err(|e| format!("Error reading response bytes: {}", e))?;
|
||||||
|
|
||||||
|
let mut gz = GzDecoder::new(&buffer[..]);
|
||||||
|
let mut decompressed = String::new();
|
||||||
|
gz.read_to_string(&mut decompressed)
|
||||||
|
.map_err(|e| format!("Error decompressing content: {}", e))?;
|
||||||
|
|
||||||
|
from_str(&decompressed).map_err(|e| format!("Invalid JSON: {}", e))?
|
||||||
|
} else {
|
||||||
|
from_reader(response).map_err(|e| format!("Invalid JSON: {}", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let predownload_config = config
|
||||||
|
.get("predownload")
|
||||||
|
.and_then(|p| p.get("config"))
|
||||||
|
.ok_or("Missing predownload.config in response")?;
|
||||||
|
|
||||||
|
let base_url = predownload_config
|
||||||
|
.get("baseUrl")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("Missing or invalid baseUrl")?;
|
||||||
|
|
||||||
|
let index_file = predownload_config
|
||||||
|
.get("indexFile")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("Missing or invalid indexFile")?;
|
||||||
|
|
||||||
|
let default_config = config
|
||||||
|
.get("default")
|
||||||
|
.ok_or("Missing default config in response")?;
|
||||||
|
|
||||||
|
let cdn_list = default_config
|
||||||
|
.get("cdnList")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or("Missing or invalid cdnList")?;
|
||||||
|
|
||||||
|
let mut cdn_urls = Vec::new();
|
||||||
|
for cdn in cdn_list {
|
||||||
|
if let Some(url) = cdn.get("url").and_then(Value::as_str) {
|
||||||
|
cdn_urls.push(url.trim_end_matches('/').to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cdn_urls.is_empty() {
|
||||||
|
return Err("No valid CDN URLs found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_index_url = format!("{}/{}", cdn_urls[0], index_file.trim_start_matches('/'));
|
||||||
|
let zip_bases = cdn_urls
|
||||||
|
.iter()
|
||||||
|
.map(|cdn| format!("{}/{}", cdn, base_url.trim_start_matches('/')))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(DownloadConfig {
|
||||||
|
index_url: full_index_url,
|
||||||
|
zip_bases,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_index(client: &Client, config: &DownloadConfig, log_file: &fs::File) -> Value {
|
||||||
|
println!("{} Fetching index file...", Status::info());
|
||||||
|
|
||||||
|
let mut response = match client
|
||||||
|
.get(&config.index_url)
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
{
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
log_error(log_file, &format!("Error fetching index file: {}", e));
|
||||||
|
clear().unwrap();
|
||||||
|
println!("{} Error fetching index file: {}", Status::error(), e);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let msg = format!("Error fetching index file: HTTP {}", response.status());
|
||||||
|
log_error(log_file, &msg);
|
||||||
|
clear().unwrap();
|
||||||
|
println!("{} {}", Status::error(), msg);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_encoding = response
|
||||||
|
.headers()
|
||||||
|
.get("content-encoding")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let text = if content_encoding.contains("gzip") {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
if let Err(e) = response.copy_to(&mut buffer) {
|
||||||
|
log_error(log_file, &format!("Error reading index file bytes: {}", e));
|
||||||
|
clear().unwrap();
|
||||||
|
println!("{} Error reading index file: {}", Status::error(), e);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut gz = GzDecoder::new(&buffer[..]);
|
||||||
|
let mut decompressed_text = String::new();
|
||||||
|
if let Err(e) = gz.read_to_string(&mut decompressed_text) {
|
||||||
|
log_error(log_file, &format!("Error decompressing index file: {}", e));
|
||||||
|
clear().unwrap();
|
||||||
|
println!("{} Error decompressing index file: {}", Status::error(), e);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
decompressed_text
|
||||||
|
} else {
|
||||||
|
match response.text() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
log_error(
|
||||||
|
log_file,
|
||||||
|
&format!("Error reading index file response: {}", e),
|
||||||
|
);
|
||||||
|
clear().unwrap();
|
||||||
|
println!("{} Error reading index file: {}", Status::error(), e);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{} Index file downloaded successfully", Status::success());
|
||||||
|
|
||||||
|
match from_str(&text) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log_error(log_file, &format!("Error parsing index file JSON: {}", e));
|
||||||
|
clear().unwrap();
|
||||||
|
println!("{} Error parsing index file: {}", Status::error(), e);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_dir() -> PathBuf {
|
fn get_dir() -> PathBuf {
|
||||||
loop {
|
loop {
|
||||||
print!("{} Enter download directory (Enter for current): ", Status::question());
|
print!(
|
||||||
|
"{} Enter download directory (Enter for current): ",
|
||||||
|
Status::question()
|
||||||
|
);
|
||||||
io::stdout().flush().unwrap();
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
@ -45,7 +338,10 @@ fn get_dir() -> PathBuf {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
print!("{} Directory doesn't exist. Create? (y/n): ", Status::warning());
|
print!(
|
||||||
|
"{} Directory doesn't exist. Create? (y/n): ",
|
||||||
|
Status::warning()
|
||||||
|
);
|
||||||
io::stdout().flush().unwrap();
|
io::stdout().flush().unwrap();
|
||||||
|
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
@ -58,33 +354,6 @@ fn get_dir() -> PathBuf {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_index(client: &Client) -> Value {
|
|
||||||
println!("{} Fetching index file...", Status::info());
|
|
||||||
|
|
||||||
let response = client.get(INDEX_URL)
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.send()
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
clear().unwrap();
|
|
||||||
println!("{} Error fetching index file: {}", Status::error(), e);
|
|
||||||
println!("\n{} Press Enter to exit...", Status::warning());
|
|
||||||
let _ = io::stdin().read_line(&mut String::new());
|
|
||||||
std::process::exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
clear().unwrap();
|
|
||||||
println!("{} Error fetching index file: HTTP {}", Status::error(), response.status());
|
|
||||||
println!("\n{} Press Enter to exit...", Status::warning());
|
|
||||||
let _ = io::stdin().read_line(&mut String::new());
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = response.text().unwrap();
|
|
||||||
println!("{} Index file downloaded successfully", Status::success());
|
|
||||||
from_str(&text).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_md5(path: &Path) -> String {
|
fn calculate_md5(path: &Path) -> String {
|
||||||
let mut file = fs::File::open(path).unwrap();
|
let mut file = fs::File::open(path).unwrap();
|
||||||
let mut hasher = Md5::new();
|
let mut hasher = Md5::new();
|
||||||
|
@ -92,82 +361,212 @@ fn calculate_md5(path: &Path) -> String {
|
||||||
format!("{:x}", hasher.finalize())
|
format!("{:x}", hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn download_file(client: &Client, dest: &str, folder: &Path, expected_md5: Option<&str>) -> bool {
|
fn download_file(
|
||||||
|
client: &Client,
|
||||||
|
config: &DownloadConfig,
|
||||||
|
dest: &str,
|
||||||
|
folder: &Path,
|
||||||
|
expected_md5: Option<&str>,
|
||||||
|
log_file: &fs::File,
|
||||||
|
) -> bool {
|
||||||
let dest = dest.replace('\\', "/");
|
let dest = dest.replace('\\', "/");
|
||||||
let url = format!("{}{}", ZIP_BASE, dest);
|
|
||||||
let path = folder.join(&dest);
|
let path = folder.join(&dest);
|
||||||
|
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).unwrap();
|
if let Err(e) = fs::create_dir_all(parent) {
|
||||||
|
log_error(
|
||||||
|
log_file,
|
||||||
|
&format!("Error creating directory for {}: {}", dest, e),
|
||||||
|
);
|
||||||
|
println!("{} Error creating directory: {}", Status::error(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists first
|
for (i, base_url) in config.zip_bases.iter().enumerate() {
|
||||||
|
let url = format!("{}{}", base_url, dest);
|
||||||
|
|
||||||
let head_response = match client.head(&url).timeout(Duration::from_secs(10)).send() {
|
let head_response = match client.head(&url).timeout(Duration::from_secs(10)).send() {
|
||||||
Ok(resp) => resp,
|
Ok(resp) => resp,
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
println!("{} File not available: {}", Status::warning(), dest.yellow());
|
log_error(
|
||||||
return false;
|
log_file,
|
||||||
|
&format!("CDN {} failed for {} - {}", i + 1, dest, e),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if head_response.status() != StatusCode::OK {
|
if head_response.status() != StatusCode::OK {
|
||||||
println!("{} File not available: {}", Status::warning(), dest.yellow());
|
log_error(
|
||||||
return false;
|
log_file,
|
||||||
|
&format!(
|
||||||
|
"CDN {} failed for {} (HTTP {})",
|
||||||
|
i + 1,
|
||||||
|
dest,
|
||||||
|
head_response.status()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{} Downloading: {}", Status::progress(), dest);
|
println!("{} Downloading: {}", Status::progress(), dest);
|
||||||
|
|
||||||
let response = match client.get(&url)
|
let mut retries = MAX_RETRIES;
|
||||||
.timeout(Duration::from_secs(60))
|
let mut last_error = None;
|
||||||
.send() {
|
|
||||||
Ok(resp) => resp,
|
while retries > 0 {
|
||||||
Err(e) => {
|
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||||
clear().unwrap();
|
let response = client
|
||||||
println!("{} Download failed: {} - {}", Status::error(), dest.red(), e);
|
.get(&url)
|
||||||
return false;
|
.timeout(Duration::from_secs(DOWNLOAD_TIMEOUT))
|
||||||
}
|
.send()?;
|
||||||
};
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
clear().unwrap();
|
return Err(response.error_for_status().unwrap_err().into());
|
||||||
println!("{} Download failed: {} - HTTP {}", Status::error(), dest.red(), response.status());
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut file = fs::File::create(&path).unwrap();
|
let mut file = fs::File::create(&path)?;
|
||||||
let mut content = response;
|
let mut content = response;
|
||||||
io::copy(&mut content, &mut file).unwrap();
|
io::copy(&mut content, &mut file)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(e) => {
|
||||||
|
last_error = Some(e.to_string());
|
||||||
|
log_error(
|
||||||
|
log_file,
|
||||||
|
&format!(
|
||||||
|
"Download attempt failed for {} ({} retries left): {}",
|
||||||
|
dest,
|
||||||
|
retries - 1,
|
||||||
|
e
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
retries -= 1;
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
|
if retries > 0 {
|
||||||
|
println!(
|
||||||
|
"{} Retrying download for {}... ({} attempts left)",
|
||||||
|
Status::warning(),
|
||||||
|
dest,
|
||||||
|
retries
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if retries == 0 {
|
||||||
|
log_error(
|
||||||
|
log_file,
|
||||||
|
&format!(
|
||||||
|
"Download failed after retries for {}: {}",
|
||||||
|
dest,
|
||||||
|
last_error.unwrap_or_default()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
println!("{} Download failed: {}", Status::error(), dest.red());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(expected) = expected_md5 {
|
if let Some(expected) = expected_md5 {
|
||||||
let actual = calculate_md5(&path);
|
let actual = calculate_md5(&path);
|
||||||
if actual != expected {
|
if actual != expected {
|
||||||
|
log_error(
|
||||||
|
log_file,
|
||||||
|
&format!(
|
||||||
|
"Checksum failed for {}: expected {}, got {}",
|
||||||
|
dest, expected, actual
|
||||||
|
),
|
||||||
|
);
|
||||||
fs::remove_file(&path).unwrap();
|
fs::remove_file(&path).unwrap();
|
||||||
println!("{} Checksum failed: {}", Status::error(), dest.red());
|
println!("{} Checksum failed: {}", Status::error(), dest.red());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{} {}: {}",
|
println!(
|
||||||
|
"{} {}: {}",
|
||||||
Status::success(),
|
Status::success(),
|
||||||
if expected_md5.is_some() { "Verified" } else { "Downloaded" },
|
if expected_md5.is_some() {
|
||||||
|
"Verified"
|
||||||
|
} else {
|
||||||
|
"Downloaded"
|
||||||
|
},
|
||||||
dest
|
dest
|
||||||
);
|
);
|
||||||
|
|
||||||
true
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error(log_file, &format!("All CDNs failed for {}", dest));
|
||||||
|
println!("{} All CDNs failed for {}", Status::error(), dest.red());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(duration: Duration) -> String {
|
||||||
|
let secs = duration.as_secs();
|
||||||
|
let hours = secs / 3600;
|
||||||
|
let minutes = (secs % 3600) / 60;
|
||||||
|
let seconds = secs % 60;
|
||||||
|
|
||||||
|
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_title(start_time: Instant, success: usize, total: usize) {
|
||||||
|
let elapsed = start_time.elapsed();
|
||||||
|
let elapsed_str = format_duration(elapsed);
|
||||||
|
let progress = if total > 0 {
|
||||||
|
format!(" ({}%)", (success as f32 / total as f32 * 100.0).round())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = format!(
|
||||||
|
"Wuthering Waves Downloader - Elapsed: {} - {}/{} files{}",
|
||||||
|
elapsed_str, success, total, progress
|
||||||
|
);
|
||||||
|
|
||||||
|
set_title(&title).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
set_title("Wuthering Waves Downloader").unwrap();
|
let log_file = setup_logging();
|
||||||
clear().unwrap();
|
|
||||||
|
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let folder = get_dir();
|
|
||||||
println!("\n{} Download folder: {}\n", Status::info(), folder.display().to_string().cyan());
|
|
||||||
|
|
||||||
let data = fetch_index(&client);
|
let config = match get_predownload(&client) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log_error(&log_file, &e);
|
||||||
|
clear().unwrap();
|
||||||
|
println!("{} {}", Status::error(), e);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let folder = get_dir();
|
||||||
|
clear().unwrap();
|
||||||
|
println!(
|
||||||
|
"\n{} Download folder: {}\n",
|
||||||
|
Status::info(),
|
||||||
|
folder.display().to_string().cyan()
|
||||||
|
);
|
||||||
|
|
||||||
|
clear().unwrap();
|
||||||
|
let data = fetch_index(&client, &config, &log_file);
|
||||||
|
|
||||||
let resources = match data.get("resource").and_then(Value::as_array) {
|
let resources = match data.get("resource").and_then(Value::as_array) {
|
||||||
Some(res) => res,
|
Some(res) => res,
|
||||||
None => {
|
None => {
|
||||||
|
log_error(&log_file, "No resources found in index file");
|
||||||
println!("{} No resources found in index file", Status::warning());
|
println!("{} No resources found in index file", Status::warning());
|
||||||
println!("\n{} Press Enter to exit...", Status::warning());
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
let _ = io::stdin().read_line(&mut String::new());
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
|
@ -175,31 +574,91 @@ fn main() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("{} Found {} files to download\n", Status::info(), resources.len().to_string().cyan());
|
println!(
|
||||||
|
"{} Found {} files to download\n",
|
||||||
|
Status::info(),
|
||||||
|
resources.len().to_string().cyan()
|
||||||
|
);
|
||||||
let success = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
let success = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
let total_files = resources.len();
|
let total_files = resources.len();
|
||||||
let folder_clone = folder.clone();
|
let folder_clone = folder.clone();
|
||||||
|
|
||||||
|
let start_time = Instant::now();
|
||||||
|
|
||||||
let success_clone = success.clone();
|
let success_clone = success.clone();
|
||||||
|
let title_thread = thread::spawn(move || {
|
||||||
|
loop {
|
||||||
|
update_title(
|
||||||
|
start_time,
|
||||||
|
success_clone.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
total_files,
|
||||||
|
);
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let success_clone = success.clone();
|
||||||
|
let log_file_clone = log_file.try_clone().unwrap();
|
||||||
|
|
||||||
ctrlc::set_handler(move || {
|
ctrlc::set_handler(move || {
|
||||||
clear().unwrap();
|
clear().unwrap();
|
||||||
println!("{} Download interrupted by user", Status::warning());
|
println!("{} Download interrupted by user", Status::warning());
|
||||||
let success_count = success_clone.load(std::sync::atomic::Ordering::SeqCst);
|
let success_count = success_clone.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
print_results(success_count, total_files, &folder_clone);
|
|
||||||
std::process::exit(0);
|
|
||||||
}).unwrap();
|
|
||||||
|
|
||||||
for (i, item) in resources.iter().enumerate() {
|
let title = if success_count == total_files {
|
||||||
|
" DOWNLOAD COMPLETE ".on_blue().white().bold()
|
||||||
|
} else {
|
||||||
|
" PARTIAL DOWNLOAD ".on_blue().white().bold()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("\n{}\n", title);
|
||||||
|
println!(
|
||||||
|
"{} Successfully downloaded: {}",
|
||||||
|
Status::success(),
|
||||||
|
success_count.to_string().green()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} Failed downloads: {}",
|
||||||
|
Status::error(),
|
||||||
|
(total_files - success_count).to_string().red()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} Files saved to: {}",
|
||||||
|
Status::info(),
|
||||||
|
folder_clone.display().to_string().cyan()
|
||||||
|
);
|
||||||
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input).unwrap();
|
||||||
|
|
||||||
|
log_error(
|
||||||
|
&log_file_clone,
|
||||||
|
&format!(
|
||||||
|
"Download interrupted by user. Success: {}/{}",
|
||||||
|
success_count, total_files
|
||||||
|
),
|
||||||
|
);
|
||||||
|
std::process::exit(0);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for item in resources.iter() {
|
||||||
if let Some(dest) = item.get("dest").and_then(Value::as_str) {
|
if let Some(dest) = item.get("dest").and_then(Value::as_str) {
|
||||||
print!("{} ", format!("[{}/{}]", i+1, resources.len()).magenta());
|
|
||||||
let md5 = item.get("md5").and_then(Value::as_str);
|
let md5 = item.get("md5").and_then(Value::as_str);
|
||||||
if download_file(&client, dest, &folder, md5) {
|
if download_file(&client, &config, dest, &folder, md5, &log_file) {
|
||||||
success.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
success.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print_results(success.load(std::sync::atomic::Ordering::SeqCst), total_files, &folder);
|
drop(title_thread);
|
||||||
|
|
||||||
|
print_results(
|
||||||
|
success.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
total_files,
|
||||||
|
&folder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_results(success: usize, total: usize, folder: &Path) {
|
fn print_results(success: usize, total: usize, folder: &Path) {
|
||||||
|
@ -210,9 +669,21 @@ fn print_results(success: usize, total: usize, folder: &Path) {
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("\n{}\n", title);
|
println!("\n{}\n", title);
|
||||||
println!("{} Successfully downloaded: {}", Status::success(), success.to_string().green());
|
println!(
|
||||||
println!("{} Failed downloads: {}", Status::error(), (total - success).to_string().red());
|
"{} Successfully downloaded: {}",
|
||||||
println!("{} Files saved to: {}", Status::info(), folder.display().to_string().cyan());
|
Status::success(),
|
||||||
|
success.to_string().green()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} Failed downloads: {}",
|
||||||
|
Status::error(),
|
||||||
|
(total - success).to_string().red()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{} Files saved to: {}",
|
||||||
|
Status::info(),
|
||||||
|
folder.display().to_string().cyan()
|
||||||
|
);
|
||||||
println!("\n{} Press Enter to exit...", Status::warning());
|
println!("\n{} Press Enter to exit...", Status::warning());
|
||||||
let _ = io::stdin().read_line(&mut String::new());
|
let _ = io::stdin().read_line(&mut String::new());
|
||||||
}
|
}
|
Loading…
Reference in a new issue