Skip to main content

nzb_postproc/
detect.rs

1//! File detection helpers for post-processing.
2//!
3//! Scans a completed download directory to find par2 files, RAR archives,
4//! 7z archives, ZIP archives, and cleanup candidates.
5
6use std::path::{Path, PathBuf};
7
8use walkdir::WalkDir;
9
10/// Parsed RAR volume information: set name and volume number.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RarVolumeInfo {
13    /// The base name of the RAR set (e.g. "movie" from "movie.part003.rar").
14    pub set_name: String,
15    /// Zero-based volume number. For new-style `.partNNN.rar`, this is NNN-1.
16    /// For old-style, `.rar` = 0, `.r00` = 1, `.r01` = 2, etc.
17    pub volume_number: u32,
18}
19
20/// Parse a filename to extract RAR set name and volume number.
21///
22/// Returns `None` if the file isn't a recognizable RAR volume.
23///
24/// Handles:
25///   - New-style: `"movie.part001.rar"` → `("movie", 0)`, `"movie.part002.rar"` → `("movie", 1)`
26///   - Old-style: `"movie.rar"` → `("movie", 0)`, `"movie.r00"` → `("movie", 1)`, `"movie.r01"` → `("movie", 2)`
27pub fn parse_rar_volume(filename: &str) -> Option<RarVolumeInfo> {
28    let name_lower = filename.to_lowercase();
29
30    // New-style: .partNNN.rar
31    if let Some(stem) = name_lower.strip_suffix(".rar") {
32        if let Some(dot_pos) = stem.rfind(".part") {
33            let part_num_str = &stem[dot_pos + 5..];
34            if !part_num_str.is_empty()
35                && part_num_str.chars().all(|c| c.is_ascii_digit())
36                && let Ok(part_num) = part_num_str.parse::<u32>()
37            {
38                // Use the original filename's casing for set_name
39                let set_name = &filename[..dot_pos];
40                return Some(RarVolumeInfo {
41                    set_name: set_name.to_string(),
42                    volume_number: part_num.saturating_sub(1),
43                });
44            }
45        }
46        // Plain .rar — first volume in old-style set
47        let set_name = &filename[..filename.len() - 4];
48        return Some(RarVolumeInfo {
49            set_name: set_name.to_string(),
50            volume_number: 0,
51        });
52    }
53
54    // Old-style continuation: .r00, .r01, ..., .s00, etc.
55    if name_lower.len() > 4 {
56        let last4 = &name_lower[name_lower.len() - 4..];
57        if last4.starts_with('.')
58            && last4.as_bytes()[1].is_ascii_lowercase()
59            && last4.as_bytes()[2].is_ascii_digit()
60            && last4.as_bytes()[3].is_ascii_digit()
61        {
62            let letter = last4.as_bytes()[1];
63            let tens = (last4.as_bytes()[2] - b'0') as u32;
64            let ones = (last4.as_bytes()[3] - b'0') as u32;
65            // .r00 = volume 1, .r01 = volume 2, ..., .r99 = volume 100
66            // .s00 = volume 101, .s01 = volume 102, etc.
67            let letter_offset = (letter - b'r') as u32 * 100;
68            let vol = letter_offset + tens * 10 + ones + 1;
69            let set_name = &filename[..filename.len() - 4];
70            return Some(RarVolumeInfo {
71                set_name: set_name.to_string(),
72                volume_number: vol,
73            });
74        }
75    }
76
77    None
78}
79
80/// The type of archive detected.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum ArchiveType {
83    Rar,
84    SevenZip,
85    Zip,
86}
87
88impl std::fmt::Display for ArchiveType {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::Rar => write!(f, "RAR"),
92            Self::SevenZip => write!(f, "7z"),
93            Self::Zip => write!(f, "ZIP"),
94        }
95    }
96}
97
98/// Find all `.par2` files in a directory. The index par2 file (without
99/// `.volNNN+NNN.par2` or `.volNNN-NNN.par2` suffix) is returned first so callers can use it
100/// as the primary verification target.
101pub fn find_par2_files(dir: &Path) -> Vec<PathBuf> {
102    let mut index_files: Vec<PathBuf> = Vec::new();
103    let mut volume_files: Vec<PathBuf> = Vec::new();
104
105    for entry in WalkDir::new(dir).into_iter().flatten() {
106        let path = entry.path();
107        if !path.is_file() {
108            continue;
109        }
110        let name = match path.file_name().and_then(|n| n.to_str()) {
111            Some(n) => n.to_lowercase(),
112            None => continue,
113        };
114        if !name.ends_with(".par2") {
115            continue;
116        }
117        // Index par2 files do NOT contain ".vol" before ".par2"
118        if is_par2_volume(&name) {
119            volume_files.push(path.to_path_buf());
120        } else {
121            index_files.push(path.to_path_buf());
122        }
123    }
124
125    index_files.sort();
126    volume_files.sort();
127
128    // Index files first, then volumes
129    index_files.extend(volume_files);
130    index_files
131}
132
133/// Returns true if a filename looks like a par2 volume file (e.g.
134/// `foo.vol00+01.par2` or `foo.vol00-01.par2`) rather than the index file.
135fn is_par2_volume(name_lower: &str) -> bool {
136    // Typical patterns: .vol000+000.par2 and .vol000-000.par2.
137    // We check for ".vol" anywhere before the final ".par2"
138    let without_ext = name_lower.trim_end_matches(".par2");
139    // Look for ".vol" followed by digits, a '+', and more digits
140    if let Some(vol_pos) = without_ext.rfind(".vol") {
141        let after_vol = &without_ext[vol_pos + 4..];
142        // Check pattern: digits + '+' + digits
143        if let Some(separator_pos) = after_vol.find(['+', '-']) {
144            let before_separator = &after_vol[..separator_pos];
145            let after_separator = &after_vol[separator_pos + 1..];
146            return !before_separator.is_empty()
147                && before_separator.chars().all(|c| c.is_ascii_digit())
148                && !after_separator.is_empty()
149                && after_separator.chars().all(|c| c.is_ascii_digit());
150        }
151    }
152    false
153}
154
155/// Find the first RAR volume(s) in a directory. Handles both old-style naming
156/// (.rar, .r00, .r01, ...) and new-style (.part001.rar, .part002.rar, ...).
157///
158/// Returns only the *first* volume of each archive set (the one you pass to
159/// `unrar x`).
160pub fn find_rar_files(dir: &Path) -> Vec<PathBuf> {
161    let mut first_volumes: Vec<PathBuf> = Vec::new();
162
163    for entry in WalkDir::new(dir).into_iter().flatten() {
164        let path = entry.path();
165        if !path.is_file() {
166            continue;
167        }
168        let name = match path.file_name().and_then(|n| n.to_str()) {
169            Some(n) => n,
170            None => continue,
171        };
172        let name_lower = name.to_lowercase();
173
174        // New-style: .part001.rar is the first volume
175        if name_lower.ends_with(".rar")
176            && let Some(stem) = name_lower.strip_suffix(".rar")
177        {
178            // Check for .partNNN pattern
179            if let Some(dot_pos) = stem.rfind(".part") {
180                let part_num_str = &stem[dot_pos + 5..];
181                if !part_num_str.is_empty()
182                    && part_num_str.chars().all(|c| c.is_ascii_digit())
183                    && let Ok(part_num) = part_num_str.parse::<u32>()
184                {
185                    if part_num == 1 {
186                        first_volumes.push(path.to_path_buf());
187                    }
188                    // part > 1 is not a first volume
189                    continue;
190                }
191            }
192            // Plain .rar with no .partNNN — this is the first volume in old-style
193            first_volumes.push(path.to_path_buf());
194        }
195        // Old-style: .r00, .r01, etc. — we do NOT add these; the .rar file
196        // is the first volume in old-style sets.
197    }
198
199    first_volumes.sort();
200    first_volumes
201}
202
203/// Detect all archives in a directory. Returns (ArchiveType, path) pairs.
204/// For multi-volume RAR sets, only the first volume is returned.
205pub fn find_archives(dir: &Path) -> Vec<(ArchiveType, PathBuf)> {
206    let mut archives: Vec<(ArchiveType, PathBuf)> = Vec::new();
207
208    // RAR first volumes
209    for path in find_rar_files(dir) {
210        archives.push((ArchiveType::Rar, path));
211    }
212
213    // 7z (including split volumes), and ZIP
214    for entry in WalkDir::new(dir).into_iter().flatten() {
215        let path = entry.path();
216        if !path.is_file() {
217            continue;
218        }
219        let name = match path.file_name().and_then(|n| n.to_str()) {
220            Some(n) => n.to_lowercase(),
221            None => continue,
222        };
223
224        if name.ends_with(".7z") || name.ends_with(".7z.enc") {
225            archives.push((ArchiveType::SevenZip, path.to_path_buf()));
226        } else if is_split_7z_first_volume(&name) {
227            // Split 7z: .7z.001 is the first volume — 7z handles the rest
228            archives.push((ArchiveType::SevenZip, path.to_path_buf()));
229        } else if name.ends_with(".zip") {
230            archives.push((ArchiveType::Zip, path.to_path_buf()));
231        }
232    }
233
234    archives.sort_by(|a, b| a.1.cmp(&b.1));
235    archives
236}
237
238/// Find files that are safe to delete after successful extraction.
239/// This includes par2 files, RAR volumes (old-style and new-style), and
240/// other recovery/split files.
241pub fn find_cleanup_files(dir: &Path) -> Vec<PathBuf> {
242    let mut cleanup: Vec<PathBuf> = Vec::new();
243
244    for entry in WalkDir::new(dir).into_iter().flatten() {
245        let path = entry.path();
246        if !path.is_file() {
247            continue;
248        }
249        let name = match path.file_name().and_then(|n| n.to_str()) {
250            Some(n) => n.to_lowercase(),
251            None => continue,
252        };
253
254        if is_cleanup_candidate(&name) {
255            cleanup.push(path.to_path_buf());
256        }
257    }
258
259    cleanup.sort();
260    cleanup
261}
262
263/// Returns whether a completed output directory contains at least one file
264/// that is not an archive or PAR2 recovery artifact. This is deliberately a
265/// conservative final-status check: a job made solely of raw recovery and
266/// archive files is not a usable completed download.
267pub fn has_usable_output(dir: &Path) -> std::io::Result<bool> {
268    for entry in WalkDir::new(dir).into_iter().flatten() {
269        let path = entry.path();
270        if !path.is_file() {
271            continue;
272        }
273        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
274            continue;
275        };
276        if !is_cleanup_candidate(&name.to_ascii_lowercase()) {
277            return Ok(true);
278        }
279    }
280
281    // Distinguish an empty, readable output directory from a missing one.
282    std::fs::read_dir(dir)?;
283    Ok(false)
284}
285
286/// Returns true if a lowercased filename is the first volume of a split 7z
287/// archive (e.g., `archive.7z.001`).
288fn is_split_7z_first_volume(name_lower: &str) -> bool {
289    // Pattern: anything.7z.001
290    if let Some(stem) = name_lower.strip_suffix(".001") {
291        return stem.ends_with(".7z");
292    }
293    false
294}
295
296/// Returns true if a lowercased filename is any volume of a split 7z archive
297/// (e.g., `.7z.001`, `.7z.002`, ...).
298fn is_split_7z_volume(name_lower: &str) -> bool {
299    // Pattern: anything.7z.NNN where NNN is digits
300    if let Some(dot_pos) = name_lower.rfind('.') {
301        let ext = &name_lower[dot_pos + 1..];
302        if !ext.is_empty() && ext.chars().all(|c| c.is_ascii_digit()) {
303            let stem = &name_lower[..dot_pos];
304            return stem.ends_with(".7z");
305        }
306    }
307    false
308}
309
310/// Determine whether a file (by its lowercased name) is safe to clean up
311/// after successful extraction.
312fn is_cleanup_candidate(name: &str) -> bool {
313    // Par2 files: .par2
314    if name.ends_with(".par2") || name.ends_with(".zip") || name.ends_with(".7z") {
315        return true;
316    }
317
318    // RAR volumes (new-style): .part001.rar, .part002.rar, ...
319    // and plain .rar files
320    if name.ends_with(".rar") {
321        return true;
322    }
323
324    // Old-style RAR split volumes: .r00, .r01, ..., .r99, .s00, ...
325    // Pattern: ends with .rNN or .sNN (or any letter + two digits)
326    if name.len() > 4 {
327        let last4 = &name[name.len() - 4..];
328        if last4.starts_with('.')
329            && last4.as_bytes()[1].is_ascii_lowercase()
330            && last4.as_bytes()[2].is_ascii_digit()
331            && last4.as_bytes()[3].is_ascii_digit()
332        {
333            return true;
334        }
335    }
336
337    // Extended old-style volumes beyond .r99: .s00, .t00, etc. are caught above.
338    // Also handle three-digit extensions: .part001.rar already covered.
339
340    // Split 7z volumes: .7z.001, .7z.002, etc.
341    if is_split_7z_volume(name) {
342        return true;
343    }
344
345    // Encrypted 7z archives: .7z.enc
346    if name.ends_with(".7z.enc") {
347        return true;
348    }
349
350    false
351}
352
353// ---------------------------------------------------------------------------
354// Tests
355// ---------------------------------------------------------------------------
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use std::fs;
361
362    /// Create a temporary directory with the given filenames (empty files).
363    fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
364        let dir = tempfile::tempdir().unwrap();
365        for name in files {
366            let path = dir.path().join(name);
367            if let Some(parent) = path.parent() {
368                fs::create_dir_all(parent).unwrap();
369            }
370            fs::write(&path, b"").unwrap();
371        }
372        dir
373    }
374
375    #[test]
376    fn test_find_par2_index_first() {
377        let dir = make_test_dir(&["movie.vol00+01.par2", "movie.vol01+02.par2", "movie.par2"]);
378        let results = find_par2_files(dir.path());
379        assert_eq!(results.len(), 3);
380        // Index file should come first
381        assert!(
382            results[0].file_name().unwrap().to_str().unwrap() == "movie.par2",
383            "Index par2 file should be first, got {:?}",
384            results[0]
385        );
386    }
387
388    #[test]
389    fn test_find_par2_hyphenated_volumes_after_index() {
390        let dir = make_test_dir(&["movie.vol63-67.par2", "movie.par2", "movie.vol00-01.par2"]);
391        let files = find_par2_files(dir.path());
392        assert_eq!(files[0].file_name().unwrap(), "movie.par2");
393        assert_eq!(files.len(), 3);
394    }
395
396    #[test]
397    fn test_find_par2_empty_dir() {
398        let dir = make_test_dir(&["readme.txt", "movie.mkv"]);
399        let results = find_par2_files(dir.path());
400        assert!(results.is_empty());
401    }
402
403    #[test]
404    fn test_find_rar_new_style() {
405        let dir = make_test_dir(&[
406            "archive.part001.rar",
407            "archive.part002.rar",
408            "archive.part003.rar",
409        ]);
410        let results = find_rar_files(dir.path());
411        // Only the first volume should be returned
412        assert_eq!(results.len(), 1);
413        assert!(
414            results[0]
415                .file_name()
416                .unwrap()
417                .to_str()
418                .unwrap()
419                .contains("part001"),
420        );
421    }
422
423    #[test]
424    fn test_find_rar_old_style() {
425        let dir = make_test_dir(&["archive.rar", "archive.r00", "archive.r01", "archive.r02"]);
426        let results = find_rar_files(dir.path());
427        // Only .rar (the first volume) should be returned
428        assert_eq!(results.len(), 1);
429        assert!(
430            results[0]
431                .file_name()
432                .unwrap()
433                .to_str()
434                .unwrap()
435                .ends_with(".rar")
436        );
437    }
438
439    #[test]
440    fn test_find_archives_mixed() {
441        let dir = make_test_dir(&[
442            "movie.part001.rar",
443            "movie.part002.rar",
444            "subs.zip",
445            "extras.7z",
446        ]);
447        let results = find_archives(dir.path());
448        let types: Vec<ArchiveType> = results.iter().map(|(t, _)| *t).collect();
449        assert!(types.contains(&ArchiveType::Rar));
450        assert!(types.contains(&ArchiveType::Zip));
451        assert!(types.contains(&ArchiveType::SevenZip));
452        // RAR should only have 1 entry (first volume)
453        assert_eq!(types.iter().filter(|&&t| t == ArchiveType::Rar).count(), 1);
454    }
455
456    #[test]
457    fn usable_output_requires_a_non_artifact_file() {
458        let raw_only = make_test_dir(&[
459            "release.part001.rar",
460            "release.part002.rar",
461            "release.par2",
462            "release.vol00+01.par2",
463            "release.7z",
464        ]);
465        assert!(!has_usable_output(raw_only.path()).unwrap());
466
467        let payload = make_test_dir(&["release.part001.rar", "Movie.2024.mkv"]);
468        assert!(has_usable_output(payload.path()).unwrap());
469    }
470
471    #[test]
472    fn test_find_cleanup_files() {
473        let dir = make_test_dir(&[
474            "movie.par2",
475            "movie.vol00+01.par2",
476            "movie.part001.rar",
477            "movie.part002.rar",
478            "movie.r00",
479            "movie.r01",
480            "movie.mkv",  // should NOT be cleaned up
481            "readme.txt", // should NOT be cleaned up
482        ]);
483        let results = find_cleanup_files(dir.path());
484        // par2 (2) + rar (2) + r00 + r01 = 6
485        assert_eq!(
486            results.len(),
487            6,
488            "Expected 6 cleanup files, got: {results:?}"
489        );
490        // .mkv and .txt should NOT be present
491        for path in &results {
492            let name = path.file_name().unwrap().to_str().unwrap();
493            assert!(!name.ends_with(".mkv"));
494            assert!(!name.ends_with(".txt"));
495        }
496    }
497
498    #[test]
499    fn test_is_par2_volume() {
500        assert!(is_par2_volume("file.vol00+01.par2"));
501        assert!(is_par2_volume("file.vol123+456.par2"));
502        assert!(is_par2_volume("file.vol00-01.par2"));
503        assert!(is_par2_volume("file.vol63-67.par2"));
504        assert!(!is_par2_volume("file.par2"));
505        assert!(!is_par2_volume("file.volume.par2"));
506    }
507
508    #[test]
509    fn test_cleanup_old_style_volumes() {
510        assert!(is_cleanup_candidate("archive.r00"));
511        assert!(is_cleanup_candidate("archive.r99"));
512        assert!(is_cleanup_candidate("archive.s00"));
513        assert!(!is_cleanup_candidate("readme.txt"));
514        assert!(!is_cleanup_candidate("movie.mkv"));
515    }
516
517    // -----------------------------------------------------------------------
518    // Split 7z archive detection
519    // -----------------------------------------------------------------------
520
521    #[test]
522    fn test_split_7z_first_volume() {
523        assert!(is_split_7z_first_volume("archive.7z.001"));
524        assert!(is_split_7z_first_volume("my.movie.7z.001"));
525        assert!(!is_split_7z_first_volume("archive.7z.002"));
526        assert!(!is_split_7z_first_volume("archive.7z.010"));
527        assert!(!is_split_7z_first_volume("archive.7z"));
528        assert!(!is_split_7z_first_volume("archive.rar.001"));
529    }
530
531    #[test]
532    fn test_split_7z_volume() {
533        assert!(is_split_7z_volume("archive.7z.001"));
534        assert!(is_split_7z_volume("archive.7z.002"));
535        assert!(is_split_7z_volume("archive.7z.099"));
536        assert!(!is_split_7z_volume("archive.7z"));
537        assert!(!is_split_7z_volume("archive.rar.001"));
538        assert!(!is_split_7z_volume("archive.7z.abc"));
539    }
540
541    #[test]
542    fn test_find_archives_split_7z() {
543        let dir = make_test_dir(&["movie.7z.001", "movie.7z.002", "movie.7z.003"]);
544        let results = find_archives(dir.path());
545        // Only the first volume (.001) should be returned
546        assert_eq!(results.len(), 1);
547        assert_eq!(results[0].0, ArchiveType::SevenZip);
548        assert!(
549            results[0]
550                .1
551                .file_name()
552                .unwrap()
553                .to_str()
554                .unwrap()
555                .ends_with(".7z.001"),
556        );
557    }
558
559    #[test]
560    fn test_cleanup_split_7z_volumes() {
561        assert!(is_cleanup_candidate("archive.7z"));
562        assert!(is_cleanup_candidate("archive.7z.001"));
563        assert!(is_cleanup_candidate("archive.7z.002"));
564        assert!(is_cleanup_candidate("archive.7z.099"));
565        assert!(!is_cleanup_candidate("movie.mkv"));
566    }
567
568    #[test]
569    fn test_find_cleanup_includes_split_7z() {
570        let dir = make_test_dir(&[
571            "movie.7z.001",
572            "movie.7z.002",
573            "movie.7z.003",
574            "movie.mkv", // should NOT be cleaned up
575        ]);
576        let results = find_cleanup_files(dir.path());
577        assert_eq!(
578            results.len(),
579            3,
580            "Expected 3 split 7z cleanup files, got: {results:?}"
581        );
582        for path in &results {
583            let name = path.file_name().unwrap().to_str().unwrap();
584            assert!(!name.ends_with(".mkv"));
585        }
586    }
587
588    // -----------------------------------------------------------------------
589    // RAR volume filename parser
590    // -----------------------------------------------------------------------
591
592    #[test]
593    fn test_parse_rar_volume_new_style() {
594        let v = parse_rar_volume("movie.part001.rar").unwrap();
595        assert_eq!(v.set_name, "movie");
596        assert_eq!(v.volume_number, 0);
597
598        let v = parse_rar_volume("movie.part002.rar").unwrap();
599        assert_eq!(v.set_name, "movie");
600        assert_eq!(v.volume_number, 1);
601
602        let v = parse_rar_volume("My.Movie.2024.part015.rar").unwrap();
603        assert_eq!(v.set_name, "My.Movie.2024");
604        assert_eq!(v.volume_number, 14);
605    }
606
607    #[test]
608    fn test_parse_rar_volume_old_style() {
609        let v = parse_rar_volume("archive.rar").unwrap();
610        assert_eq!(v.set_name, "archive");
611        assert_eq!(v.volume_number, 0);
612
613        let v = parse_rar_volume("archive.r00").unwrap();
614        assert_eq!(v.set_name, "archive");
615        assert_eq!(v.volume_number, 1);
616
617        let v = parse_rar_volume("archive.r01").unwrap();
618        assert_eq!(v.set_name, "archive");
619        assert_eq!(v.volume_number, 2);
620
621        let v = parse_rar_volume("archive.r99").unwrap();
622        assert_eq!(v.set_name, "archive");
623        assert_eq!(v.volume_number, 100);
624
625        let v = parse_rar_volume("archive.s00").unwrap();
626        assert_eq!(v.set_name, "archive");
627        assert_eq!(v.volume_number, 101);
628    }
629
630    #[test]
631    fn test_parse_rar_volume_non_rar() {
632        assert!(parse_rar_volume("movie.mkv").is_none());
633        assert!(parse_rar_volume("movie.par2").is_none());
634        assert!(parse_rar_volume("movie.7z").is_none());
635        assert!(parse_rar_volume("movie.zip").is_none());
636        assert!(parse_rar_volume("readme.txt").is_none());
637    }
638
639    #[test]
640    fn test_parse_rar_volume_case_insensitive() {
641        let v = parse_rar_volume("Movie.Part003.RAR").unwrap();
642        assert_eq!(v.set_name, "Movie");
643        assert_eq!(v.volume_number, 2);
644
645        let v = parse_rar_volume("ARCHIVE.R05").unwrap();
646        assert_eq!(v.set_name, "ARCHIVE");
647        assert_eq!(v.volume_number, 6);
648    }
649
650    #[test]
651    fn test_parse_rar_volume_preserves_original_set_name() {
652        let v = parse_rar_volume("My.Movie.2024.1080p.part001.rar").unwrap();
653        assert_eq!(v.set_name, "My.Movie.2024.1080p");
654    }
655}