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` 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`) rather than the index file (`foo.par2`).
135fn is_par2_volume(name_lower: &str) -> bool {
136    // Typical pattern: .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(plus_pos) = after_vol.find('+') {
144            let before_plus = &after_vol[..plus_pos];
145            let after_plus = &after_vol[plus_pos + 1..];
146            return !before_plus.is_empty()
147                && before_plus.chars().all(|c| c.is_ascii_digit())
148                && !after_plus.is_empty()
149                && after_plus.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 true if a lowercased filename is the first volume of a split 7z
264/// archive (e.g., `archive.7z.001`).
265fn is_split_7z_first_volume(name_lower: &str) -> bool {
266    // Pattern: anything.7z.001
267    if let Some(stem) = name_lower.strip_suffix(".001") {
268        return stem.ends_with(".7z");
269    }
270    false
271}
272
273/// Returns true if a lowercased filename is any volume of a split 7z archive
274/// (e.g., `.7z.001`, `.7z.002`, ...).
275fn is_split_7z_volume(name_lower: &str) -> bool {
276    // Pattern: anything.7z.NNN where NNN is digits
277    if let Some(dot_pos) = name_lower.rfind('.') {
278        let ext = &name_lower[dot_pos + 1..];
279        if !ext.is_empty() && ext.chars().all(|c| c.is_ascii_digit()) {
280            let stem = &name_lower[..dot_pos];
281            return stem.ends_with(".7z");
282        }
283    }
284    false
285}
286
287/// Determine whether a file (by its lowercased name) is safe to clean up
288/// after successful extraction.
289fn is_cleanup_candidate(name: &str) -> bool {
290    // Par2 files: .par2
291    if name.ends_with(".par2") {
292        return true;
293    }
294
295    // RAR volumes (new-style): .part001.rar, .part002.rar, ...
296    // and plain .rar files
297    if name.ends_with(".rar") {
298        return true;
299    }
300
301    // Old-style RAR split volumes: .r00, .r01, ..., .r99, .s00, ...
302    // Pattern: ends with .rNN or .sNN (or any letter + two digits)
303    if name.len() > 4 {
304        let last4 = &name[name.len() - 4..];
305        if last4.starts_with('.')
306            && last4.as_bytes()[1].is_ascii_lowercase()
307            && last4.as_bytes()[2].is_ascii_digit()
308            && last4.as_bytes()[3].is_ascii_digit()
309        {
310            return true;
311        }
312    }
313
314    // Extended old-style volumes beyond .r99: .s00, .t00, etc. are caught above.
315    // Also handle three-digit extensions: .part001.rar already covered.
316
317    // Split 7z volumes: .7z.001, .7z.002, etc.
318    if is_split_7z_volume(name) {
319        return true;
320    }
321
322    // Encrypted 7z archives: .7z.enc
323    if name.ends_with(".7z.enc") {
324        return true;
325    }
326
327    false
328}
329
330// ---------------------------------------------------------------------------
331// Tests
332// ---------------------------------------------------------------------------
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use std::fs;
338
339    /// Create a temporary directory with the given filenames (empty files).
340    fn make_test_dir(files: &[&str]) -> tempfile::TempDir {
341        let dir = tempfile::tempdir().unwrap();
342        for name in files {
343            let path = dir.path().join(name);
344            if let Some(parent) = path.parent() {
345                fs::create_dir_all(parent).unwrap();
346            }
347            fs::write(&path, b"").unwrap();
348        }
349        dir
350    }
351
352    #[test]
353    fn test_find_par2_index_first() {
354        let dir = make_test_dir(&["movie.vol00+01.par2", "movie.vol01+02.par2", "movie.par2"]);
355        let results = find_par2_files(dir.path());
356        assert_eq!(results.len(), 3);
357        // Index file should come first
358        assert!(
359            results[0].file_name().unwrap().to_str().unwrap() == "movie.par2",
360            "Index par2 file should be first, got {:?}",
361            results[0]
362        );
363    }
364
365    #[test]
366    fn test_find_par2_empty_dir() {
367        let dir = make_test_dir(&["readme.txt", "movie.mkv"]);
368        let results = find_par2_files(dir.path());
369        assert!(results.is_empty());
370    }
371
372    #[test]
373    fn test_find_rar_new_style() {
374        let dir = make_test_dir(&[
375            "archive.part001.rar",
376            "archive.part002.rar",
377            "archive.part003.rar",
378        ]);
379        let results = find_rar_files(dir.path());
380        // Only the first volume should be returned
381        assert_eq!(results.len(), 1);
382        assert!(
383            results[0]
384                .file_name()
385                .unwrap()
386                .to_str()
387                .unwrap()
388                .contains("part001"),
389        );
390    }
391
392    #[test]
393    fn test_find_rar_old_style() {
394        let dir = make_test_dir(&["archive.rar", "archive.r00", "archive.r01", "archive.r02"]);
395        let results = find_rar_files(dir.path());
396        // Only .rar (the first volume) should be returned
397        assert_eq!(results.len(), 1);
398        assert!(
399            results[0]
400                .file_name()
401                .unwrap()
402                .to_str()
403                .unwrap()
404                .ends_with(".rar")
405        );
406    }
407
408    #[test]
409    fn test_find_archives_mixed() {
410        let dir = make_test_dir(&[
411            "movie.part001.rar",
412            "movie.part002.rar",
413            "subs.zip",
414            "extras.7z",
415        ]);
416        let results = find_archives(dir.path());
417        let types: Vec<ArchiveType> = results.iter().map(|(t, _)| *t).collect();
418        assert!(types.contains(&ArchiveType::Rar));
419        assert!(types.contains(&ArchiveType::Zip));
420        assert!(types.contains(&ArchiveType::SevenZip));
421        // RAR should only have 1 entry (first volume)
422        assert_eq!(types.iter().filter(|&&t| t == ArchiveType::Rar).count(), 1);
423    }
424
425    #[test]
426    fn test_find_cleanup_files() {
427        let dir = make_test_dir(&[
428            "movie.par2",
429            "movie.vol00+01.par2",
430            "movie.part001.rar",
431            "movie.part002.rar",
432            "movie.r00",
433            "movie.r01",
434            "movie.mkv",  // should NOT be cleaned up
435            "readme.txt", // should NOT be cleaned up
436        ]);
437        let results = find_cleanup_files(dir.path());
438        // par2 (2) + rar (2) + r00 + r01 = 6
439        assert_eq!(
440            results.len(),
441            6,
442            "Expected 6 cleanup files, got: {:?}",
443            results
444        );
445        // .mkv and .txt should NOT be present
446        for path in &results {
447            let name = path.file_name().unwrap().to_str().unwrap();
448            assert!(!name.ends_with(".mkv"));
449            assert!(!name.ends_with(".txt"));
450        }
451    }
452
453    #[test]
454    fn test_is_par2_volume() {
455        assert!(is_par2_volume("file.vol00+01.par2"));
456        assert!(is_par2_volume("file.vol123+456.par2"));
457        assert!(!is_par2_volume("file.par2"));
458        assert!(!is_par2_volume("file.volume.par2"));
459    }
460
461    #[test]
462    fn test_cleanup_old_style_volumes() {
463        assert!(is_cleanup_candidate("archive.r00"));
464        assert!(is_cleanup_candidate("archive.r99"));
465        assert!(is_cleanup_candidate("archive.s00"));
466        assert!(!is_cleanup_candidate("readme.txt"));
467        assert!(!is_cleanup_candidate("movie.mkv"));
468    }
469
470    // -----------------------------------------------------------------------
471    // Split 7z archive detection
472    // -----------------------------------------------------------------------
473
474    #[test]
475    fn test_split_7z_first_volume() {
476        assert!(is_split_7z_first_volume("archive.7z.001"));
477        assert!(is_split_7z_first_volume("my.movie.7z.001"));
478        assert!(!is_split_7z_first_volume("archive.7z.002"));
479        assert!(!is_split_7z_first_volume("archive.7z.010"));
480        assert!(!is_split_7z_first_volume("archive.7z"));
481        assert!(!is_split_7z_first_volume("archive.rar.001"));
482    }
483
484    #[test]
485    fn test_split_7z_volume() {
486        assert!(is_split_7z_volume("archive.7z.001"));
487        assert!(is_split_7z_volume("archive.7z.002"));
488        assert!(is_split_7z_volume("archive.7z.099"));
489        assert!(!is_split_7z_volume("archive.7z"));
490        assert!(!is_split_7z_volume("archive.rar.001"));
491        assert!(!is_split_7z_volume("archive.7z.abc"));
492    }
493
494    #[test]
495    fn test_find_archives_split_7z() {
496        let dir = make_test_dir(&["movie.7z.001", "movie.7z.002", "movie.7z.003"]);
497        let results = find_archives(dir.path());
498        // Only the first volume (.001) should be returned
499        assert_eq!(results.len(), 1);
500        assert_eq!(results[0].0, ArchiveType::SevenZip);
501        assert!(
502            results[0]
503                .1
504                .file_name()
505                .unwrap()
506                .to_str()
507                .unwrap()
508                .ends_with(".7z.001"),
509        );
510    }
511
512    #[test]
513    fn test_cleanup_split_7z_volumes() {
514        assert!(is_cleanup_candidate("archive.7z.001"));
515        assert!(is_cleanup_candidate("archive.7z.002"));
516        assert!(is_cleanup_candidate("archive.7z.099"));
517        assert!(!is_cleanup_candidate("movie.mkv"));
518    }
519
520    #[test]
521    fn test_find_cleanup_includes_split_7z() {
522        let dir = make_test_dir(&[
523            "movie.7z.001",
524            "movie.7z.002",
525            "movie.7z.003",
526            "movie.mkv", // should NOT be cleaned up
527        ]);
528        let results = find_cleanup_files(dir.path());
529        assert_eq!(
530            results.len(),
531            3,
532            "Expected 3 split 7z cleanup files, got: {:?}",
533            results
534        );
535        for path in &results {
536            let name = path.file_name().unwrap().to_str().unwrap();
537            assert!(!name.ends_with(".mkv"));
538        }
539    }
540
541    // -----------------------------------------------------------------------
542    // RAR volume filename parser
543    // -----------------------------------------------------------------------
544
545    #[test]
546    fn test_parse_rar_volume_new_style() {
547        let v = parse_rar_volume("movie.part001.rar").unwrap();
548        assert_eq!(v.set_name, "movie");
549        assert_eq!(v.volume_number, 0);
550
551        let v = parse_rar_volume("movie.part002.rar").unwrap();
552        assert_eq!(v.set_name, "movie");
553        assert_eq!(v.volume_number, 1);
554
555        let v = parse_rar_volume("My.Movie.2024.part015.rar").unwrap();
556        assert_eq!(v.set_name, "My.Movie.2024");
557        assert_eq!(v.volume_number, 14);
558    }
559
560    #[test]
561    fn test_parse_rar_volume_old_style() {
562        let v = parse_rar_volume("archive.rar").unwrap();
563        assert_eq!(v.set_name, "archive");
564        assert_eq!(v.volume_number, 0);
565
566        let v = parse_rar_volume("archive.r00").unwrap();
567        assert_eq!(v.set_name, "archive");
568        assert_eq!(v.volume_number, 1);
569
570        let v = parse_rar_volume("archive.r01").unwrap();
571        assert_eq!(v.set_name, "archive");
572        assert_eq!(v.volume_number, 2);
573
574        let v = parse_rar_volume("archive.r99").unwrap();
575        assert_eq!(v.set_name, "archive");
576        assert_eq!(v.volume_number, 100);
577
578        let v = parse_rar_volume("archive.s00").unwrap();
579        assert_eq!(v.set_name, "archive");
580        assert_eq!(v.volume_number, 101);
581    }
582
583    #[test]
584    fn test_parse_rar_volume_non_rar() {
585        assert!(parse_rar_volume("movie.mkv").is_none());
586        assert!(parse_rar_volume("movie.par2").is_none());
587        assert!(parse_rar_volume("movie.7z").is_none());
588        assert!(parse_rar_volume("movie.zip").is_none());
589        assert!(parse_rar_volume("readme.txt").is_none());
590    }
591
592    #[test]
593    fn test_parse_rar_volume_case_insensitive() {
594        let v = parse_rar_volume("Movie.Part003.RAR").unwrap();
595        assert_eq!(v.set_name, "Movie");
596        assert_eq!(v.volume_number, 2);
597
598        let v = parse_rar_volume("ARCHIVE.R05").unwrap();
599        assert_eq!(v.set_name, "ARCHIVE");
600        assert_eq!(v.volume_number, 6);
601    }
602
603    #[test]
604    fn test_parse_rar_volume_preserves_original_set_name() {
605        let v = parse_rar_volume("My.Movie.2024.1080p.part001.rar").unwrap();
606        assert_eq!(v.set_name, "My.Movie.2024.1080p");
607    }
608}