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);
+26 -8
View File
@@ -25,11 +25,16 @@ export interface ReleaseInput {
// Suffixes that differ between the files of one release. Stripped repeatedly,
// so "x.mkv.vol03+04.par2" and "x.part07.rar" both reduce to "x".
const SUFFIXES = [
// A part's par2 files ("X.part1.par2", "X.part1.vol00+34.par2" beside "X.part1.rar": 683 single files in a.b.test).
/\.part\d+(?:\.vol\d+[+-]\d+)?\.par2$/i,
/\.vol\d+[+-]\d+\.par2$/i,
/\.par2$/i,
/\.part\d+\.rar$/i,
// Recovery volumes too ("chappelle1.part02.rev").
/\.part\d+\.(?:rar|rev)$/i,
/\.r\d{2,3}$/i,
/\.rar$/i,
/\.(?:rar|rev)$/i,
// BCArchive volumes ("NZPECQUztWOponTpCratvvTPDBXBjvHIULOUPvSH.180.bc").
/(?:\.\d{1,4})?\.bc$/i,
/\.7z(\.\d{3})?$/i,
/\.zip(\.\d{3})?$/i,
// Split volumes (x.mkv.001), not codec names (H.264, H.265).
@@ -43,7 +48,9 @@ const SUFFIXES = [
/** Strips per-file suffixes (volume numbers, archive and par2 extensions) from a filename. */
export function fileStem(name: string): string {
let stem = name.trim()
// Old-style rar volumes after .r99 ("BD_NoTHing018.s41", "….s00"): only as the file's own extension, in lower case, so
// a season ("show.s01.rar", "Show.S01") stays.
let stem = name.trim().replace(/\.[s-z]\d{2}$/, '')
let changed = true
while (changed) {
changed = false
@@ -281,6 +288,15 @@ export function randomPoster(from: string): boolean {
return looksRandom((at === -1 ? address : address.slice(0, at)).replace(/[-_.]/g, ''))
}
/** The subject without a number after the file's unquoted name at its end (" - 28": a size in kB). */
export function sizeAfterName(subject: string, name: string): string {
const end = subject.trimEnd()
const digits = end.length - end.replace(/\d+$/, '').length
if (digits === 0) return subject
const before = end.slice(0, end.length - digits).replace(/\s*-\s*$/, '')
return before.length < end.length - digits && name.length > 0 && before.endsWith(name) && !before.endsWith(`"${name}"`) ? before : subject
}
export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
const poster = randomPoster(input.poster) ? '' : input.poster
const counter = /\[(\d+)\/(\d+)\]/.exec(input.subject)
@@ -300,14 +316,16 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
// as the quotes would have been: otherwise every file of a set would name a release of its own.
// The subject here has its spaces collapsed; the name may not ("lara_bath 15of19 larabath_2.5.jpg").
const collapsed = input.name.replace(/\s+/g, ' ').trim()
// A size after a name that isn't quoted, at the end ("[27/70] - 2407.jpg - 28": each image a release of its own).
const subject = sizeAfterName(input.subject, input.name)
// Other quoted text stays: it names the release (">>> CREATIVE COMMONS NZB <<< "0190 alarm 5.0 " - File 1 of 9:
// "creative_commons_nzb_0190_alarm_5.0_.rar"": 12,605 files of 1,400 uploads were one release; LOG.md 2026-10-12 (9)).
const named = (
input.subject.includes(`"${input.name}"`)
? input.subject.replace(`"${input.name}"`, ' ')
: input.subject.includes(input.name)
? input.subject.replace(input.name, ' ')
: input.subject.replace(collapsed, ' ')
subject.includes(`"${input.name}"`)
? subject.replace(`"${input.name}"`, ' ')
: subject.includes(input.name)
? subject.replace(input.name, ' ')
: subject.replace(collapsed, ' ')
)
// The name repeated elsewhere ("(NELET11.par2) [01/58] - "NELET11.par2"": each file a release of its own).
.split(input.name.length >= 5 ? input.name : '\0')
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Grouping of files into releases (downloads), independent of AdonisJS.
*/
export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './identity.js'
export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, sizeAfterName, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './identity.js'
export { looksRandom } from './random.js'
+20 -1
View File
@@ -1,5 +1,5 @@
import { test } from '@japa/runner'
import { fileStem, releaseIdentity, subjectRemainder } from '#lib/release/index'
import { fileStem, releaseIdentity, sizeAfterName, subjectRemainder } from '#lib/release/index'
import { looksLikeFilename, weakFileKey } from '#lib/headers/index'
test.group('release / fileStem', () => {
@@ -298,6 +298,25 @@ test.group('release / par2 names', () => {
assert.equal(b.name, 'Laura_van_Dam_-_Capture_Radio_113-CABLE-10-10-2026-TALiON')
})
test('old rar volumes, recovery volumes, a part\'s par2, BCArchive volumes; a size after an unquoted name', ({ assert }) => {
assert.equal(fileStem('BD_NoTHing018.s41'), 'BD_NoTHing018')
assert.equal(fileStem('x8ZRmX5N83mrokOwqJtoIGHTrqXQrgCyj.s00'), 'x8ZRmX5N83mrokOwqJtoIGHTrqXQrgCyj')
// A season stays.
assert.equal(fileStem('the.office.us.s01.rar'), 'the.office.us.s01')
assert.equal(fileStem('Some.Show.S01'), 'Some.Show.S01')
assert.equal(fileStem('chappelle1.part02.rev'), 'chappelle1')
assert.equal(fileStem('chappelle1.rev'), 'chappelle1')
assert.equal(fileStem('VwE3i90MVX9dqMFuDfzdS7Q6.part1.par2'), 'VwE3i90MVX9dqMFuDfzdS7Q6')
assert.equal(fileStem('VwE3i90MVX9dqMFuDfzdS7Q6.part1.vol00+34.par2'), 'VwE3i90MVX9dqMFuDfzdS7Q6')
assert.equal(fileStem('Movie.Part1.mkv'), 'Movie.Part1')
assert.equal(fileStem('NZPECQUztWOponTpCratvvTPDBXBjvHIULOUPvSH.180.bc'), 'NZPECQUztWOponTpCratvvTPDBXBjvHIULOUPvSH')
assert.equal(sizeAfterName('(Hot MILF getting banged hard)[27/70] - 2407.jpg - 28', '2407.jpg'), '(Hot MILF getting banged hard)[27/70] - 2407.jpg')
assert.equal(sizeAfterName('Show [01/10] - "a.jpg" - 28', 'a.jpg'), 'Show [01/10] - "a.jpg" - 28')
assert.equal(sizeAfterName('Show - Episode 05', 'x.mkv'), 'Show - Episode 05')
const milf = (i: string, n: string, kb: string) => releaseIdentity({ poster: 'p', subject: `(Hot MILF getting banged hard)[${i}/70] - ${n} - ${kb}`, name: n, weak: false })!
assert.equal(milf('27', '2407.jpg', '28').key, milf('28', '2408.jpg', '31').key)
})
test("split rar volumes; a file's size in brackets after its counter", ({ assert }) => {
assert.equal(fileStem('rld-gtre.r13.002'), 'rld-gtre')
const k = (i: number, size: number, n: string) =>