From 0c5ede99dde4e667aa921eb55044f78d29b61ead Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Tue, 13 Oct 2026 03:59:24 +0200 Subject: [PATCH] A quoted name that runs on into a title; .xNNN/.zNNN volumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy Joel - Extremes ", "GrooveCoverage.part26.rarGrooveCoveragebestof", "Goga Sekulic.part134.rar - Goga Sekulic - …": the stem kept the title, and each part was a release of its own (lois, CPP, DieViper, Maveri, tigra: ~2,400 files). runOn cuts the name after its archive extension when a space, a dash or the name's own start follows. And three-digit .x/.z volumes ("Reply 987986177631730.x004"), not .x264/.x265. Co-Authored-By: Claude Opus 5.5 --- edge/src/names.rs | 62 +++++++++++++++++++++++++---- lib/release/identity.ts | 25 +++++++++++- lib/release/index.ts | 2 +- tests/unit/release/identity.spec.ts | 12 +++++- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/edge/src/names.rs b/edge/src/names.rs index 409f388..e583fda 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -71,16 +71,24 @@ pub fn file_stem_re(name: &str) -> String { /// Old-style rar volumes after .r99 and split zip volumes, as the file's own extension, lower case; .xNN but not /// .x32/.x64/.x86 (no lookahead here: the builds are put back by old_volume_at). -static OLD_RAR_VOLUME: LazyLock = LazyLock::new(|| re(r"\.(?:[s-vxz])[0-9]{2}$")); +static OLD_RAR_VOLUME: LazyLock = LazyLock::new(|| re(r"\.(?:[s-v][0-9]{2}|[xz][0-9]{2,3})$")); /// Whether a name ends with an old rar or split zip volume (OLD_RAR_VOLUME without the .x builds). fn old_volume_at(b: &[u8]) -> bool { let len = b.len(); - len >= 4 - && b[len - 4] == b'.' - && matches!(b[len - 3], b's'..=b'v' | b'x' | b'z') - && b[len - 2..].iter().all(u8::is_ascii_digit) - && !(b[len - 3] == b'x' && matches!(&b[len - 2..], b"32" | b"64" | b"86")) + let digits = b.iter().rev().take_while(|c| c.is_ascii_digit()).count(); + if !(2..=3).contains(&digits) || len < digits + 2 || b[len - digits - 2] != b'.' { + return false; + } + let letter = b[len - digits - 1]; + let number = &b[len - digits..]; + match letter { + b's'..=b'v' => digits == 2, + b'z' => true, + // Not the builds .x32, .x64, .x86, .x264, .x265. + b'x' => !matches!(number, b"32" | b"64" | b"86" | b"264" | b"265"), + _ => false, + } } static QUOTED: LazyLock = LazyLock::new(|| re(r#""[^"]*""#)); static COUNTERS: LazyLock = LazyLock::new(|| re(r"\[[0-9]+/[0-9]+\]|\([0-9]+/[0-9]+\)")); @@ -229,7 +237,7 @@ pub fn file_stem(name: &str) -> String { let mut len = b.len(); // Old-style rar volumes after .r99 (".s41"), as the file's own extension, lower case. if old_volume_at(b) { - len -= 4; + len -= b.iter().rev().take_while(|c| c.is_ascii_digit()).count() + 2; } let mut changed = true; while changed { @@ -679,6 +687,27 @@ pub fn random_poster(from: &str) -> bool { looks_random(&local) } +/// RUN_ON (lib/release/identity.ts): an archive's or par2's extension. +static RUN_ON: LazyLock = LazyLock::new(|| re(r"(?i)\.(?:part[0-9]+\.rar|vol[0-9]+[+-][0-9]+\.par2|par2|rar|r[0-9]{2,3}|7z(?:\.[0-9]{3})?|zip|nzb|nfo|sfv)")); + +/// runOn (lib/release/identity.ts): a quoted name that runs on past its extension into a title, cut there. +pub fn run_on(name: &str) -> &str { + let b = name.as_bytes(); + let ascii = b.len() >= 4 && b[..4].is_ascii(); + for m in RUN_ON.find_iter(b) { + let rest = &name[m.end()..]; + if rest.is_empty() { + return name; + } + let spaced = rest.starts_with(js::is_space) || rest.trim_start_matches(js::is_space).starts_with('-'); + let repeats = ascii && rest.len() >= 4 && rest.as_bytes()[..4].eq_ignore_ascii_case(&b[..4]); + if spaced || repeats { + return &name[..m.end()]; + } + } + name +} + /// 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); @@ -703,7 +732,7 @@ 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). let post_total = file_counter_total(subject); - let stem = file_stem(name); + let stem = file_stem(run_on(name)); // 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: …"). @@ -1445,6 +1474,8 @@ mod image_set_tests { ("App.x86", "App.x86"), ("Licence 700790900887990.z18", "Licence 700790900887990"), ("Licence 077887994400668.x12", "Licence 077887994400668"), + ("Reply 987986177631730.x004", "Reply 987986177631730"), + ("Some.Movie.2010.x264", "Some.Movie.2010.x264"), ("chappelle1.part02.rev", "chappelle1"), ("chappelle1.rev", "chappelle1"), ("VwE3i90MVX9dqMFuDfzdS7Q6.part1.par2", "VwE3i90MVX9dqMFuDfzdS7Q6"), @@ -1459,6 +1490,21 @@ mod image_set_tests { 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"); + for (n, cut) in [ + ("lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy Joel - Extremes ", "lois 1258 Billy Joel - Extremes.part01.rar"), + ("GrooveCoverage.part26.rarGrooveCoveragebestof", "GrooveCoverage.part26.rar"), + ("Goga Sekulic.part134.rar - Goga Sekulic - Diskografija (2000-2014).", "Goga Sekulic.part134.rar"), + ("HASf293jLAIWUELvhaG3.part091.rar HASf293jLAIWUELvhaG3", "HASf293jLAIWUELvhaG3.part091.rar"), + ("Movie.rar.Collection.mkv", "Movie.rar.Collection.mkv"), + ("x.part01.rar", "x.part01.rar"), + ] { + assert_eq!(super::run_on(n), cut, "{n}"); + } + let lois = |i: &str, n: &str| super::release_identity("p", &format!(r#"(thnx to original poster) [{i}/92] - "{n}" yEnc"#), n); + assert_eq!( + lois("02", "lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy Joel - Extremes ").key, + lois("01", "lois 1258 Billy Joel - Extremes.par2 lois 1258 Billy Joel - Extremes ").key + ); 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"); diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 8c289de..950be46 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -51,7 +51,8 @@ export function fileStem(name: string): string { // Old-style rar volumes after .r99 ("BD_NoTHing018.s41", "….s00", ".t91", ".v81"): only as the file's own extension, in // lower case, so a season ("show.s01.rar", "Show.S01") stays. Split zip volumes (".z18"), and .xNN but not the builds // ".x32", ".x64", ".x86" ("Licence 700790900887990.z18", "….x31"; "….v1.0.142.x64" stays). - let stem = name.trim().replace(/\.(?:[s-vz]\d{2}|x(?!32$|64$|86$)\d{2})$/, '') + // Also three digits ("Reply 987986177631730.x004"), but not ".x264", ".x265". + let stem = name.trim().replace(/\.(?:[s-v]\d{2}|z\d{2,3}|x(?!(?:32|64|86|264|265)$)\d{2,3})$/, '') let changed = true while (changed) { changed = false @@ -291,6 +292,26 @@ export function randomPoster(from: string): boolean { return looksRandom((at === -1 ? address : address.slice(0, at)).replace(/[-_.]/g, '')) } +/** An archive's or par2's extension, where a name that runs on into a title is cut (runOn). */ +const RUN_ON = /\.(?:part\d+\.rar|vol\d+[+-]\d+\.par2|par2|rar|r\d{2,3}|7z(?:\.\d{3})?|zip|nzb|nfo|sfv)/gi + +/** + * A quoted name that runs on past its extension into a title ("lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy + * Joel - Extremes ", "GrooveCoverage.part26.rarGrooveCoveragebestof", "Goga Sekulic.part134.rar - Goga Sekulic - …": each + * part a release of its own): the name up to that extension, when a space or a dash follows it, or the name's own start. + */ +export function runOn(name: string): string { + const head = name.slice(0, 4).toLowerCase() + const ascii = /^[\x00-\x7f]{4}$/.test(head) + for (const m of name.matchAll(RUN_ON)) { + const end = m.index + m[0].length + const rest = name.slice(end) + if (rest.length === 0) return name + if (/^\s|^\s*-/.test(rest) || (ascii && rest.slice(0, 4).toLowerCase() === head)) return name.slice(0, end) + } + return name +} + /** 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() @@ -305,7 +326,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { const counter = /\[(\d+)\/(\d+)\]/.exec(input.subject) // The post rule's key takes the [i/M] total (as when the catalog started); the release's file total any counter's. const postTotal = counter && !brokenTotal(counter[1], counter[2]) ? Number.parseInt(counter[2], 10) : undefined - const stem = fileStem(input.name) + const stem = fileStem(runOn(input.name)) if (input.weak) { // Obfuscating posters randomise the poster per article; a real filename diff --git a/lib/release/index.ts b/lib/release/index.ts index d751ccc..a6bdae8 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, sizeAfterName, subjectRemainder, namesRelease, isPostToken, type ReleaseIdentity, type ReleaseInput } from './identity.js' +export { releaseIdentity, par2ReleaseIdentity, par2ReleaseName, rarReleaseIdentity, isGenericName, fileStem, sizeAfterName, runOn, 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 552c083..9911096 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, sizeAfterName, subjectRemainder } from '#lib/release/index' +import { fileStem, releaseIdentity, runOn, sizeAfterName, subjectRemainder } from '#lib/release/index' import { looksLikeFilename, weakFileKey } from '#lib/headers/index' test.group('release / fileStem', () => { @@ -308,6 +308,16 @@ test.group('release / par2 names', () => { assert.equal(fileStem('App.x86'), 'App.x86') assert.equal(fileStem('Licence 700790900887990.z18'), 'Licence 700790900887990') assert.equal(fileStem('Licence 077887994400668.x12'), 'Licence 077887994400668') + assert.equal(fileStem('Reply 987986177631730.x004'), 'Reply 987986177631730') + assert.equal(fileStem('Some.Movie.2010.x264'), 'Some.Movie.2010.x264') + assert.equal(runOn('lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy Joel - Extremes '), 'lois 1258 Billy Joel - Extremes.part01.rar') + assert.equal(runOn('GrooveCoverage.part26.rarGrooveCoveragebestof'), 'GrooveCoverage.part26.rar') + assert.equal(runOn('Goga Sekulic.part134.rar - Goga Sekulic - Diskografija (2000-2014).'), 'Goga Sekulic.part134.rar') + assert.equal(runOn('HASf293jLAIWUELvhaG3.part091.rar HASf293jLAIWUELvhaG3'), 'HASf293jLAIWUELvhaG3.part091.rar') + assert.equal(runOn('Movie.rar.Collection.mkv'), 'Movie.rar.Collection.mkv') + assert.equal(runOn('x.part01.rar'), 'x.part01.rar') + const lois = (i: string, n: string) => releaseIdentity({ poster: 'p', subject: `(thnx to original poster) [${i}/92] - "${n}" yEnc`, name: n, weak: false })! + assert.equal(lois('02', 'lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy Joel - Extremes ').key, lois('01', 'lois 1258 Billy Joel - Extremes.par2 lois 1258 Billy Joel - Extremes ').key) assert.equal(fileStem('chappelle1.part02.rev'), 'chappelle1') assert.equal(fileStem('chappelle1.rev'), 'chappelle1') assert.equal(fileStem('VwE3i90MVX9dqMFuDfzdS7Q6.part1.par2'), 'VwE3i90MVX9dqMFuDfzdS7Q6')