diff --git a/edge/src/names.rs b/edge/src/names.rs index 44b1112..49f97bf 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -511,7 +511,17 @@ pub fn is_poster_tag(remainder: &str, stem: &str) -> bool { } /// releaseIdentity for a named (not weak) file: never null. +/// randomPoster (lib/release/identity.ts): a poster whose own name, before the "@", is a random token. +pub fn random_poster(from: &str) -> bool { + let address = match from.find('<').and_then(|open| from[open + 1..].find('>').map(|close| &from[open + 1..open + 1 + close])) { + Some(address) => address, + None => js::trim(from).split(' ').next().unwrap_or(""), + }; + looks_random(address.split_once('@').map_or(address, |(local, _)| local)) +} + 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 file_total = counter_total(subject).and_then(|t| t.parse::().ok()); @@ -1033,6 +1043,19 @@ mod numbered_tests { mod image_set_tests { use super::{release_identity, subject_remainder, subject_remainder_re}; + #[test] + fn random_posters_are_no_part_of_the_key() { + use super::random_poster; + let ftd = "-Alight_Upon_the_Lake_(Live_at_the_Jazz_Showcase_Jun_1975)-WEB-2026-FTD"; + let a = release_identity("N9vplIaavBCz@aCTVoAsk.JSE", &format!("[1/6] - \"Yusef_Lateef{ftd}.par2\" yEnc 1744"), &format!("Yusef_Lateef{ftd}.par2")); + let b = release_identity("FVWWHku0fGAg@QfaTz0g0.RVH", &format!("[3/6] - \"Yusef_Lateef{ftd}.part2.rar\" yEnc 104857600"), &format!("Yusef_Lateef{ftd}.part2.rar")); + assert_eq!(a.key, b.key); + assert!(random_poster("N9vplIaavBCz@aCTVoAsk.JSE")); + assert!(!random_poster("Captain Salvo ")); + assert!(!random_poster("Poster49118 ")); + assert!(!random_poster("w.merklein@freenet.de (muffin)")); + } + #[test] fn sizes_in_brackets_and_one_word_sets() { for s in [r#"Aletta Ocean - "100628-035.jpg" yEnc [347K]"#, r#"Corie - "2877-041.jpg" yEnc [290K]"#, "x - !!!blob2010-102katie009.jpg 336573 byt"] { diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 74eb774..6287346 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -184,7 +184,22 @@ export function isPosterTag(remainder: string, stem: string): boolean { * file cannot be grouped from its name alone (random extensionless names in * obfuscated posts; those need par2 matching). See CONCEPT.md (D16). */ +/** + * A poster whose own name (before the "@") is a random token, as uploaders + * that draw a new poster per file do ("N9vplIaavBCz@aCTVoAsk.JSE"): it says + * nothing about which upload a file is in, so it's no part of a release key + * (two files of one FTD upload were releases apart; LOG.md 2026-10-11 (8)). + */ +export function randomPoster(from: string): boolean { + const open = from.indexOf('<') + const close = open === -1 ? -1 : from.indexOf('>', open + 1) + const address = close !== -1 ? from.slice(open + 1, close) : from.trim().split(' ')[0] + const at = address.indexOf('@') + return looksRandom(at === -1 ? address : address.slice(0, at)) +} + export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { + const poster = randomPoster(input.poster) ? '' : input.poster 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 ? Number.parseInt(counter[2], 10) : undefined @@ -203,7 +218,7 @@ 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. const remainder = subjectRemainder(input.subject.includes(`"${input.name}"`) ? input.subject : input.subject.replace(input.name, ' ').replace(/"/g, ' ')) // A document or comic is a release of its own (collections post hundreds under one subject). - if (DOCUMENT.test(input.name)) return { key: hash('stem', input.poster, stem), name: stem.replace(DOCUMENT, '') || stem, fileTotal, source: 'stem' } + if (DOCUMENT.test(input.name)) return { key: hash('stem', poster, stem), name: stem.replace(DOCUMENT, '') || stem, fileTotal, source: 'stem' } const image = IMAGE.test(input.name) // The poster's constant tag ("[KiLL.dA…]---- (01/10) "Album.rar"", "(X Posted)") would make one // release of all their uploads: a readable file name that shares no word with it says which @@ -224,7 +239,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { // of one upload (par2 and nfo included) share it (LOG.md 2026-10-09 (9)). const total = counterTotal(input.subject) return { - key: hash('subject', input.poster, remainder, total ?? prefix), + key: hash('subject', poster, remainder, total ?? prefix), name: remainder, fileTotal, source: 'subject', @@ -233,13 +248,13 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { // Not a name, but a post id (e.g. `83110629-n [10/18] - "random"`): with the // [i/M] counter it groups the files of one post whose names are random. if (!tag && postTotal && isPostToken(remainder)) { - return { key: hash('post', input.poster, remainder, postTotal), name: remainder, fileTotal, source: 'post' } + return { key: hash('post', poster, remainder, postTotal), name: remainder, fileTotal, source: 'post' } } // An image without words around it: its set by its name (keyed as a stem, so "x.jpg" joins "x.part01.rar"). - if (image) return { key: hash('stem', input.poster, prefix || stem), name: prefix || stem, fileTotal, source: 'stem' } + if (image) return { key: hash('stem', poster, prefix || stem), name: prefix || stem, fileTotal, source: 'stem' } // The [i/M] total is informational, not part of the key: posters often // upload the par2 index without a counter, and it belongs to the same post. - return { key: hash('stem', input.poster, stem), name: stem, fileTotal, source: 'stem' } + return { key: hash('stem', poster, stem), name: stem, fileTotal, source: 'stem' } } /** Release of a par2 recovery set; every file listed in it belongs to it. */ diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index aa59d7a..41c13eb 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -175,6 +175,18 @@ test.group('release / par2 names', () => { assert.equal(par2ReleaseName(['Album.Name-2024-GRP.part01.rar', 'Album.Name-2024-GRP.part02.rar']), 'Album.Name-2024-GRP') assert.equal(par2ReleaseName(['only.one.file.2024.mkv']), 'only.one.file.2024') }) + test('a random poster per file is no part of the key; a real poster is', async ({ assert }) => { + const { randomPoster } = await import('#lib/release/identity') + const ftd = '-Alight_Upon_the_Lake_(Live_at_the_Jazz_Showcase_Jun_1975)-WEB-2026-FTD' + const a = releaseIdentity({ poster: 'N9vplIaavBCz@aCTVoAsk.JSE', subject: `[1/6] - "Yusef_Lateef${ftd}.par2" yEnc 1744`, name: `Yusef_Lateef${ftd}.par2`, weak: false })! + const b = releaseIdentity({ poster: 'FVWWHku0fGAg@QfaTz0g0.RVH', subject: `[3/6] - "Yusef_Lateef${ftd}.part2.rar" yEnc 104857600`, name: `Yusef_Lateef${ftd}.part2.rar`, weak: false })! + assert.equal(a.key, b.key) + assert.isTrue(randomPoster('N9vplIaavBCz@aCTVoAsk.JSE')) + assert.isFalse(randomPoster('Captain Salvo ')) + assert.isFalse(randomPoster('Poster49118 ')) + assert.isFalse(randomPoster('w.merklein@freenet.de (muffin)')) + }) + test('image sets: sizes in brackets and one-word set names', ({ assert }) => { const id = (subject: string, name: string) => releaseIdentity({ poster: 'p', subject, name, weak: false })! assert.equal(id('Aletta Ocean - "100628-035.jpg" yEnc [347K]', '100628-035.jpg').key, id('Aletta Ocean - "100628-036.jpg" yEnc [298K]', '100628-036.jpg').key)