More per-file suffixes: old rar volumes, .rev, a part's par2, .bc; a size after an unquoted name
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:
1 parent
2823d42bfa
commit
d4e53ca0d8
4 files changed
+127
-17
No files matched your search
+80
-7
@@ -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("ed) {
|
||||
subject.replacen("ed, " ", 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
@@ -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,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'
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user