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