More per-file suffixes: old rar volumes, .rev, a part's par2, .bc; a size after an unquoted name
Test and deploy / test (push) Successful in 59s
Test and deploy / edge (push) Successful in 2m7s
Test and deploy / deploy (push) Successful in 1m22s

Each file of these uploads was a release of its own:
- "BD_NoTHing018.s41", "…CyjI.s00": rar volumes after .r99 (as the file's
  own lower-case extension only, so "show.s01.rar" keeps its season);
- "chappelle1.part02.rev": rar recovery volumes;
- "X.part1.par2", "X.part1.vol00+34.par2" beside "X.part1.rar";
- "NZPEC….180.bc": BCArchive volumes;
- "[27/70] - 2407.jpg - 28": the size in kB after an unquoted name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-12 23:59:09 +02:00
1 parent 2823d42bfa
commit d4e53ca0d8
4 files changed
+127 -17

No files matched your search

+80 -7
View File
@@ -28,11 +28,14 @@ fn test(re: &Regex, s: &str) -> bool {
static SUFFIXES: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
vec![
// A part's par2 files ("X.part1.par2", "X.part1.vol00+34.par2").
(re(r"(?i)\.part[0-9]+(?:\.vol[0-9]+[+-][0-9]+)?\.par2$"), ""),
(re(r"(?i)\.vol[0-9]+[+-][0-9]+\.par2$"), ""),
(re(r"(?i)\.par2$"), ""),
(re(r"(?i)\.part[0-9]+\.rar$"), ""),
(re(r"(?i)\.part[0-9]+\.(?:rar|rev)$"), ""),
(re(r"(?i)\.r[0-9]{2,3}$"), ""),
(re(r"(?i)\.rar$"), ""),
(re(r"(?i)\.(?:rar|rev)$"), ""),
(re(r"(?i)(?:\.[0-9]{1,4})?\.bc$"), ""),
(re(r"(?i)\.7z(\.[0-9]{3})?$"), ""),
(re(r"(?i)\.zip(\.[0-9]{3})?$"), ""),
// JS: (?<=\.(7z|…))\.\d{3}$ — no lookbehind here, so the extension is matched and put back.
@@ -47,7 +50,7 @@ static SUFFIXES: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
/// fileStem with the regexes, as in the TypeScript (the reference for file_stem's tests).
#[allow(dead_code)]
pub fn file_stem_re(name: &str) -> String {
let mut stem = js::trim(name).to_string();
let mut stem = replace(&OLD_RAR_VOLUME, js::trim(name), "", false);
let mut changed = true;
while changed {
changed = false;
@@ -65,6 +68,8 @@ pub fn file_stem_re(name: &str) -> String {
stem
}
/// Old-style rar volumes after .r99, as the file's own extension, lower case: /\.[s-z]\d{2}$/.
static OLD_RAR_VOLUME: LazyLock<Regex> = LazyLock::new(|| re(r"\.[s-z][0-9]{2}$"));
static QUOTED: LazyLock<Regex> = LazyLock::new(|| re(r#""[^"]*""#));
static COUNTERS: LazyLock<Regex> = LazyLock::new(|| re(r"\[[0-9]+/[0-9]+\]|\([0-9]+/[0-9]+\)"));
static YENC_END: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\byEnc\b(SP+[0-9]+)?SP*$"));
@@ -145,9 +150,9 @@ fn strip_suffix_rule(s: &[u8], rule: usize) -> Option<usize> {
eq_ci(&s[v..v + 4], b".vol").then_some(v)
}
1 => ends_ci(s, b".par2").then(|| n - 5),
// \.part\d+\.rar$
// \.part\d+\.(?:rar|rev)$
2 => {
if !ends_ci(s, b".rar") {
if !ends_ci(s, b".rar") && !ends_ci(s, b".rev") {
return None;
}
let d = digits_back(s, n - 4);
@@ -160,7 +165,7 @@ fn strip_suffix_rule(s: &[u8], rule: usize) -> Option<usize> {
let p = n.checked_sub(d + 2).filter(|_| (2..=3).contains(&d))?;
eq_ci(&s[p..p + 2], b".r").then_some(p)
}
4 => ends_ci(s, b".rar").then(|| n - 4),
4 => (ends_ci(s, b".rar") || ends_ci(s, b".rev")).then(|| n - 4),
// \.7z(\.\d{3})?$ and \.zip(\.\d{3})?$
5 | 6 => {
let ext: &[u8] = if rule == 5 { b".7z" } else { b".zip" };
@@ -181,6 +186,25 @@ fn strip_suffix_rule(s: &[u8], rule: usize) -> Option<usize> {
9 => strip_ext(s, &[b"tar"]),
10 => strip_ext(s, &PACK_EXTS),
11 => strip_ext(s, &MEDIA_EXTS),
// \.part\d+(?:\.vol\d+[+-]\d+)?\.par2$
12 => {
if !ends_ci(s, b".par2") {
return None;
}
let m = strip_suffix_rule(s, 0).unwrap_or(n - 5);
let d = digits_back(s, m);
let p = m.checked_sub(d + 5).filter(|_| d > 0)?;
eq_ci(&s[p..p + 5], b".part").then_some(p)
}
// (?:\.\d{1,4})?\.bc$
13 => {
if !ends_ci(s, b".bc") {
return None;
}
let m = n - 3;
let d = digits_back(s, m);
Some(if (1..=4).contains(&d) && m > d && s[m - d - 1] == b'.' { m - d - 1 } else { m })
}
_ => None,
}
}
@@ -191,10 +215,15 @@ pub fn file_stem(name: &str) -> String {
let stem = js::trim(name);
let b = stem.as_bytes();
let mut len = b.len();
// Old-style rar volumes after .r99 (".s41"), as the file's own extension, lower case.
if len >= 4 && b[len - 4] == b'.' && (b's'..=b'z').contains(&b[len - 3]) && b[len - 2..].iter().all(u8::is_ascii_digit) {
len -= 4;
}
let mut changed = true;
while changed {
changed = false;
for rule in 0..12 {
// In the order of SUFFIXES: a part's par2 first, BCArchive volumes after the rar rules.
for rule in [12, 0, 1, 2, 3, 4, 13, 5, 6, 7, 8, 9, 10, 11] {
if let Some(next) = strip_suffix_rule(&b[..len], rule) {
if next > 0 && next < len {
len = next;
@@ -638,6 +667,26 @@ pub fn random_poster(from: &str) -> bool {
looks_random(&local)
}
/// sizeAfterName (lib/release/identity.ts): the subject without a number after the file's unquoted name at its end.
pub fn size_after_name(subject: &str, name: &str) -> String {
let end = subject.trim_end_matches(js::is_space);
let before_digits = end.trim_end_matches(|c: char| c.is_ascii_digit());
if before_digits.len() == end.len() {
return subject.to_string();
}
// /\s*-\s*$/
let t = before_digits.trim_end_matches(js::is_space);
let before = match t.strip_suffix('-') {
Some(t) => t.trim_end_matches(js::is_space),
None => return subject.to_string(),
};
if !name.is_empty() && before.ends_with(name) && !before.ends_with(&format!("\"{name}\"")) {
before.to_string()
} else {
subject.to_string()
}
}
pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release {
let poster = if random_poster(poster) { "" } else { poster };
// The post rule's key takes the [i/M] total; the release's file total any counter's (counterTotal).
@@ -646,6 +695,9 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release {
// A name that isn't quoted (unquoted_name) is taken out as the quotes would have been.
// The subject here has its spaces collapsed; the name may not.
// Other quoted text stays: it names the release (">>> CREATIVE COMMONS NZB <<< "0190 alarm 5.0 " - File 1 of 9: …").
// A size after a name that isn't quoted, at the end ("[27/70] - 2407.jpg - 28").
let subject = size_after_name(subject, name);
let subject = subject.as_str();
let quoted = format!("\"{name}\"");
let without = if subject.contains(&quoted) {
subject.replacen(&quoted, " ", 1)
@@ -1370,6 +1422,27 @@ mod image_set_tests {
#[test]
fn split_rar_volumes_and_sizes_after_the_counter() {
assert_eq!(super::file_stem("rld-gtre.r13.002"), "rld-gtre");
for (n, stem) in [
("BD_NoTHing018.s41", "BD_NoTHing018"),
("x8ZRmX5N83mrokOwqJtoIGHTrqXQrgCyj.s00", "x8ZRmX5N83mrokOwqJtoIGHTrqXQrgCyj"),
("the.office.us.s01.rar", "the.office.us.s01"),
("Some.Show.S01", "Some.Show.S01"),
("chappelle1.part02.rev", "chappelle1"),
("chappelle1.rev", "chappelle1"),
("VwE3i90MVX9dqMFuDfzdS7Q6.part1.par2", "VwE3i90MVX9dqMFuDfzdS7Q6"),
("VwE3i90MVX9dqMFuDfzdS7Q6.part1.vol00+34.par2", "VwE3i90MVX9dqMFuDfzdS7Q6"),
("Movie.Part1.mkv", "Movie.Part1"),
("NZPECQUztWOponTpCratvvTPDBXBjvHIULOUPvSH.180.bc", "NZPECQUztWOponTpCratvvTPDBXBjvHIULOUPvSH"),
("x.12345.bc", "x.12345"),
] {
assert_eq!(super::file_stem(n), stem, "{n}");
assert_eq!(super::file_stem_re(n), stem, "{n}");
}
assert_eq!(super::size_after_name("(Hot MILF getting banged hard)[27/70] - 2407.jpg - 28", "2407.jpg"), "(Hot MILF getting banged hard)[27/70] - 2407.jpg");
assert_eq!(super::size_after_name(r#"Show [01/10] - "a.jpg" - 28"#, "a.jpg"), r#"Show [01/10] - "a.jpg" - 28"#);
assert_eq!(super::size_after_name("Show - Episode 05", "x.mkv"), "Show - Episode 05");
let milf = |i: &str, n: &str, kb: &str| super::release_identity("p", &format!("(Hot MILF getting banged hard)[{i}/70] - {n} - {kb}"), n);
assert_eq!(milf("27", "2407.jpg", "28").key, milf("28", "2408.jpg", "31").key);
assert_eq!(super::file_stem_re("rld-gtre.r13.002"), "rld-gtre");
let k = |i: u32, size: u32, n: &str| release_identity("Kingdn", &format!(r#"Buffy The Vampire Slayer s02e04 [{i}/32] - [{size}] "{n}" yEnc"#), n);
assert_eq!(k(26, 4929, "Buffy s02e04 xvid-tns.part24.rar").key, k(27, 400, "Buffy s02e04 xvid-tns.vol00+01.PAR2").key);