1use crate::pattern_extraction::{PatternConfig, PatternExtractor};
2use crate::{DownloadCommands, OutputFormat};
3use ngdp_bpsv::{BpsvBuilder, BpsvFieldType, BpsvValue};
4use ngdp_cache::cached_cdn_client::CachedCdnClient;
5use ngdp_cache::cached_ribbit_client::CachedRibbitClient;
6use ribbit_client::Region;
7use std::path::{Path, PathBuf};
8use tact_client::resumable::{DownloadProgress, ResumableDownload, find_resumable_downloads};
9use tact_client::{HttpClient, ProtocolVersion as TactProtocolVersion, Region as TactRegion};
10use tracing::{debug, error, info, warn};
11
12pub async fn handle(
13 cmd: DownloadCommands,
14 _format: OutputFormat,
15) -> Result<(), Box<dyn std::error::Error>> {
16 match cmd {
17 DownloadCommands::Build {
18 product,
19 build,
20 output,
21 region,
22 dry_run,
23 tags,
24 } => {
25 info!(
26 "Build download requested: product={}, build={}, region={}",
27 product, build, region
28 );
29 info!("Output directory: {:?}", output);
30
31 let region = region.parse::<Region>().unwrap_or(Region::US);
33
34 match download_build(&product, &build, &output, region, dry_run, tags).await {
35 Ok(_) => info!("✅ Build download completed successfully!"),
36 Err(e) => {
37 error!("❌ Build download failed: {}", e);
38 return Err(e);
39 }
40 }
41 }
42 DownloadCommands::Files {
43 product,
44 patterns,
45 output,
46 build,
47 dry_run,
48 tags,
49 limit,
50 } => {
51 info!(
52 "File download requested: product={}, patterns={:?}",
53 product, patterns
54 );
55 info!("Output directory: {:?}", output);
56
57 match download_files(&product, &patterns, &output, build, dry_run, tags, limit).await {
58 Ok(_) => info!("✅ File download completed successfully!"),
59 Err(e) => {
60 error!("❌ File download failed: {}", e);
61 return Err(e);
62 }
63 }
64 }
65 DownloadCommands::Resume { session } => {
66 info!("Resuming download: session={}", session);
67
68 match resume_download(&session).await {
69 Ok(_) => info!("✅ Resume download completed successfully!"),
70 Err(e) => {
71 error!("❌ Resume download failed: {}", e);
72 return Err(e);
73 }
74 }
75 }
76 DownloadCommands::TestResume {
77 hash,
78 host,
79 output,
80 resumable,
81 } => {
82 info!(
83 "Testing resumable download: hash={}, host={}, output={:?}, resumable={}",
84 hash, host, output, resumable
85 );
86
87 match test_resumable_download(&hash, &host, &output, resumable).await {
88 Ok(_) => info!("✅ Test download completed successfully!"),
89 Err(e) => {
90 error!("❌ Test download failed: {}", e);
91 return Err(e);
92 }
93 }
94 }
95 }
96 Ok(())
97}
98
99async fn download_build(
101 product: &str,
102 build: &str,
103 output: &Path,
104 region: Region,
105 dry_run: bool,
106 tags: Option<String>,
107) -> Result<(), Box<dyn std::error::Error>> {
108 info!(
109 "📋 Initializing build download for {} build {}",
110 product, build
111 );
112
113 if dry_run {
114 info!("🔍 DRY RUN mode - no files will be downloaded");
115 }
116
117 if let Some(tags) = &tags {
118 info!("🏷️ Filtering by tags: {}", tags);
119 }
120
121 tokio::fs::create_dir_all(output).await?;
123 info!("📁 Created output directory: {:?}", output);
124
125 let ribbit_client = CachedRibbitClient::new(region).await?;
127 let cdn_client = CachedCdnClient::new().await?;
128
129 info!("🌐 Getting product versions from Ribbit...");
130 let versions = ribbit_client.get_product_versions(product).await?;
131
132 let version_entry = if build.is_empty() || build == "latest" {
134 versions
135 .entries
136 .first()
137 .ok_or("No versions available for product")?
138 } else {
139 versions
140 .entries
141 .iter()
142 .find(|v| v.build_id.to_string() == build || v.versions_name == build)
143 .ok_or_else(|| format!("Build '{build}' not found for product '{product}'"))?
144 };
145
146 info!(
147 "📦 Found build: {} ({})",
148 version_entry.versions_name, version_entry.build_id
149 );
150
151 info!("🌐 Getting CDN configuration...");
153 let cdns = ribbit_client.get_product_cdns(product).await?;
154 let cdn_entry = cdns.entries.first().ok_or("No CDN servers available")?;
155
156 let cdn_host = cdn_entry.hosts.first().ok_or("No CDN hosts available")?;
157
158 info!("🔗 Using CDN host: {}", cdn_host);
159
160 info!("⬇️ Downloading BuildConfig...");
162 if dry_run {
163 info!(
164 "🔍 Would download BuildConfig: {}",
165 version_entry.build_config
166 );
167 } else {
168 let build_config_response = cdn_client
169 .download_build_config(cdn_host, &cdn_entry.path, &version_entry.build_config)
170 .await?;
171
172 let build_config_data = build_config_response.bytes().await?;
173
174 let config_dir = output.join("Data/config");
176 tokio::fs::create_dir_all(&config_dir).await?;
177
178 let build_config_hash = &version_entry.build_config;
180 let build_config_subdir =
181 format!("{}/{}", &build_config_hash[0..2], &build_config_hash[2..4]);
182 let build_config_subdir_path = config_dir.join(&build_config_subdir);
183 tokio::fs::create_dir_all(&build_config_subdir_path).await?;
184 let build_config_path = build_config_subdir_path.join(build_config_hash);
185 tokio::fs::write(&build_config_path, &build_config_data).await?;
186 info!(
187 "💾 Saved BuildConfig to: {}/{}",
188 build_config_subdir, build_config_hash
189 );
190
191 let legacy_path = output.join("build_config");
193 tokio::fs::write(&legacy_path, &build_config_data).await?;
194 info!("💾 Saved BuildConfig (legacy) to: {:?}", legacy_path);
195 }
196
197 info!("⬇️ Downloading CDNConfig...");
199 if dry_run {
200 info!("🔍 Would download CDNConfig: {}", version_entry.cdn_config);
201 } else {
202 let cdn_config_response = cdn_client
203 .download_cdn_config(cdn_host, &cdn_entry.path, &version_entry.cdn_config)
204 .await?;
205
206 let cdn_config_data = cdn_config_response.bytes().await?;
207
208 let config_dir = output.join("Data/config");
210 tokio::fs::create_dir_all(&config_dir).await?;
211
212 let cdn_config_hash = &version_entry.cdn_config;
214 let cdn_config_subdir = format!("{}/{}", &cdn_config_hash[0..2], &cdn_config_hash[2..4]);
215 let cdn_config_subdir_path = config_dir.join(&cdn_config_subdir);
216 tokio::fs::create_dir_all(&cdn_config_subdir_path).await?;
217 let cdn_config_path = cdn_config_subdir_path.join(cdn_config_hash);
218 tokio::fs::write(&cdn_config_path, &cdn_config_data).await?;
219 info!(
220 "💾 Saved CDNConfig to: {}/{}",
221 cdn_config_subdir, cdn_config_hash
222 );
223
224 let legacy_path = output.join("cdn_config");
226 tokio::fs::write(&legacy_path, &cdn_config_data).await?;
227 info!("💾 Saved CDNConfig (legacy) to: {:?}", legacy_path);
228 }
229
230 info!("⬇️ Downloading ProductConfig...");
232 if dry_run {
233 info!(
234 "🔍 Would download ProductConfig: {}",
235 version_entry.product_config
236 );
237 } else {
238 let product_config_response = cdn_client
239 .download_product_config(
240 cdn_host,
241 &cdn_entry.config_path,
242 &version_entry.product_config,
243 )
244 .await?;
245
246 let product_config_path = output.join("product_config");
247 tokio::fs::write(&product_config_path, product_config_response.bytes().await?).await?;
248 info!("💾 Saved ProductConfig to: {:?}", product_config_path);
249 }
250
251 if let Some(keyring_hash) = &version_entry.key_ring {
253 info!("⬇️ Downloading KeyRing...");
254 if dry_run {
255 info!("🔍 Would download KeyRing: {}", keyring_hash);
256 } else {
257 let keyring_response = cdn_client
258 .download_key_ring(cdn_host, &cdn_entry.path, keyring_hash)
259 .await?;
260
261 let keyring_path = output.join("keyring");
262 tokio::fs::write(&keyring_path, keyring_response.bytes().await?).await?;
263 info!("💾 Saved KeyRing to: {:?}", keyring_path);
264 }
265 }
266
267 if dry_run {
268 info!("✅ Dry run completed - showed what would be downloaded");
269 } else {
270 info!("📄 Writing .build.info file...");
272
273 let region_enum = match region {
274 Region::US => ribbit_client::Region::US,
275 Region::EU => ribbit_client::Region::EU,
276 Region::KR => ribbit_client::Region::KR,
277 Region::TW => ribbit_client::Region::TW,
278 _ => ribbit_client::Region::US,
279 };
280
281 write_build_info_for_download(
282 output,
283 product,
284 version_entry,
285 &version_entry.build_config,
286 &version_entry.cdn_config,
287 cdn_entry,
288 region_enum,
289 )
290 .await?;
291
292 info!("✓ .build.info file written");
293 info!("✅ Build download completed successfully!");
294 info!("📂 Files saved to: {:?}", output);
295 info!(
296 "💡 Use 'ngdp download resume {}' to continue incomplete installations",
297 output.display()
298 );
299 }
300
301 Ok(())
302}
303
304async fn download_files(
306 product: &str,
307 patterns: &[String],
308 output: &Path,
309 build: Option<String>,
310 dry_run: bool,
311 tags: Option<String>,
312 limit: Option<usize>,
313) -> Result<(), Box<dyn std::error::Error>> {
314 info!(
315 "📋 Initializing pattern-based file download for {} with {} patterns",
316 product,
317 patterns.len()
318 );
319
320 if dry_run {
321 info!("🔍 DRY RUN mode - analyzing patterns and showing matches");
322 }
323
324 if let Some(tags) = &tags {
325 info!("🏷️ Filtering by tags: {}", tags);
326 }
327
328 if let Some(limit) = limit {
329 info!("📊 Limiting to {} files per pattern", limit);
330 }
331
332 tokio::fs::create_dir_all(output).await?;
334 info!("📁 Created output directory: {:?}", output);
335
336 let pattern_config = PatternConfig {
338 max_matches_per_pattern: limit,
339 ..Default::default()
340 };
341
342 let mut extractor = PatternExtractor::with_config(pattern_config);
343
344 for pattern in patterns {
346 match extractor.add_pattern(pattern) {
347 Ok(()) => info!("✅ Added pattern: {}", pattern),
348 Err(e) => {
349 error!("❌ Invalid pattern '{}': {}", pattern, e);
350 return Err(format!("Invalid pattern '{pattern}': {e}").into());
351 }
352 }
353 }
354
355 let stats = extractor.get_stats();
357 info!("📊 Pattern Analysis:");
358 info!(" • Total patterns: {}", stats.total_patterns);
359 info!(" • Glob patterns: {}", stats.glob_patterns);
360 info!(" • Regex patterns: {}", stats.regex_patterns);
361 info!(" • Content keys: {}", stats.content_keys);
362 info!(" • Encoding keys: {}", stats.encoding_keys);
363 info!(" • File paths: {}", stats.file_paths);
364
365 if dry_run {
366 info!("🔍 DRY RUN: Demonstrating pattern matching with sample file list");
368
369 let sample_files = get_sample_file_list();
370 let matches = extractor.match_files(&sample_files);
371
372 if matches.is_empty() {
373 info!("📝 No matches found in sample data");
374 info!("💡 Sample files available for testing:");
375 for (i, file) in sample_files.iter().take(10).enumerate() {
376 info!(" {}: {}", i + 1, file);
377 }
378 } else {
379 info!("🎯 Found {} pattern matches in sample data:", matches.len());
380
381 for (i, pattern_match) in matches.iter().take(20).enumerate() {
382 info!(
383 " {}: {} (pattern: {}, priority: {})",
384 i + 1,
385 pattern_match.file_path,
386 pattern_match.pattern,
387 pattern_match.metadata.priority_score
388 );
389 }
390
391 if matches.len() > 20 {
392 info!(" ... and {} more matches", matches.len() - 20);
393 }
394 }
395
396 info!("✅ Dry run completed - patterns would be applied to real manifest data");
397 return Ok(());
398 }
399
400 let region = Region::US; let ribbit_client = CachedRibbitClient::new(region).await?;
403 let cdn_client = CachedCdnClient::new().await?;
404
405 info!("🌐 Getting product versions from Ribbit...");
406 let versions = ribbit_client.get_product_versions(product).await?;
407
408 let version_entry = if let Some(build_id) = build {
410 versions
411 .entries
412 .iter()
413 .find(|v| v.build_id.to_string() == build_id || v.versions_name == build_id)
414 .ok_or_else(|| format!("Build '{build_id}' not found for product '{product}'"))?
415 } else {
416 versions
417 .entries
418 .first()
419 .ok_or("No versions available for product")?
420 };
421
422 info!(
423 "📦 Found build: {} ({})",
424 version_entry.versions_name, version_entry.build_id
425 );
426
427 info!("🌐 Getting CDN configuration...");
429 let cdns = ribbit_client.get_product_cdns(product).await?;
430 let cdn_entry = cdns.entries.first().ok_or("No CDN servers available")?;
431 let cdn_host = cdn_entry.hosts.first().ok_or("No CDN hosts available")?;
432
433 info!("🔗 Using CDN host: {}", cdn_host);
434
435 info!("⬇️ Downloading BuildConfig...");
437 let build_config_response = cdn_client
438 .download_build_config(cdn_host, &cdn_entry.path, &version_entry.build_config)
439 .await?;
440
441 let build_config_data = build_config_response.bytes().await?;
442
443 let build_config_text = String::from_utf8_lossy(&build_config_data);
445
446 info!("📋 Parsing BuildConfig to extract manifest hashes...");
447 let (encoding_hash, root_hash, install_hash) = parse_build_config_hashes(&build_config_text)?;
448
449 info!("🔑 Found manifest hashes:");
450 info!(" • Encoding: {}", encoding_hash);
451 info!(" • Root: {}", root_hash.as_deref().unwrap_or("None"));
452 info!(" • Install: {}", install_hash.as_deref().unwrap_or("None"));
453
454 info!("🚧 Next steps for full implementation:");
456 info!(" 1. Download and decompress BLTE-encoded encoding file");
457 info!(" 2. Parse encoding file to build CKey → EKey mapping");
458 info!(" 3. Download and decompress root file if available");
459 info!(" 4. Parse root file to build path → CKey mapping");
460 info!(" 5. Apply patterns to real file list from manifest");
461 info!(" 6. Download matched files from CDN data endpoint");
462 info!(" 7. Decompress BLTE data and save with directory structure");
463
464 let mock_file_list = get_comprehensive_file_list();
466 let matches = extractor.match_files(&mock_file_list);
467
468 if matches.is_empty() {
469 warn!("📝 No pattern matches found");
470 return Ok(());
471 }
472
473 info!(
474 "🎯 Pattern matching results: {} files matched",
475 matches.len()
476 );
477
478 for (i, pattern_match) in matches.iter().take(limit.unwrap_or(10)).enumerate() {
480 info!(
481 " {}: {} (pattern: '{}', priority: {})",
482 i + 1,
483 pattern_match.file_path,
484 pattern_match.pattern,
485 pattern_match.metadata.priority_score
486 );
487
488 if let Some(file_type) = &pattern_match.metadata.file_type {
490 debug!(" File type: {}", file_type);
491 }
492 }
493
494 info!("✅ Pattern-based file extraction analysis completed!");
495 info!("💡 Use --dry-run to see pattern matching without attempting downloads");
496
497 warn!(
498 "🚧 Full manifest integration and download implementation pending TACT parser integration"
499 );
500
501 Ok(())
502}
503
504type BuildConfigResult =
505 Result<(String, Option<String>, Option<String>), Box<dyn std::error::Error>>;
506
507fn parse_build_config_hashes(build_config: &str) -> BuildConfigResult {
509 let mut encoding_hash = None;
510 let mut root_hash = None;
511 let mut install_hash = None;
512
513 for line in build_config.lines() {
514 let line = line.trim();
515 if line.starts_with("encoding = ") {
516 encoding_hash = Some(
517 line.split_whitespace()
518 .nth(2)
519 .unwrap_or_default()
520 .to_string(),
521 );
522 } else if line.starts_with("root = ") {
523 root_hash = Some(
524 line.split_whitespace()
525 .nth(2)
526 .unwrap_or_default()
527 .to_string(),
528 );
529 } else if line.starts_with("install = ") {
530 install_hash = Some(
531 line.split_whitespace()
532 .nth(2)
533 .unwrap_or_default()
534 .to_string(),
535 );
536 }
537 }
538
539 let encoding = encoding_hash.ok_or("No encoding hash found in build config")?;
540
541 Ok((encoding, root_hash, install_hash))
542}
543
544fn get_sample_file_list() -> Vec<String> {
546 vec![
547 "achievement.dbc".to_string(),
548 "spell.dbc".to_string(),
549 "item.db2".to_string(),
550 "world/maps/azeroth/azeroth.wdt".to_string(),
551 "interface/framexml/uiparent.lua".to_string(),
552 "interface/addons/blizzard_auctionui/blizzard_auctionui.lua".to_string(),
553 "sound/music/zonemusic/stormwind.ogg".to_string(),
554 "sound/spells/frostbolt.ogg".to_string(),
555 "textures/interface/buttons/ui-button.blp".to_string(),
556 "creature/human/male/humanmale.m2".to_string(),
557 "world/wmo/stormwind/stormwind_keep.wmo".to_string(),
558 ]
559}
560
561fn get_comprehensive_file_list() -> Vec<String> {
563 vec![
564 "achievement.dbc".to_string(),
566 "spell.dbc".to_string(),
567 "item.db2".to_string(),
568 "creature.dbc".to_string(),
569 "gameobject.dbc".to_string(),
570 "interface/framexml/uiparent.lua".to_string(),
572 "interface/framexml/worldframe.lua".to_string(),
573 "interface/framexml/chatframe.lua".to_string(),
574 "interface/addons/blizzard_auctionui/blizzard_auctionui.lua".to_string(),
575 "interface/addons/blizzard_raidui/blizzard_raidui.lua".to_string(),
576 "interface/framexml/uiparent.xml".to_string(),
577 "sound/music/zonemusic/stormwind.ogg".to_string(),
579 "sound/music/zonemusic/ironforge.ogg".to_string(),
580 "sound/spells/frostbolt.ogg".to_string(),
581 "sound/spells/fireball.ogg".to_string(),
582 "sound/creature/human/humanvoicemale01.ogg".to_string(),
583 "textures/interface/buttons/ui-button.blp".to_string(),
585 "textures/interface/icons/spell_frost_frostbolt.blp".to_string(),
586 "textures/world/azeroth/stormwind/stormwind_cobblestone.blp".to_string(),
587 "textures/character/human/male/humanmale_face00_00.blp".to_string(),
588 "creature/human/male/humanmale.m2".to_string(),
590 "creature/orc/male/orcmale.m2".to_string(),
591 "item/weapon/sword/2h_sword_01.m2".to_string(),
592 "world/maps/azeroth/azeroth.wdt".to_string(),
594 "world/maps/azeroth/azeroth_31_49.adt".to_string(),
595 "world/wmo/stormwind/stormwind_keep.wmo".to_string(),
596 "world/wmo/ironforge/ironforge_main.wmo".to_string(),
597 "fonts/frizqt__.ttf".to_string(),
599 "tileset/generic/dirt.blp".to_string(),
600 "character/human/male/humanmale.skin".to_string(),
601 "character/bloodelf/female/bloodelffemale.skin".to_string(),
602 ]
603}
604
605async fn resume_download(session: &str) -> Result<(), Box<dyn std::error::Error>> {
607 let session_path = PathBuf::from(session);
608
609 if session_path.is_dir() {
610 let build_info_path = session_path.join(".build.info");
612 if build_info_path.exists() {
613 info!(
614 "🏗️ Detected installation directory with .build.info: {:?}",
615 session_path
616 );
617 return resume_from_installation(&session_path).await;
618 }
619
620 info!(
622 "🔍 Searching for resumable downloads in: {:?}",
623 session_path
624 );
625 let downloads = find_resumable_downloads(&session_path).await?;
626
627 if downloads.is_empty() {
628 warn!("No resumable downloads found in directory");
629 return Ok(());
630 }
631
632 info!("Found {} resumable download(s):", downloads.len());
633 for (i, progress) in downloads.iter().enumerate() {
634 info!(
635 " {}: {} - {}",
636 i + 1,
637 progress.file_hash,
638 progress.progress_string()
639 );
640 }
641
642 let progress = &downloads[0];
644 info!("Resuming first download: {}", progress.file_hash);
645
646 let client = create_tact_client().await?;
647 let mut resumable_download = ResumableDownload::new(client, progress.clone());
648 resumable_download.start_or_resume().await?;
649 resumable_download.cleanup_completed().await?;
650 } else if session_path.extension().and_then(|s| s.to_str()) == Some("download") {
651 info!("📂 Loading progress from: {:?}", session_path);
653 let progress = DownloadProgress::load_from_file(&session_path).await?;
654
655 info!(
656 "Resuming: {} - {}",
657 progress.file_hash,
658 progress.progress_string()
659 );
660
661 let client = create_tact_client().await?;
662 let mut resumable_download = ResumableDownload::new(client, progress);
663 resumable_download.start_or_resume().await?;
664 resumable_download.cleanup_completed().await?;
665 } else {
666 return Err(format!(
667 "Invalid session path: {session}. Must be a directory, .download file, or installation with .build.info"
668 )
669 .into());
670 }
671
672 Ok(())
673}
674
675async fn resume_from_installation(install_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
677 info!("📋 Reading installation metadata from .build.info...");
678
679 let build_info_path = install_path.join(".build.info");
681 let build_info_content = tokio::fs::read_to_string(&build_info_path).await?;
682
683 let build_info = ngdp_bpsv::BpsvDocument::parse(&build_info_content)?;
685
686 let rows = build_info.rows();
688 if rows.is_empty() {
689 return Err("No entries found in .build.info file".into());
690 }
691
692 let schema = build_info.schema();
693 let row = &rows[0]; let product = row
695 .get_raw_by_name("Product", schema)
696 .ok_or("Product not found in .build.info")?;
697 let version = row
698 .get_raw_by_name("Version", schema)
699 .ok_or("Version not found in .build.info")?;
700 let branch = row
701 .get_raw_by_name("Branch", schema)
702 .ok_or("Branch not found in .build.info")?;
703 let build_key = row
704 .get_raw_by_name("Build Key", schema)
705 .ok_or("Build Key not found in .build.info")?;
706 let cdn_path = row
707 .get_raw_by_name("CDN Path", schema)
708 .ok_or("CDN Path not found in .build.info")?;
709 let cdn_hosts_str = row
710 .get_raw_by_name("CDN Hosts", schema)
711 .ok_or("CDN Hosts not found in .build.info")?;
712
713 let cdn_hosts: Vec<&str> = cdn_hosts_str.split_whitespace().collect();
715 let cdn_host = cdn_hosts.first().ok_or("No CDN hosts available")?;
716
717 info!("📦 Installation details:");
718 info!(" • Product: {}", product);
719 info!(" • Version: {}", version);
720 info!(" • Branch: {}", branch);
721 info!(" • Build Key: {}", build_key);
722 info!(" • CDN Host: {}", cdn_host);
723 info!(" • CDN Path: {}", cdn_path);
724
725 let build_config_subdir = format!("{}/{}", &build_key[0..2], &build_key[2..4]);
727 let build_config_path = install_path
728 .join("Data/config")
729 .join(&build_config_subdir)
730 .join(build_key);
731
732 if !build_config_path.exists() {
733 return Err(format!(
734 "Build configuration not found at: {}. Run metadata-only installation first.",
735 build_config_path.display()
736 )
737 .into());
738 }
739
740 let build_config_data = tokio::fs::read_to_string(&build_config_path).await?;
741 let build_config = tact_parser::config::BuildConfig::parse(&build_config_data)?;
742
743 info!("✓ Loaded build configuration from local cache");
744
745 let cdn_client = CachedCdnClient::new().await?;
747
748 let install_value = build_config
750 .config
751 .get_value("install")
752 .ok_or("Missing install field in build config")?;
753 let install_parts: Vec<&str> = install_value.split_whitespace().collect();
754
755 let install_ekey = if install_parts.len() >= 2 {
757 install_parts[1].to_string()
758 } else {
759 return Err("Install manifest content key lookup not yet implemented for resume. Use direct encoding key.".into());
761 };
762
763 info!(
764 "📥 Resuming installation using install manifest: {}",
765 install_ekey
766 );
767
768 let install_data = cdn_client
770 .download_data(cdn_host, cdn_path, &install_ekey)
771 .await?
772 .bytes()
773 .await?;
774
775 let install_data = if install_data.starts_with(b"BLTE") {
776 blte::decompress_blte(install_data.to_vec(), None)?
777 } else {
778 install_data.to_vec()
779 };
780
781 let install_manifest = tact_parser::install::InstallManifest::parse(&install_data)?;
782
783 info!(
784 "📋 Install manifest loaded: {} files",
785 install_manifest.entries.len()
786 );
787
788 let data_dir = install_path.join("Data/data");
790 tokio::fs::create_dir_all(&data_dir).await?;
791
792 let mut missing_files = Vec::new();
793 let mut total_missing_size = 0u64;
794
795 info!("🔍 Checking for missing files...");
796 for entry in &install_manifest.entries {
797 let ckey_hex = hex::encode(&entry.ckey);
800 let expected_path = data_dir.join(&ckey_hex);
801
802 if !expected_path.exists() {
803 missing_files.push(entry);
804 total_missing_size += entry.size as u64;
805 }
806 }
807
808 if missing_files.is_empty() {
809 info!("✅ No missing files found - installation appears complete!");
810 return Ok(());
811 }
812
813 info!(
814 "📊 Found {} missing files ({} bytes total)",
815 missing_files.len(),
816 format_bytes(total_missing_size)
817 );
818
819 info!("🚧 Resume functionality implementation in progress");
821 info!("📋 Missing files that would be downloaded:");
822 for (i, entry) in missing_files.iter().take(10).enumerate() {
823 info!(" {}: {} ({} bytes)", i + 1, entry.path, entry.size);
824 }
825
826 if missing_files.len() > 10 {
827 info!(" ... and {} more files", missing_files.len() - 10);
828 }
829
830 info!(
831 "💡 Use 'ngdp install game {} --path {} --resume' for full resume functionality",
832 product,
833 install_path.display()
834 );
835
836 Ok(())
837}
838
839fn format_bytes(bytes: u64) -> String {
841 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
842 let mut size = bytes as f64;
843 let mut unit_index = 0;
844
845 while size >= 1024.0 && unit_index < UNITS.len() - 1 {
846 size /= 1024.0;
847 unit_index += 1;
848 }
849
850 format!("{:.2} {}", size, UNITS[unit_index])
851}
852
853async fn test_resumable_download(
855 hash: &str,
856 _host: &str,
857 output: &Path,
858 resumable: bool,
859) -> Result<(), Box<dyn std::error::Error>> {
860 if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
862 return Err("Invalid hash format. Expected 32 hex characters.".into());
863 }
864
865 info!("🚀 Starting test download");
866 info!("📋 Hash: {}", hash);
867 info!("📁 Output: {:?}", output);
868 info!("🔄 Resumable: {}", resumable);
869
870 if resumable {
871 info!("📥 Starting resumable download...");
873
874 let progress = DownloadProgress::new(
875 hash.to_string(),
876 "blzddist1-a.akamaihd.net".to_string(),
877 "/tpr/wow/data".to_string(),
878 output.to_path_buf(),
879 );
880
881 let client = create_tact_client().await?;
882 let mut resumable_download = ResumableDownload::new(client, progress);
883
884 resumable_download.start_or_resume().await?;
885 resumable_download.cleanup_completed().await?;
886 } else {
887 info!("📥 Starting regular CDN download with fallback...");
889
890 let cdn_client = CachedCdnClient::new().await?;
891 cdn_client.add_fallback_host("cdn.arctium.tools");
893 cdn_client.add_fallback_host("tact.mirror.reliquaryhq.com");
894 let response = cdn_client
895 .download_data("cdn.blizzard.com", "/tpr/wow", hash)
896 .await?;
897 let bytes = response.bytes().await?;
898
899 tokio::fs::write(output, bytes).await?;
900 info!("💾 Saved to: {:?}", output);
901 }
902
903 if let Ok(metadata) = tokio::fs::metadata(output).await {
905 info!("📊 Downloaded {} bytes", metadata.len());
906 }
907
908 Ok(())
909}
910
911async fn create_tact_client() -> Result<HttpClient, Box<dyn std::error::Error>> {
913 let client = HttpClient::new(TactRegion::US, TactProtocolVersion::V2)?
914 .with_max_retries(3)
915 .with_initial_backoff_ms(1000)
916 .with_user_agent("ngdp-client/0.3.1");
917
918 Ok(client)
919}
920
921async fn write_build_info_for_download(
923 output_path: &Path,
924 product: &str,
925 version_entry: &ribbit_client::VersionEntry,
926 build_config_hash: &str,
927 cdn_config_hash: &str,
928 cdn_entry: &ribbit_client::CdnEntry,
929 region: ribbit_client::Region,
930) -> Result<(), Box<dyn std::error::Error>> {
931 let install_key = ""; let cdn_hosts = cdn_entry.hosts.join(" ");
937
938 let cdn_servers = if cdn_entry.servers.is_empty() {
940 cdn_entry
942 .hosts
943 .iter()
944 .flat_map(|host| {
945 vec![
946 format!("http://{}/?maxhosts=4", host),
947 format!("https://{}/?maxhosts=4&fallback=1", host),
948 ]
949 })
950 .collect::<Vec<_>>()
951 .join(" ")
952 } else {
953 cdn_entry.servers.join(" ")
954 };
955
956 let tags = format!(
958 "Windows x86_64 {}? acct-{}?",
959 region.as_str().to_uppercase(),
960 region.as_str().to_uppercase()
961 );
962
963 let mut builder = BpsvBuilder::new();
965
966 builder.add_field("Branch", BpsvFieldType::String(0))?;
968 builder.add_field("Active", BpsvFieldType::Decimal(1))?;
969 builder.add_field("Build Key", BpsvFieldType::Hex(16))?;
970 builder.add_field("CDN Key", BpsvFieldType::Hex(16))?;
971 builder.add_field("Install Key", BpsvFieldType::Hex(16))?;
972 builder.add_field("IM Size", BpsvFieldType::Decimal(4))?;
973 builder.add_field("CDN Path", BpsvFieldType::String(0))?;
974 builder.add_field("CDN Hosts", BpsvFieldType::String(0))?;
975 builder.add_field("CDN Servers", BpsvFieldType::String(0))?;
976 builder.add_field("Tags", BpsvFieldType::String(0))?;
977 builder.add_field("Armadillo", BpsvFieldType::String(0))?;
978 builder.add_field("Last Activated", BpsvFieldType::String(0))?;
979 builder.add_field("Version", BpsvFieldType::String(0))?;
980 builder.add_field("KeyRing", BpsvFieldType::Hex(16))?;
981 builder.add_field("Product", BpsvFieldType::String(0))?;
982
983 builder.add_row(vec![
985 BpsvValue::String(region.as_str().to_string()), BpsvValue::Decimal(1), BpsvValue::Hex(build_config_hash.to_string()), BpsvValue::Hex(cdn_config_hash.to_string()), BpsvValue::Hex(install_key.to_string()), BpsvValue::Decimal(0), BpsvValue::String(cdn_entry.path.clone()), BpsvValue::String(cdn_hosts), BpsvValue::String(cdn_servers), BpsvValue::String(tags), BpsvValue::String(String::new()), BpsvValue::String(String::new()), BpsvValue::String(version_entry.versions_name.clone()), BpsvValue::Hex(version_entry.key_ring.as_deref().unwrap_or("").to_string()), BpsvValue::String(product.to_string()), ])?;
1001
1002 let build_info_content = builder.build_string()?;
1004
1005 let build_info_path = output_path.join(".build.info");
1007 tokio::fs::write(&build_info_path, build_info_content).await?;
1008
1009 debug!("Written .build.info to: {}", build_info_path.display());
1010 Ok(())
1011}