diff --git a/edge/src/names.rs b/edge/src/names.rs index b18f6bd..7c47d66 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -28,11 +28,14 @@ fn test(re: &Regex, s: &str) -> bool { static SUFFIXES: LazyLock> = 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> = 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 = LazyLock::new(|| re(r"\.[s-z][0-9]{2}$")); static QUOTED: LazyLock = LazyLock::new(|| re(r#""[^"]*""#)); static COUNTERS: LazyLock = LazyLock::new(|| re(r"\[[0-9]+/[0-9]+\]|\([0-9]+/[0-9]+\)")); static YENC_END: LazyLock = LazyLock::new(|| re(r"(?i)\byEnc\b(SP+[0-9]+)?SP*$")); @@ -145,9 +150,9 @@ fn strip_suffix_rule(s: &[u8], rule: usize) -> Option { 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 { 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 { 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); diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 7ded03f..72ac3a9 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -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') diff --git a/lib/release/index.ts b/lib/release/index.ts index 55e8d36..d751ccc 100644 --- a/lib/release/index.ts +++ b/lib/release/index.ts @@ -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' diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index af046b1..8d20d0e 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -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) =>