diff --git a/edge/src/names.rs b/edge/src/names.rs index 2ef04d3..684f6e5 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -748,7 +748,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(run_on(name)); + let stem = random_volume_base(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: …"). @@ -895,7 +895,9 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { } } // A numbered volume as the extension, within the counter's total ("DN.VPL PCK.1508" of 2195): one upload. - let base = match (VOLUME_NUMBER.captures(stem.as_bytes()), post_total.filter(|&n| n != 0)) { + // Also zero-padded after a date ("CHT PCK 2 9.26.001"). + let volume = VOLUME_NUMBER.captures(stem.as_bytes()).or_else(|| PADDED_VOLUME.captures(stem.as_bytes())); + let base = match (volume, post_total.filter(|&n| n != 0)) { (Some(c), Some(total)) if number(&c[2]) <= total as f64 => String::from_utf8(c[1].to_vec()).unwrap(), _ => stem.clone(), }; @@ -926,6 +928,20 @@ static SCENE_RELEASE: LazyLock = LazyLock::new(|| { }); /// RANDOM_VOLUME (lib/release/identity.ts): a file of a multi-volume upload. static RANDOM_VOLUME: LazyLock = LazyLock::new(|| re(r"(?i)\.(?:part[0-9]+\.rar|vol[0-9]+[+-][0-9]+\.par2|par2|r[0-9]{2,3}|7z\.[0-9]{3}|zip\.[0-9]{3}|[0-9]{3})$")); +/// PADDED_VOLUME (lib/release/identity.ts): a volume number of 3–5 digits after a dot. +static PADDED_VOLUME: LazyLock = LazyLock::new(|| re(r"^(.*[A-Za-z].*?)\.([0-9]{3,5})$")); +/// RANDOM_PART (lib/release/identity.ts): a random name's volume with any short extension, or none. +static RANDOM_PART: LazyLock = LazyLock::new(|| re(r"^(.+)\.part[0-9]{2,}(?:\.[A-Za-z0-9]{1,5})?$")); + +/// randomVolumeBase (lib/release/identity.ts): a random name's volume number and extension off its stem. +pub fn random_volume_base(stem: String) -> String { + let base = RANDOM_PART.captures(stem.as_bytes()).map(|c| String::from_utf8(c[1].to_vec()).unwrap()); + match base { + Some(b) if b.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-') && looks_random(&b.chars().filter(|c| !matches!(c, '-' | '_')).collect::()) => b, + _ => stem, + } +} + static VOLUME_NUMBER: LazyLock = LazyLock::new(|| re(r"^(.*[^0-9. ])[. ]([0-9]{1,5})\.?$")); static GENERIC: LazyLock = LazyLock::new(|| { @@ -1582,6 +1598,12 @@ mod image_set_tests { assert_eq!(show("01", "The.Big.Bang.Theory.S01E01.part01.rar").key, show("20", "The.Big.Bang.Theory.S01E01.nfo").key); let tender = |i: &str, n: &str| super::release_identity("p", &format!("Tender TEEN SEX in HD - File {i} of 435 - {n}"), n); assert_eq!(tender("001", "a1 (2).jpg").key, tender("002", "a1.jpg").key); + let rv = |i: &str, total: &str, n: &str| super::release_identity("p", &format!(r#"[{i}/{total}] - "{n}" yEnc"#), n); + assert_eq!(rv("01", "69", "df2BkeRxIvHcWrsWwwz.part01.cer").key, rv("69", "69", "df2BkeRxIvHcWrsWwwz.part69.bdmv").key); + assert_eq!(rv("01", "32", "jA0eSSUMat8tQtIKUyPMA.part01").key, rv("32", "32", "jA0eSSUMat8tQtIKUyPMA.part32").key); + assert_ne!(rv("01", "2", "Some.Movie.Part01.mkv").key, rv("02", "2", "Some.Movie.Part02.mkv").key); + let pck = |i: &str, n: &str| super::release_identity("YME", &format!(r#"(????) [{i}/1009] - "{n}" yEnc"#), n); + assert_eq!(pck("0001", "CHT PCK 2 9.26.001").key, pck("0002", "CHT PCK 2 9.26.002").key); let mdg = |post: &str, i: &str, n: &str| super::release_identity("p", &format!(r#"{post} - ({i}/90) "{n}" - 16,14 MB - yEnc"#), n); assert_eq!(mdg("MDG005b", "01", "IMG_2307.JPG").key, mdg("MDG005b", "02", "IMG_2308.jpg").key); assert_ne!(mdg("MDG005b", "01", "IMG_2307.JPG").key, mdg("MDG006a", "01", "IMG_4410.JPG").key); diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 492fe43..977d7da 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -152,6 +152,21 @@ const SCENE_TRACK = /^\d{1,3}[-_ .]+(.+?)(?:_-_| - |-)/ const SCENE_RELEASE = /^(.+?)(?:_-_| - |-).*-(?:web|cd[a-z]?|vinyl|vls|sat|cable|fm|dab|single|ep|lp|promo|bootleg)-.*(?:19|20)\d{2}(?:-[A-Za-z0-9_]+)?$/i /** A file of a multi-volume upload: a rar part or volume, a par2 (index or volume), a numbered split. */ const RANDOM_VOLUME = /\.(?:part\d+\.rar|vol\d+[+-]\d+\.par2|par2|r\d{2,3}|7z\.\d{3}|zip\.\d{3}|\d{3})$/i +/** A volume number of 3–5 digits after a dot, whatever comes before it ("CHT PCK 2 9.26.001"). */ +const PADDED_VOLUME = /^(.*[A-Za-z].*?)\.(\d{3,5})$/ +/** A random name's volume with any short extension, or none ("df2BkeRxIvHcWrsWwwz.part01.cer", "….part69.bdmv"). */ +const RANDOM_PART = /^(.+)\.part\d{2,}(?:\.[A-Za-z0-9]{1,5})?$/ + +/** + * A random name's volume number and extension off its stem: obfuscators give every volume an extension of its own + * ("df2BkeRxIvHcWrsWwwz.part01.cer", "….part69.bdmv", "jA0eSSUMat8tQtIKUyPMA.part01"), each a release of its own; + * LOG.md 2026-10-14 (52)). Only a random name (no dots or spaces): "Movie.Part01.mkv" is a stem as it is. + */ +export function randomVolumeBase(stem: string): string { + const m = RANDOM_PART.exec(stem) + return m && /^[A-Za-z0-9_-]+$/.test(m[1]) && looksRandom(m[1].replace(/[-_]/g, '')) ? m[1] : stem +} + const VOLUME_NUMBER = /^(.*[^\d. ])[. ](\d{1,5})\.?$/ /** Image files: a set's images are named one by one ("IMG_3086.jpg"); its subject names the set. */ @@ -333,7 +348,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(runOn(input.name)) + const stem = randomVolumeBase(fileStem(runOn(input.name))) if (input.weak) { // Obfuscating posters randomise the poster per article; a real filename @@ -475,7 +490,8 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { // upload the par2 index without a counter, and it belongs to the same post. // A numbered volume as the extension, within the counter's total ("(????) [0659/2195] - "DN.VPL PCK.1508"", // "TKY.ASN PCK.691."): the volumes are one upload. - const volume = VOLUME_NUMBER.exec(stem) + // Also zero-padded after a date ("(????) [0001/1009] - "CHT PCK 2 9.26.001"": 5,419 single files of YME in a.b.pwp). + const volume = VOLUME_NUMBER.exec(stem) ?? PADDED_VOLUME.exec(stem) const base = volume && postTotal && Number(volume[2]) <= postTotal ? volume[1] : stem // Without case and a scene index's "00-" ("00-laura_van_dam_-_capture_radio_113-cable-10-10-2026-talion.m3u" beside // "Laura_van_Dam_-_Capture_Radio_113-CABLE-10-10-2026-TALiON.par2": one upload, two releases; LOG.md 2026-10-12 (19)). diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index 97266dd..befe807 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -458,6 +458,17 @@ test.group('release / par2 names', () => { assert.equal(tender('003', 'a2 (2).jpg').key, tender('002', 'a1.jpg').key) }) + test("a random name's volumes with extensions of their own; a padded volume after a date", ({ assert }) => { + const id = (i: string, total: string, n: string) => releaseIdentity({ poster: 'p', subject: `[${i}/${total}] - "${n}" yEnc`, name: n, weak: false })! + assert.equal(id('01', '69', 'df2BkeRxIvHcWrsWwwz.part01.cer').key, id('69', '69', 'df2BkeRxIvHcWrsWwwz.part69.bdmv').key) + assert.equal(id('01', '32', 'jA0eSSUMat8tQtIKUyPMA.part01').key, id('32', '32', 'jA0eSSUMat8tQtIKUyPMA.part32').key) + // A readable name keeps its part. + assert.notEqual(id('01', '2', 'Some.Movie.Part01.mkv').key, id('02', '2', 'Some.Movie.Part02.mkv').key) + const pck = (i: string, n: string) => releaseIdentity({ poster: 'YME', subject: `(????) [${i}/1009] - "${n}" yEnc`, name: n, weak: false })! + assert.equal(pck('0001', 'CHT PCK 2 9.26.001').key, pck('0002', 'CHT PCK 2 9.26.002').key) + assert.notEqual(pck('0001', 'CHT PCK 2 9.26.001').key, pck('0400', 'CHT PCK 4 10.26.400').key) + }) + test('an image without words around it is keyed by its set', ({ assert }) => { const id = (name: string, counter: string) => releaseIdentity({ poster: 'p', subject: `##[ "${name}" (${counter})`, name, weak: false })! assert.equal(id('diana-040-001.jpg', '01/68').key, id('diana-040-002.jpg', '02/68').key)