1use std::path::{Path, PathBuf};
10use std::time::Instant;
11
12use nzb_core::models::{StageResult, StageStatus};
13use tracing::{debug, error, info, warn};
14
15use crate::detect::{ArchiveType, find_archives, find_cleanup_files, find_par2_files};
16use crate::par2::par2_repair;
17use crate::unpack::{extract_7z, extract_rar, extract_zip};
18
19enum VerifyRepairOutcome {
23 AllCorrect {
24 intact_count: usize,
25 },
26 Damaged {
27 intact: usize,
28 damaged: usize,
29 missing: usize,
30 blocks_needed: u32,
31 blocks_available: u32,
32 repair_result: Result<rust_par2::RepairResult, rust_par2::RepairError>,
33 },
34}
35
36#[derive(Debug)]
38pub struct PostProcResult {
39 pub success: bool,
41 pub stages: Vec<StageResult>,
43 pub error: Option<String>,
45}
46
47#[derive(Debug, Clone)]
49pub struct PostProcConfig {
50 pub cleanup_after_extract: bool,
52 pub output_dir: Option<PathBuf>,
55 pub articles_failed: usize,
60 pub skip_extract: bool,
63 pub password: Option<String>,
65}
66
67impl Default for PostProcConfig {
68 fn default() -> Self {
69 Self {
70 cleanup_after_extract: true,
71 output_dir: None,
72 articles_failed: 0,
73 skip_extract: false,
74 password: None,
75 }
76 }
77}
78
79pub async fn run_pipeline(job_dir: &Path, config: &PostProcConfig) -> PostProcResult {
87 let mut stages: Vec<StageResult> = Vec::new();
88 let mut pipeline_ok = true;
89
90 info!(dir = %job_dir.display(), "Starting post-processing pipeline");
91
92 let par2_files = find_par2_files(job_dir);
104
105 if par2_files.is_empty() {
106 stages.push(StageResult {
107 name: "Verify".to_string(),
108 status: StageStatus::Skipped,
109 message: Some("No par2 files found".to_string()),
110 duration_secs: 0.0,
111 });
112 } else if config.articles_failed == 0 {
113 info!("Skipping PAR2 verification — zero article failures (CRC-verified)");
114 stages.push(StageResult {
115 name: "Verify".to_string(),
116 status: StageStatus::Skipped,
117 message: Some("Skipped — zero article failures".to_string()),
118 duration_secs: 0.0,
119 });
120 } else {
121 let verify_start = Instant::now();
122 let index_par2 = par2_files[0].clone();
123
124 match rust_par2::parse(&index_par2) {
125 Ok(file_set) => {
126 rename_to_par2_names(&file_set, job_dir);
132
133 let dir = job_dir.to_path_buf();
139 let verify_repair_result = tokio::task::spawn_blocking(move || {
140 let verify_result = rust_par2::verify(&file_set, &dir);
141
142 if verify_result.all_correct() {
143 VerifyRepairOutcome::AllCorrect {
144 intact_count: verify_result.intact.len(),
145 }
146 } else {
147 let intact = verify_result.intact.len();
148 let damaged = verify_result.damaged.len();
149 let missing = verify_result.missing.len();
150 let blocks_needed = verify_result.blocks_needed();
151 let blocks_available = verify_result.recovery_blocks_available;
152
153 info!(
154 intact,
155 damaged,
156 missing,
157 blocks_needed,
158 "Native PAR2 verify: damage detected, attempting native repair"
159 );
160
161 info!("Running native PAR2 repair (with pre-computed verify)");
163 let repair_result =
164 rust_par2::repair_from_verify(&file_set, &dir, &verify_result);
165
166 VerifyRepairOutcome::Damaged {
167 intact,
168 damaged,
169 missing,
170 blocks_needed,
171 blocks_available,
172 repair_result,
173 }
174 }
175 })
176 .await;
177
178 let verify_duration = verify_start.elapsed().as_secs_f64();
179
180 match verify_repair_result {
181 Ok(VerifyRepairOutcome::AllCorrect { intact_count }) => {
182 info!(
183 files = intact_count,
184 duration_secs = verify_duration,
185 "Native PAR2 verify: all files correct"
186 );
187 stages.push(StageResult {
188 name: "Verify".to_string(),
189 status: StageStatus::Success,
190 message: Some(format!(
191 "All {intact_count} files correct (native verify, {verify_duration:.3}s)",
192 )),
193 duration_secs: verify_duration,
194 });
195 }
196 Ok(VerifyRepairOutcome::Damaged {
197 intact,
198 damaged,
199 missing,
200 blocks_needed,
201 blocks_available,
202 repair_result,
203 }) => {
204 stages.push(StageResult {
206 name: "Verify".to_string(),
207 status: StageStatus::Success,
208 message: Some(format!(
209 "{intact} intact, {damaged} damaged, {missing} missing — {blocks_needed} blocks needed (native verify)",
210 )),
211 duration_secs: verify_duration,
212 });
213
214 match repair_result {
216 Ok(result) => {
217 info!(
218 blocks_repaired = result.blocks_repaired,
219 files_repaired = result.files_repaired,
220 "Native PAR2 repair complete"
221 );
222 if !result.success {
223 pipeline_ok = false;
224 }
225 stages.push(StageResult {
226 name: "Repair".to_string(),
227 status: if result.success {
228 StageStatus::Success
229 } else {
230 StageStatus::Failed
231 },
232 message: Some(result.message),
233 duration_secs: verify_duration,
234 });
235 }
236 Err(e) => {
237 error!(
238 error = %e,
239 blocks_needed,
240 blocks_available,
241 damaged,
242 missing,
243 "Native PAR2 repair failed"
244 );
245 pipeline_ok = false;
246 stages.push(StageResult {
247 name: "Repair".to_string(),
248 status: StageStatus::Failed,
249 message: Some(format!("Repair failed: {e}")),
250 duration_secs: verify_duration,
251 });
252 }
253 }
254 }
255 Err(e) => {
256 error!(error = %e, "Verify/repair task panicked");
257 pipeline_ok = false;
258 stages.push(StageResult {
259 name: "Verify".to_string(),
260 status: StageStatus::Failed,
261 message: Some(format!("Verify task panicked: {e}")),
262 duration_secs: verify_duration,
263 });
264 }
265 }
266 }
267 Err(e) => {
268 debug!(error = %e, "Native PAR2 parse failed");
270 let verify_duration = verify_start.elapsed().as_secs_f64();
271
272 if config.articles_failed == 0 {
273 stages.push(StageResult {
275 name: "Verify".to_string(),
276 status: StageStatus::Skipped,
277 message: Some(format!(
278 "PAR2 parse failed ({e}), but zero article failures"
279 )),
280 duration_secs: verify_duration,
281 });
282 } else {
283 stages.push(StageResult {
285 name: "Verify".to_string(),
286 status: StageStatus::Skipped,
287 message: Some(format!("PAR2 parse failed ({e}), attempting repair")),
288 duration_secs: verify_duration,
289 });
290
291 let repair_result = run_repair_stage(job_dir).await;
292 if repair_result.status == StageStatus::Failed {
293 pipeline_ok = false;
294 }
295 stages.push(repair_result);
296 }
297 }
298 }
299 }
300
301 let should_extract = pipeline_ok || config.articles_failed <= 5;
308 if config.skip_extract {
309 info!("Skipping extraction — completed by direct unpack");
310 stages.push(StageResult {
311 name: "Extract".to_string(),
312 status: StageStatus::Skipped,
313 message: Some("Skipped — completed by direct unpack".to_string()),
314 duration_secs: 0.0,
315 });
316 } else if should_extract {
317 let output_dir = config.output_dir.as_deref().unwrap_or(job_dir);
318 let result = run_extract_stage(job_dir, output_dir, config.password.as_deref()).await;
319 if result.status == StageStatus::Failed {
320 pipeline_ok = false;
321 } else if result.status == StageStatus::Success {
322 pipeline_ok = true;
324 }
325 stages.push(result);
326 }
327
328 if pipeline_ok && config.cleanup_after_extract {
332 let result = run_cleanup_stage(job_dir);
333 stages.push(result);
334 }
335
336 let error = if pipeline_ok {
337 None
338 } else {
339 let msgs: Vec<String> = stages
341 .iter()
342 .filter(|s| s.status == StageStatus::Failed)
343 .filter_map(|s| s.message.clone())
344 .collect();
345 Some(msgs.join("; "))
346 };
347
348 info!(
349 success = pipeline_ok,
350 stages = stages.len(),
351 "Post-processing pipeline finished"
352 );
353
354 PostProcResult {
355 success: pipeline_ok,
356 stages,
357 error,
358 }
359}
360
361fn rename_to_par2_names(file_set: &rust_par2::Par2FileSet, dir: &Path) {
375 let mut expected: std::collections::HashMap<[u8; 16], &str> = std::collections::HashMap::new();
377 for par2_file in file_set.files.values() {
378 expected.insert(par2_file.hash_16k, &par2_file.filename);
379 }
380
381 let any_match = file_set
383 .files
384 .values()
385 .any(|f| dir.join(&f.filename).exists());
386 if any_match {
387 return;
388 }
389
390 let entries: Vec<_> = match std::fs::read_dir(dir) {
392 Ok(rd) => rd.filter_map(|e| e.ok()).collect(),
393 Err(_) => return,
394 };
395
396 let mut renamed = 0u32;
397 for entry in &entries {
398 let path = entry.path();
399 if !path.is_file() {
400 continue;
401 }
402 let current_name = match path.file_name().and_then(|n| n.to_str()) {
403 Some(n) => n.to_string(),
404 None => continue,
405 };
406
407 if current_name.to_lowercase().ends_with(".par2") {
409 continue;
410 }
411
412 let hash = match rust_par2::compute_hash_16k(&path) {
413 Ok(h) => h,
414 Err(_) => continue,
415 };
416
417 if let Some(&par2_name) = expected.get(&hash)
418 && current_name != par2_name
419 {
420 let new_path = dir.join(par2_name);
421 if !new_path.exists() {
422 if let Err(e) = std::fs::rename(&path, &new_path) {
423 warn!(
424 from = %current_name,
425 to = %par2_name,
426 "Failed to rename file to PAR2 expected name: {e}"
427 );
428 } else {
429 renamed += 1;
430 debug!(
431 from = %current_name,
432 to = %par2_name,
433 "Renamed file to match PAR2 metadata"
434 );
435 }
436 }
437 }
438 }
439
440 if renamed > 0 {
441 info!(
442 renamed,
443 "PAR2-guided deobfuscation: renamed files to match PAR2 expected names"
444 );
445 }
446}
447
448async fn run_repair_stage(job_dir: &Path) -> StageResult {
455 let start = Instant::now();
456 let par2_files = find_par2_files(job_dir);
457
458 if par2_files.is_empty() {
459 return StageResult {
460 name: "Repair".to_string(),
461 status: StageStatus::Skipped,
462 message: Some("No par2 files found".to_string()),
463 duration_secs: start.elapsed().as_secs_f64(),
464 };
465 }
466
467 let index_par2 = &par2_files[0];
468 info!(file = %index_par2.display(), "Running native par2 repair");
469
470 match par2_repair(index_par2).await {
471 Ok(result) => {
472 let status = if result.repaired || result.success {
473 StageStatus::Success
474 } else {
475 StageStatus::Failed
476 };
477
478 StageResult {
479 name: "Repair".to_string(),
480 status,
481 message: Some(result.message),
482 duration_secs: start.elapsed().as_secs_f64(),
483 }
484 }
485 Err(e) => {
486 error!(error = %e, "par2 repair failed with error");
487 StageResult {
488 name: "Repair".to_string(),
489 status: StageStatus::Failed,
490 message: Some(format!("par2 repair error: {e}")),
491 duration_secs: start.elapsed().as_secs_f64(),
492 }
493 }
494 }
495}
496
497async fn run_extract_stage(
498 job_dir: &Path,
499 output_dir: &Path,
500 password: Option<&str>,
501) -> StageResult {
502 let start = Instant::now();
503 let archives = find_archives(job_dir);
504
505 if archives.is_empty() {
506 info!("No archives found — skipping extraction");
507 return StageResult {
508 name: "Extract".to_string(),
509 status: StageStatus::Skipped,
510 message: Some("No archives found".to_string()),
511 duration_secs: start.elapsed().as_secs_f64(),
512 };
513 }
514
515 let mut all_ok = true;
516 let mut messages: Vec<String> = Vec::new();
517
518 for (archive_type, path) in &archives {
519 info!(kind = %archive_type, file = %path.display(), "Extracting archive");
520
521 let result = match archive_type {
522 ArchiveType::Rar => extract_rar(path, output_dir, password).await,
523 ArchiveType::SevenZip => extract_7z(path, output_dir, password).await,
524 ArchiveType::Zip => extract_zip(path, output_dir).await,
525 };
526
527 match result {
528 Ok(unpack_result) => {
529 if unpack_result.success {
530 messages.push(format!("{archive_type}: OK"));
531 } else {
532 all_ok = false;
533 warn!(kind = %archive_type, file = %path.display(), "Extraction reported failure");
534 messages.push(format!("{archive_type}: failed"));
535 }
536 }
537 Err(e) => {
538 all_ok = false;
539 error!(kind = %archive_type, file = %path.display(), error = %e, "Extraction error");
540 messages.push(format!("{archive_type}: {e}"));
541 }
542 }
543 }
544
545 StageResult {
546 name: "Extract".to_string(),
547 status: if all_ok {
548 StageStatus::Success
549 } else {
550 StageStatus::Failed
551 },
552 message: Some(messages.join("; ")),
553 duration_secs: start.elapsed().as_secs_f64(),
554 }
555}
556
557fn run_cleanup_stage(job_dir: &Path) -> StageResult {
558 let start = Instant::now();
559 let files = find_cleanup_files(job_dir);
560
561 if files.is_empty() {
562 return StageResult {
563 name: "Cleanup".to_string(),
564 status: StageStatus::Skipped,
565 message: Some("No files to clean up".to_string()),
566 duration_secs: start.elapsed().as_secs_f64(),
567 };
568 }
569
570 let mut removed = 0u32;
571 let mut errors = 0u32;
572
573 for path in &files {
574 match std::fs::remove_file(path) {
575 Ok(()) => {
576 removed += 1;
577 }
578 Err(e) => {
579 warn!(file = %path.display(), error = %e, "Failed to remove cleanup file");
580 errors += 1;
581 }
582 }
583 }
584
585 let status = if errors == 0 {
586 StageStatus::Success
587 } else {
588 StageStatus::Failed
589 };
590
591 StageResult {
592 name: "Cleanup".to_string(),
593 status,
594 message: Some(format!("Removed {removed} files, {errors} errors")),
595 duration_secs: start.elapsed().as_secs_f64(),
596 }
597}
598
599#[cfg(test)]
604mod tests {
605 use super::*;
606 use std::fs;
607
608 fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
609 let dir = tempfile::tempdir().unwrap();
610 for name in files {
611 let path = dir.path().join(name);
612 if let Some(parent) = path.parent() {
613 fs::create_dir_all(parent).unwrap();
614 }
615 fs::write(&path, b"").unwrap();
616 }
617 dir
618 }
619
620 #[test]
621 fn test_post_proc_result_default() {
622 let result = PostProcResult {
623 success: true,
624 stages: vec![],
625 error: None,
626 };
627 assert!(result.success);
628 assert!(result.stages.is_empty());
629 assert!(result.error.is_none());
630 }
631
632 #[test]
633 fn test_config_default() {
634 let config = PostProcConfig::default();
635 assert!(config.cleanup_after_extract);
636 assert!(config.output_dir.is_none());
637 assert_eq!(config.articles_failed, 0);
638 }
639
640 #[tokio::test]
641 async fn test_pipeline_no_files() {
642 let dir = make_test_dir(&[]);
644 let config = PostProcConfig::default();
645 let result = run_pipeline(dir.path(), &config).await;
646
647 assert!(result.success, "Pipeline should succeed for empty dir");
648
649 let verify_stage = result.stages.iter().find(|s| s.name == "Verify");
651 assert!(verify_stage.is_some(), "Verify stage should be present");
652 assert_eq!(verify_stage.unwrap().status, StageStatus::Skipped);
653
654 let extract_stage = result.stages.iter().find(|s| s.name == "Extract");
655 assert!(extract_stage.is_some(), "Extract stage should be present");
656 assert_eq!(extract_stage.unwrap().status, StageStatus::Skipped);
657 }
658
659 #[tokio::test]
660 async fn test_pipeline_only_text_files() {
661 let dir = make_test_dir(&["readme.txt", "info.nfo"]);
662 let config = PostProcConfig::default();
663 let result = run_pipeline(dir.path(), &config).await;
664
665 assert!(result.success);
666 for stage in &result.stages {
668 assert_eq!(
669 stage.status,
670 StageStatus::Skipped,
671 "Stage '{}' should be skipped",
672 stage.name
673 );
674 }
675 }
676
677 #[test]
678 fn test_cleanup_removes_files() {
679 let dir = make_test_dir(&[
680 "movie.par2",
681 "movie.vol00+01.par2",
682 "movie.rar",
683 "movie.r00",
684 "movie.mkv", ]);
686
687 let result = run_cleanup_stage(dir.path());
688 assert_eq!(result.status, StageStatus::Success);
689
690 assert!(dir.path().join("movie.mkv").exists());
692 assert!(!dir.path().join("movie.par2").exists());
694 assert!(!dir.path().join("movie.vol00+01.par2").exists());
695 assert!(!dir.path().join("movie.rar").exists());
696 assert!(!dir.path().join("movie.r00").exists());
697 }
698
699 #[tokio::test]
700 async fn test_pipeline_stage_order() {
701 let dir = make_test_dir(&[]);
704 let config = PostProcConfig {
705 cleanup_after_extract: false,
706 ..Default::default()
707 };
708 let result = run_pipeline(dir.path(), &config).await;
709
710 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
712 assert!(stage_names.contains(&"Verify"), "Should have Verify stage");
713 assert!(
714 stage_names.contains(&"Extract"),
715 "Should have Extract stage"
716 );
717
718 let verify_idx = stage_names.iter().position(|&n| n == "Verify").unwrap();
720 let extract_idx = stage_names.iter().position(|&n| n == "Extract").unwrap();
721 assert!(
722 verify_idx < extract_idx,
723 "Verify ({verify_idx}) should come before Extract ({extract_idx})"
724 );
725 }
726
727 #[tokio::test]
728 async fn test_pipeline_skips_verify_with_zero_failures() {
729 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
732 let config = PostProcConfig {
733 cleanup_after_extract: false,
734 ..Default::default()
735 };
736 let result = run_pipeline(dir.path(), &config).await;
737 assert!(result.success);
738
739 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
740 assert_eq!(
741 verify_stage.status,
742 StageStatus::Skipped,
743 "Verify should be skipped when articles_failed == 0"
744 );
745 assert!(
746 verify_stage
747 .message
748 .as_deref()
749 .unwrap_or("")
750 .contains("zero article failures"),
751 "Skip message should indicate zero failures"
752 );
753 }
754
755 #[tokio::test]
756 async fn test_pipeline_no_par2_files_skips_regardless() {
757 let dir = make_test_dir(&["movie.mkv"]);
759 let config = PostProcConfig {
760 cleanup_after_extract: false,
761 articles_failed: 5,
762 ..Default::default()
763 };
764 let result = run_pipeline(dir.path(), &config).await;
765 assert!(result.success);
766
767 let verify_stage = result.stages.iter().find(|s| s.name == "Verify").unwrap();
768 assert_eq!(
769 verify_stage.status,
770 StageStatus::Skipped,
771 "Verify should be skipped when no par2 files exist"
772 );
773 assert!(
774 verify_stage
775 .message
776 .as_deref()
777 .unwrap_or("")
778 .contains("No par2 files"),
779 "Skip message should indicate no par2 files"
780 );
781 }
782
783 #[tokio::test]
784 async fn test_pipeline_runs_verify_then_repair_when_failures() {
785 let dir = make_test_dir(&["movie.par2", "movie.vol00+01.par2", "movie.mkv"]);
789 let config = PostProcConfig {
790 cleanup_after_extract: false,
791 articles_failed: 3,
792 ..Default::default()
793 };
794 let result = run_pipeline(dir.path(), &config).await;
795
796 let stage_names: Vec<&str> = result.stages.iter().map(|s| s.name.as_str()).collect();
798 assert!(
799 stage_names.contains(&"Verify"),
800 "Should have Verify stage (native par2), got: {stage_names:?}"
801 );
802 assert!(
805 stage_names.contains(&"Repair"),
806 "Should have Repair stage when articles_failed > 0, got: {stage_names:?}"
807 );
808 }
809
810 fn make_par2_file_set(tmp: &Path, files: &[(&str, &[u8])]) -> rust_par2::Par2FileSet {
817 use rust_par2::{Par2File, Par2FileSet};
818 let mut map = std::collections::HashMap::new();
819 for (i, (name, content)) in files.iter().enumerate() {
820 let tmp_path = tmp.join(format!("_par2_tmp_{i}"));
822 fs::write(&tmp_path, content).unwrap();
823 let hash_16k = rust_par2::compute_hash_16k(&tmp_path).unwrap();
824 let _ = fs::remove_file(&tmp_path);
825
826 let file_id = [i as u8; 16];
827 map.insert(
828 file_id,
829 Par2File {
830 file_id,
831 hash: [0u8; 16],
832 hash_16k,
833 size: content.len() as u64,
834 filename: name.to_string(),
835 slices: vec![],
836 },
837 );
838 }
839 Par2FileSet {
840 recovery_set_id: [0u8; 16],
841 slice_size: 16384,
842 files: map,
843 recovery_block_count: 0,
844 creator: None,
845 }
846 }
847
848 #[test]
849 fn test_rename_to_par2_names_renames_mismatched() {
850 let dir = tempfile::tempdir().unwrap();
851
852 let content_a = b"AAAA test data for part01";
854 let content_b = b"BBBB test data for part02";
855 fs::write(dir.path().join("Movie.Name.part01.rar"), content_a).unwrap();
856 fs::write(dir.path().join("Movie.Name.part02.rar"), content_b).unwrap();
857
858 let file_set = make_par2_file_set(
860 dir.path(),
861 &[
862 ("xY7kQ3.part01.rar", content_a),
863 ("xY7kQ3.part02.rar", content_b),
864 ],
865 );
866
867 rename_to_par2_names(&file_set, dir.path());
868
869 assert!(
871 dir.path().join("xY7kQ3.part01.rar").exists(),
872 "part01 should be renamed to obfuscated name"
873 );
874 assert!(
875 dir.path().join("xY7kQ3.part02.rar").exists(),
876 "part02 should be renamed to obfuscated name"
877 );
878 assert!(
879 !dir.path().join("Movie.Name.part01.rar").exists(),
880 "old readable name should no longer exist"
881 );
882 assert!(
883 !dir.path().join("Movie.Name.part02.rar").exists(),
884 "old readable name should no longer exist"
885 );
886 }
887
888 #[test]
889 fn test_rename_to_par2_names_skips_when_already_correct() {
890 let dir = tempfile::tempdir().unwrap();
891
892 let content = b"test data already correct";
894 fs::write(dir.path().join("xY7kQ3.part01.rar"), content).unwrap();
895
896 let file_set = make_par2_file_set(dir.path(), &[("xY7kQ3.part01.rar", content)]);
897
898 rename_to_par2_names(&file_set, dir.path());
899
900 assert!(dir.path().join("xY7kQ3.part01.rar").exists());
902 }
903
904 #[test]
905 fn test_rename_to_par2_names_skips_par2_files() {
906 let dir = tempfile::tempdir().unwrap();
907
908 let content = b"par2 file content";
910 fs::write(dir.path().join("Movie.Name.par2"), content).unwrap();
911 fs::write(dir.path().join("Movie.Name.part01.rar"), b"rar data").unwrap();
912
913 let file_set = make_par2_file_set(dir.path(), &[("obfuscated.par2", content)]);
914
915 rename_to_par2_names(&file_set, dir.path());
916
917 assert!(
919 dir.path().join("Movie.Name.par2").exists(),
920 "PAR2 files should be skipped"
921 );
922 }
923
924 #[test]
925 fn test_rename_to_par2_names_no_match() {
926 let dir = tempfile::tempdir().unwrap();
927
928 fs::write(dir.path().join("Movie.Name.part01.rar"), b"unrelated data").unwrap();
930
931 let file_set = make_par2_file_set(
932 dir.path(),
933 &[("xY7kQ3.part01.rar", b"different data" as &[u8])],
934 );
935
936 rename_to_par2_names(&file_set, dir.path());
937
938 assert!(dir.path().join("Movie.Name.part01.rar").exists());
940 assert!(!dir.path().join("xY7kQ3.part01.rar").exists());
941 }
942}