A quoted name that runs on into a title; .xNNN/.zNNN volumes
Test and deploy / test (push) Successful in 59s
Test and deploy / edge (push) Successful in 2m2s
Test and deploy / deploy (push) Successful in 1m32s

"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 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-13 03:59:24 +02:00
1 parent 1c8da1dd12
commit 0c5ede99dd
4 files changed
+89 -12

No files matched your search

+54 -8
View File
@@ -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 /// 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). /// .x32/.x64/.x86 (no lookahead here: the builds are put back by old_volume_at).
static OLD_RAR_VOLUME: LazyLock<Regex> = LazyLock::new(|| re(r"\.(?:[s-vxz])[0-9]{2}$")); static OLD_RAR_VOLUME: LazyLock<Regex> = 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). /// 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 { fn old_volume_at(b: &[u8]) -> bool {
let len = b.len(); let len = b.len();
len >= 4 let digits = b.iter().rev().take_while(|c| c.is_ascii_digit()).count();
&& b[len - 4] == b'.' if !(2..=3).contains(&digits) || len < digits + 2 || b[len - digits - 2] != b'.' {
&& matches!(b[len - 3], b's'..=b'v' | b'x' | b'z') return false;
&& 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 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<Regex> = LazyLock::new(|| re(r#""[^"]*""#)); 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 COUNTERS: LazyLock<Regex> = 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(); let mut len = b.len();
// Old-style rar volumes after .r99 (".s41"), as the file's own extension, lower case. // Old-style rar volumes after .r99 (".s41"), as the file's own extension, lower case.
if old_volume_at(b) { if old_volume_at(b) {
len -= 4; len -= b.iter().rev().take_while(|c| c.is_ascii_digit()).count() + 2;
} }
let mut changed = true; let mut changed = true;
while changed { while changed {
@@ -679,6 +687,27 @@ pub fn random_poster(from: &str) -> bool {
looks_random(&local) looks_random(&local)
} }
/// RUN_ON (lib/release/identity.ts): an archive's or par2's extension.
static RUN_ON: LazyLock<Regex> = 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. /// 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 { pub fn size_after_name(subject: &str, name: &str) -> String {
let end = subject.trim_end_matches(js::is_space); 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 }; 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). // 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 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. // 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. // 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: …"). // 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"), ("App.x86", "App.x86"),
("Licence 700790900887990.z18", "Licence 700790900887990"), ("Licence 700790900887990.z18", "Licence 700790900887990"),
("Licence 077887994400668.x12", "Licence 077887994400668"), ("Licence 077887994400668.x12", "Licence 077887994400668"),
("Reply 987986177631730.x004", "Reply 987986177631730"),
("Some.Movie.2010.x264", "Some.Movie.2010.x264"),
("chappelle1.part02.rev", "chappelle1"), ("chappelle1.part02.rev", "chappelle1"),
("chappelle1.rev", "chappelle1"), ("chappelle1.rev", "chappelle1"),
("VwE3i90MVX9dqMFuDfzdS7Q6.part1.par2", "VwE3i90MVX9dqMFuDfzdS7Q6"), ("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("(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(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"); 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); 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!(milf("27", "2407.jpg", "28").key, milf("28", "2408.jpg", "31").key);
assert_eq!(super::file_stem_re("rld-gtre.r13.002"), "rld-gtre"); assert_eq!(super::file_stem_re("rld-gtre.r13.002"), "rld-gtre");
+23 -2
View File
@@ -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 // 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 // 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). // ".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 let changed = true
while (changed) { while (changed) {
changed = false changed = false
@@ -291,6 +292,26 @@ export function randomPoster(from: string): boolean {
return looksRandom((at === -1 ? address : address.slice(0, at)).replace(/[-_.]/g, '')) 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). */ /** 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 { export function sizeAfterName(subject: string, name: string): string {
const end = subject.trimEnd() const end = subject.trimEnd()
@@ -305,7 +326,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
const counter = /\[(\d+)\/(\d+)\]/.exec(input.subject) 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. // 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 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) { if (input.weak) {
// Obfuscating posters randomise the poster per article; a real filename // Obfuscating posters randomise the poster per article; a real filename
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Grouping of files into releases (downloads), independent of AdonisJS. * 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' export { looksRandom } from './random.js'
+11 -1
View File
@@ -1,5 +1,5 @@
import { test } from '@japa/runner' 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' import { looksLikeFilename, weakFileKey } from '#lib/headers/index'
test.group('release / fileStem', () => { test.group('release / fileStem', () => {
@@ -308,6 +308,16 @@ test.group('release / par2 names', () => {
assert.equal(fileStem('App.x86'), 'App.x86') assert.equal(fileStem('App.x86'), 'App.x86')
assert.equal(fileStem('Licence 700790900887990.z18'), 'Licence 700790900887990') assert.equal(fileStem('Licence 700790900887990.z18'), 'Licence 700790900887990')
assert.equal(fileStem('Licence 077887994400668.x12'), 'Licence 077887994400668') 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.part02.rev'), 'chappelle1')
assert.equal(fileStem('chappelle1.rev'), 'chappelle1') assert.equal(fileStem('chappelle1.rev'), 'chappelle1')
assert.equal(fileStem('VwE3i90MVX9dqMFuDfzdS7Q6.part1.par2'), 'VwE3i90MVX9dqMFuDfzdS7Q6') assert.equal(fileStem('VwE3i90MVX9dqMFuDfzdS7Q6.part1.par2'), 'VwE3i90MVX9dqMFuDfzdS7Q6')