diff --git a/edge/src/indexer.rs b/edge/src/indexer.rs index be1ca67..9819d3e 100644 --- a/edge/src/indexer.rs +++ b/edge/src/indexer.rs @@ -282,6 +282,20 @@ pub fn name_beside_quotes(quoted: &str, before_counter: &str) -> Option if has_extension(quoted) { return None; } + // A stray quote took the last pair: the last quoted part that is a file name. + let mut named = None; + let mut rest = before_counter; + while let Some(open) = rest.find('"') { + let Some(close) = rest[open + 1..].find('"') else { break }; + let part = &rest[open + 1..open + 1 + close]; + if has_extension(part) { + named = Some(part); + } + rest = &rest[open + 1 + close + 1..]; + } + if let Some(part) = named { + return Some(part.to_string()); + } // .replace(/"[^"]*"/g, ' ') let mut text = String::with_capacity(before_counter.len()); let mut rest = before_counter; @@ -344,6 +358,15 @@ pub fn unquoted_name(before_counter: &str) -> Option { let word = &before[before.rfind(' ').map_or(0, |i| i + 1)..]; return has_extension(word).then(|| word.to_string()); } + // A single-quoted name at the end: /'([^']*[^.\s']\.[A-Za-z0-9]{2,4})'$/ + if let Some(inner) = text.strip_suffix('\'') { + if let Some(open) = inner.rfind('\'') { + let part = &inner[open + 1..]; + if has_extension(part) { + return Some(part.to_string()); + } + } + } if has_extension(text) { let bracket = text.rfind("] ").map_or(0, |i| i + 2); // A track or part number (/ - \d{1,3} - /): the name starts there; else after the last " - ". @@ -361,9 +384,30 @@ pub fn unquoted_name(before_counter: &str) -> Option { return Some(last.to_string()); } } - return (!name.is_empty() && has_extension(name)).then(|| name.to_string()); + // Ending in a host is no file name: the one in brackets or a word follows. + if !is_host(name) { + return (!name.is_empty() && has_extension(name)).then(|| name.to_string()); + } } - text.split(' ').find(|w| has_extension(w)).map(str::to_string) + // A name in brackets: the last /\[([^[\]]*[^.\s[\]]\.[A-Za-z0-9]{2,4})\]/. + let mut bracketed = None; + for (open, _) in text.match_indices('[') { + let inside = &text[open + 1..]; + let Some(close) = inside.find(']') else { break }; + let part = &inside[..close]; + if !part.contains('[') && has_extension(part) { + bracketed = Some(part); + } + } + if let Some(part) = bracketed { + return Some(part.to_string()); + } + text.split(' ').find(|w| has_extension(w) && !is_host(w)).map(str::to_string) +} + +/// /www\./i: a host ("www.libranews.nl"), whose ".nl" is no extension. +fn is_host(s: &str) -> bool { + s.to_ascii_lowercase().contains("www.") } /// One line of an XOVER reply: number, subject, from, date, message-id, bytes, xref (scanOverviewText). @@ -935,6 +979,9 @@ mod unquoted_tests { ("[03/21 Geroellheimer - S03E16.part01.rar\" yEnc ", Some("Geroellheimer - S03E16.part01.rar")), (" FP -> {EXL01~P~004} -> Alexia (3) -> VF -> [0001 of 0076] -> Exclusive-tgirls_000000-lg.jpg ", Some("Exclusive-tgirls_000000-lg.jpg")), (" here's a little thanksgiving present: AB: part2 0573-1002 043.jpg ", Some("043.jpg")), + ("xlady post You, Me And Dupree www.x2l.nl [001/106] 'You, Me And Dupree.nfo'", Some("You, Me And Dupree.nfo")), + ("[01/77][LIBRATEAM_M2219009.par2] Onbeperkt downloaden met een snelheid van 1,22 MB p.s. en dat voor maar 7,95 p.m. www.libranews.nl", Some("LIBRATEAM_M2219009.par2")), + ("patensas post -^-[Mnozil Brass - Seven.nzb]-^-www.usenet2day.cc -^-[000/107]-^-www.libranews.nl-^- yEnc", Some("Mnozil Brass - Seven.nzb")), ("[7w] - 9BbEV2MrTi59GBTEFlVrssBrXNwXf2SK9cQEeDjg yEnc ", None), ("Re: Will somebody answer the question? ", None), ]; diff --git a/edge/src/names.rs b/edge/src/names.rs index 54fb79a..a803c74 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -641,14 +641,16 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { // 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: …"). let quoted = format!("\"{name}\""); - let named = if subject.contains("ed) { - subject.replacen("ed, " ", 1).replace('"', " ") + let without = if subject.contains("ed) { + subject.replacen("ed, " ", 1) } else { let collapsed = js::collapse_spaces(name); let collapsed = js::trim(&collapsed); - let without = if subject.contains(name) { subject.replacen(name, " ", 1) } else { subject.replacen(collapsed, " ", 1) }; - without.replace('"', " ") + if subject.contains(name) { subject.replacen(name, " ", 1) } else { subject.replacen(collapsed, " ", 1) } }; + // The name repeated elsewhere ("(NELET11.par2) [01/58] - "NELET11.par2""). + let without = if name.encode_utf16().count() >= 5 { without.replace(name, " ") } else { without }; + let named = without.replace('"', " "); let remainder = subject_remainder(&named); // Counted without the file name: "shyla-shy-09-0195.jpg hardcore …" is no "9 of 195". let total = counter_total(&named); @@ -672,7 +674,16 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { let letters = b.iter().take_while(|c| c.is_ascii_uppercase()).count(); let code_digits = b[letters..].iter().take_while(|c| c.is_ascii_digit()).count(); let code = (1..=5).contains(&letters) && (2..=6).contains(&code_digits) && matches!(b.get(letters + code_digits), Some(b'-' | b'_' | b' ')); - index_at(0) || (code && index_at(letters + code_digits + 1)) + // Not a date: /^\d{1,2}[-._]\d{1,2}[-._](?:19|20)\d{2}(?!\d)/ ("09-30-2008_BAK_JRS"). + let date = { + let run = |at: usize| b.get(at..).map_or(0, |s| s.iter().take_while(|c| c.is_ascii_digit()).count()); + let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'.' | b'_')); + let d1 = run(0); + let d2 = if (1..=2).contains(&d1) && sep(d1) { run(d1 + 1) } else { 0 }; + let y = d1 + 1 + d2 + 1; + (1..=2).contains(&d2) && sep(d1 + 1 + d2) && run(y) == 4 && matches!(&b[y..y + 2], b"19" | b"20") + }; + (index_at(0) || (code && index_at(letters + code_digits + 1))) && !date }; // A hashed stem under text without a counter is the upload, the text a tag ("Seit 1753 Tagen - usenet4ever.info - …"). let hashed = file_total.is_none() && test(&HASHED, &stem); @@ -1272,6 +1283,17 @@ mod image_set_tests { assert_ne!(vlad("185", 77, "P8250001.JPG").key, vlad("234", 77, "P5300085.JPG").key); } + #[test] + fn dates_lead_no_numbered_items_and_repeated_names_go() { + let show = |n: &str| release_identity("#sterntuary ", &format!(r#"#sterntuary - Jeff Rense Radio Program - "{n}" yEnc"#), n); + assert_eq!(show("09-30-2008_BAK_JRS.nfo").key, show("09-30-2008_BAK_JRS.r00").key); + assert_ne!(show("09-30-2008_BAK_JRS.r00").key, show("10-01-2008_BAK_JRS.r00").key); + let nelet = |n: &str, i: u32| release_identity("p", &format!(r#"({n}) [{i:02}/58] - "{n}" yEnc"#), n); + assert_eq!(nelet("NELET11.par2", 1).key, nelet("NELET11.part01.rar", 2).key); + let wl = |n: &str| release_identity("p", &format!(r#"(01/26) "{n}" "Silicon Dream - Time Machine_BY_WaRLoRD```` yEnc"#), n); + assert_eq!(wl("01 Time Machine.mp3").key, wl("02 Jimmy Dean Loved Marilyn.mp3").key); + } + #[test] fn hashed_stems_volume_numbers_and_scene_tracks() { let u4e = |n: &str| release_identity("p", &format!(r#"Seit 1753 Tagen - usenet4ever.info - "{n}" yEnc"#), n); diff --git a/lib/headers/subject.ts b/lib/headers/subject.ts index 4434d5e..b0f9673 100644 --- a/lib/headers/subject.ts +++ b/lib/headers/subject.ts @@ -37,11 +37,17 @@ const EXTENSION = /[^.\s]\.[A-Za-z0-9]{2,4}$/ */ export function nameBesideQuotes(quoted: string, beforeCounter: string): string | null { if (EXTENSION.test(quoted)) return null + // A stray quote took the last pair ("(01/26) "01 Time Machine.mp3" "Silicon Dream_BY_WaRLoRD yEnc", + // "… "x.nfo" " yEnc", "… "x.nzb" yEnc""): the last quoted part that is a file name. + const named = [...beforeCounter.matchAll(/"([^"]*)"/g)].map((m) => m[1]).filter((q) => EXTENSION.test(q)) + if (named.length > 0) return named.at(-1)! const name = unquotedName(beforeCounter.replace(/"[^"]*"/g, ' ')) return name && EXTENSION.test(name) ? name : null } const IMAGE_NAME = /\.(jpe?g|png|gif|bmp|webp|tiff?)$/i +/** A host ("www.libranews.nl", "www.x2l.nl"): its ".nl" is no extension. */ +const HOST = /www\./i export function unquotedName(beforeCounter: string): string | null { let text = beforeCounter.trimEnd() @@ -59,6 +65,9 @@ export function unquotedName(beforeCounter: string): string | null { const word = before.slice(before.lastIndexOf(' ') + 1) return EXTENSION.test(word) ? word : null } + // A single-quoted name at the end ("xlady post You, Me And Dupree www.x2l.nl [001/106] 'You, Me And Dupree.nfo'"). + const single = /'([^']*[^.\s']\.[A-Za-z0-9]{2,4})'$/.exec(text) + if (single) return single[1] if (EXTENSION.test(text)) { const bracket = text.lastIndexOf('] ') // A track or part number ("Album - CD2 - 07 - Artist - Title.mp3"): the name starts there, so the rest @@ -76,9 +85,13 @@ export function unquotedName(beforeCounter: string): string | null { const last = name.slice(space + 1) if (/\d$/.test(name.slice(0, space).trimEnd()) && !last.startsWith('(')) return last } - return name && EXTENSION.test(name) ? name : null + // Ending in a host is no file name: the one in brackets or a word follows. + if (!HOST.test(name)) return name && EXTENSION.test(name) ? name : null } - for (const word of text.split(' ')) if (EXTENSION.test(word)) return word + // A name in brackets ("[01/77][LIBRATEAM_M2219009.par2] … www.libranews.nl", "-^-[Mnozil Brass - Seven.nzb]-^-"). + const bracketed = [...text.matchAll(/\[([^[\]]*[^.\s[\]]\.[A-Za-z0-9]{2,4})\]/g)].at(-1) + if (bracketed) return bracketed[1] + for (const word of text.split(' ')) if (EXTENSION.test(word) && !HOST.test(word)) return word return null } const FILE_COUNTER = /\[(\d+)\/(\d+)\]/ diff --git a/lib/release/identity.ts b/lib/release/identity.ts index a7ccfeb..38cf62a 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -286,7 +286,11 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { : input.subject.includes(input.name) ? input.subject.replace(input.name, ' ') : input.subject.replace(collapsed, ' ') - ).replace(/"/g, ' ') + ) + // 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') + .join(' ') + .replace(/"/g, ' ') const remainder = subjectRemainder(named) // Counted without the file name, as quoted names are: "shyla-shy-09-0195.jpg hardcore …" is no "9 of 195" // (each of 866 images was a release of its own; LOG.md 2026-10-12 (5)). @@ -303,7 +307,8 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { // a set ("ai-engineer-complete [401/465] - "003 Defining Tools with JSON Schema.mp4"": a course's 465 // lessons were releases of their own; LOG.md 2026-10-11 (7)). Uploads hours apart are apart anyway. // Also after a disc code ("SC8367-12 - Wynette, Tammy - Singing My Song", karaoke discs). - const numbered = /^(?:[A-Z]{1,5}\d{2,6}[-_ ])?\d{1,3}[ ._-]/.test(stem) + // Not a date ("09-30-2008_BAK_JRS.r00": a daily show's 32,621 files were one release under the poster's tag). + const numbered = /^(?:[A-Z]{1,5}\d{2,6}[-_ ])?\d{1,3}[ ._-]/.test(stem) && !/^\d{1,2}[-._]\d{1,2}[-._](?:19|20)\d{2}(?!\d)/.test(stem) // A hashed stem under text without a counter is the upload, the text a tag ("Seit 1753 Tagen - usenet4ever.info - // "055631a1_b6c820bc_a856086c_85112a5-u4e.part01.rar"": a day's uploads were one release; LOG.md 2026-10-12 (11)). const hashed = fileTotal === undefined && HASHED.test(stem) diff --git a/tests/unit/headers/subject.spec.ts b/tests/unit/headers/subject.spec.ts index 1c62b9d..2258afc 100644 --- a/tests/unit/headers/subject.spec.ts +++ b/tests/unit/headers/subject.spec.ts @@ -66,6 +66,12 @@ test.group('subject / parseSubject', () => { ['[03/21 Geroellheimer - S03E16.part01.rar" yEnc (1/50)', 'Geroellheimer - S03E16.part01.rar', 1, 50], [' FP -> {EXL01~P~004} -> Alexia (3) -> VF -> [0001 of 0076] -> Exclusive-tgirls_000000-lg.jpg (1/1)', 'Exclusive-tgirls_000000-lg.jpg', 1, 1], [" here's a little thanksgiving present: AB: part2 0573-1002 043.jpg (1/1)", '043.jpg', 1, 1], + ["xlady post You, Me And Dupree www.x2l.nl [001/106] 'You, Me And Dupree.nfo' (1/1)", 'You, Me And Dupree.nfo', 1, 1], + ['[01/77][LIBRATEAM_M2219009.par2] Onbeperkt downloaden met een snelheid van 1,22 MB p.s. en dat voor maar 7,95 p.m. www.libranews.nl (1/1)', 'LIBRATEAM_M2219009.par2', 1, 1], + ['patensas post -^-[Mnozil Brass - Seven.nzb]-^-www.usenet2day.cc -^-[000/107]-^-www.libranews.nl-^- yEnc (1/1)', 'Mnozil Brass - Seven.nzb', 1, 1], + ['(01/26) "01 Time Machine (You Are The Master Of Time-Age).mp3" "Silicon Dream - Time Machine_BY_WaRLoRD```` yEnc (1/1)', '01 Time Machine (You Are The Master Of Time-Age).mp3', 1, 1], + ['Bondage-xxx-DVDRIP-XviD_By_GARM (02/74) "Bondage-xxx-DVDRIP-XviD.nfo" " yEnc (1/1)', 'Bondage-xxx-DVDRIP-XviD.nfo', 1, 1], + ['W44-K14-TheMan @ D4D.cc (sjappie45) Op [000/103] - "W44-K14-TheMan @ D4D.cc (sjappie45) Op.nzb" yEnc" (1/1)', 'W44-K14-TheMan @ D4D.cc (sjappie45) Op.nzb', 1, 1], ] for (const [subject, name, segment, total, fileIndex] of cases) { const parsed = parseSubject(subject) diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index 4b9a7d0..944bbaa 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -276,6 +276,17 @@ test.group('release / par2 names', () => { assert.notEqual(vlad('185', 77, 'P8250001.JPG').key, vlad('234', 77, 'P5300085.JPG').key) }) + test('a leading date numbers no item; a file name repeated in the subject goes everywhere', ({ assert }) => { + const id = (subject: string, name: string, poster = 'p') => releaseIdentity({ poster, subject, name, weak: false })! + const show = (n: string) => id(`#sterntuary - Jeff Rense Radio Program - "${n}" yEnc`, n, '#sterntuary ') + assert.equal(show('09-30-2008_BAK_JRS.nfo').key, show('09-30-2008_BAK_JRS.r00').key) + assert.notEqual(show('09-30-2008_BAK_JRS.r00').key, show('10-01-2008_BAK_JRS.r00').key) + const nelet = (n: string, i: number) => id(`(${n}) [${String(i).padStart(2, '0')}/58] - "${n}" yEnc`, n) + assert.equal(nelet('NELET11.par2', 1).key, nelet('NELET11.part01.rar', 2).key) + const wl = (n: string) => id(`(01/26) "${n}" "Silicon Dream - Time Machine_BY_WaRLoRD\`\`\`\` yEnc`, n) + assert.equal(wl('01 Time Machine.mp3').key, wl('02 Jimmy Dean Loved Marilyn.mp3').key) + }) + test('hashed stems under a tag, volume numbers as extensions, scene tracks under a bare counter', ({ assert }) => { const id = (subject: string, name: string) => releaseIdentity({ poster: 'p', subject, name, weak: false })! const u4e = (n: string) => id(`Seit 1753 Tagen - usenet4ever.info - "${n}" yEnc`, n)