diff --git a/edge/src/indexer.rs b/edge/src/indexer.rs index ef26f49..3193c4b 100644 --- a/edge/src/indexer.rs +++ b/edge/src/indexer.rs @@ -296,6 +296,12 @@ pub fn name_beside_quotes(quoted: &str, before_counter: &str) -> Option } /// unquotedName (lib/headers/subject.ts): the file name of a subject without a quoted one, from the text before its counter. +/// /\.(jpe?g|png|gif|bmp|webp|tiff?)$/i +fn is_image_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tif", ".tiff"].iter().any(|e| lower.ends_with(e)) +} + pub fn unquoted_name(before_counter: &str) -> Option { let mut text = before_counter.trim_end_matches(js::is_space); if text.len() >= 4 && text.is_char_boundary(text.len() - 4) && text[text.len() - 4..].eq_ignore_ascii_case("yenc") { @@ -340,7 +346,17 @@ pub fn unquoted_name(before_counter: &str) -> Option { let dash = first_track(text).or_else(|| text.rfind(" - ")).map_or(0, |i| i + 3); // Or after an arrow ("… -> Exclusive-tgirls_000000-lg.jpg"). let arrow = text.rfind("-> ").map_or(0, |i| i + 3); - let name = js::trim(&text[bracket.max(dash).max(arrow)..]); + let cut = bracket.max(dash).max(arrow); + let name = js::trim(&text[cut..]); + // An image after numbered text without a separator: its last word (" … AB: part2 0573-1002 043.jpg"). + // Not an index alone ("Teen Tease Self Shooter sad1 (10).jpg"). + if cut == 0 && is_image_name(name) && name.contains(' ') { + let space = name.rfind(' ').unwrap(); + let last = &name[space + 1..]; + if name[..space].trim_end_matches(js::is_space).ends_with(|c: char| c.is_ascii_digit()) && !last.starts_with('(') { + return Some(last.to_string()); + } + } return (!name.is_empty() && has_extension(name)).then(|| name.to_string()); } text.split(' ').find(|w| has_extension(w)).map(str::to_string) @@ -913,6 +929,7 @@ mod unquoted_tests { ("pal! [Mivina high(1).wmv\" yEnc] ", Some("Mivina high(1).wmv")), ("[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")), ("[7w] - 9BbEV2MrTi59GBTEFlVrssBrXNwXf2SK9cQEeDjg yEnc ", None), ("Re: Will somebody answer the question? ", None), ]; diff --git a/edge/src/names.rs b/edge/src/names.rs index e7a527b..99f3200 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -465,13 +465,15 @@ pub fn image_set(stem: &str) -> String { break; } } - match s.iter().rposition(u8::is_ascii_digit) { - Some(last) => { - let start = s[..=last].iter().rposition(|c| !c.is_ascii_digit()).map_or(0, |i| i + 1); - String::from_utf8_lossy(&s[..start]).into_owned() - } - None => String::new(), + let set = match s.iter().rposition(u8::is_ascii_digit) { + Some(last) => &s[..s[..=last].iter().rposition(|c| !c.is_ascii_digit()).map_or(0, |i| i + 1)], + None => return String::new(), + }; + // A number of 5+ digits left in it is an id, per image ("sss-70556-3474182-23-10825418-pc"): no set. + if set.split(|c| !c.is_ascii_digit()).any(|run| trim_zeros(run).len() >= 5) { + return String::new(); } + String::from_utf8_lossy(set).into_owned() } /// Number(digits) equality for digit strings: without leading zeros (all zeros: "0"). @@ -481,7 +483,7 @@ fn trim_zeros(d: &[u8]) -> &[u8] { } static TIMESTAMP: LazyLock = LazyLock::new(|| re(r"[-_. ]?(?:19|20)[0-9]{2}[-._ ](?:0?[1-9]|1[0-2])[-._ ][0-9]{1,2}(?:[-._ ][0-9]{1,2})*$")); -static COPY_MARK: LazyLock = LazyLock::new(|| re(r"SP*[\[(][0-9]+[\])]$")); +static COPY_MARK: LazyLock = LazyLock::new(|| re(r"(?:SP*[\[(][0-9]+[\])])+$")); static RESOLUTION: LazyLock = LazyLock::new(|| re(r"(?i)[_-][0-9]{3,4}(?:x[0-9]{3,4}|px)$")); static IMAGEVENUE: LazyLock = LazyLock::new(|| re(r"(?i)_123_[0-9]+lo$")); static ZERO_SUFFIX: LazyLock = LazyLock::new(|| re(r"[_-]0+$")); @@ -637,8 +639,10 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { let stem = file_stem(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. - let named = if subject.contains(&format!("\"{name}\"")) { - subject.to_string() + // 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('"', " ") } else { let collapsed = js::collapse_spaces(name); let collapsed = js::trim(&collapsed); @@ -916,7 +920,7 @@ pub fn upload_tool(message_id: &str) -> &'static str { /// [A-Za-z]{2,}[._ -]+[A-Za-z0-9]{2,} anywhere. fn name_like(b: &[u8]) -> bool { - let sep = |c: u8| matches!(c, b'.' | b'_' | b' ' | b'-'); + let sep = |c: u8| matches!(c, b'.' | b'_' | b' ' | b'-' | b',' | b'&' | b':' | b';' | b'\\' | b'/' | b'+'); (2..b.len()).any(|k| { if !(sep(b[k]) && b[k - 1].is_ascii_alphabetic() && b[k - 2].is_ascii_alphabetic()) { return false; @@ -1221,6 +1225,9 @@ mod image_set_tests { ("092_081_2000x1500", "092_"), ("KP0027_197_00", "KP"), ("01079-106-2048px", "01079-"), + ("diana-050-052[1][1]", "diana-050-"), + ("sss-70556-3474182-23-10825418-pc", ""), + ("Alena p 26663_alena-p-2-8323_123_39lo", ""), ("she has sensual legs 172 _ 172", "she has sensual legs "), ("jamie.lee.curtis_the.tailor.of.panama_17_123_244lo", "jamie.lee.curtis_the.tailor.of.panama_"), ("JimAndJoey Kindly Husband-to-Be", ""), @@ -1238,6 +1245,15 @@ mod image_set_tests { assert_eq!(mint("JimAndJoey Kindly Husband-to-Be.JPG").key, mint("Oops OnlyOneMore.JPG").key); } + #[test] + fn other_quoted_text_and_text_separators_name_the_release() { + let cc = |name: &str, n: &str| release_identity("p", &format!(r#">>> CREATIVE COMMONS NZB <<< "{name} " - File 1 of 9: "creative_commons_nzb_{n}.rar" yEnc"#), &format!("creative_commons_nzb_{n}.rar")); + assert_ne!(cc("0190 alarm 5.0", "0190_alarm_5.0_").key, cc("portable irfanview 4.22", "portable_irfanview_4.22").key); + let vlad = |dir: &str, total: u32, n: &str| release_identity("p", &format!(r#"Posting & deleting, read 0 file "E:\VladModels\y038_Irina\{dir}" Day 5/6 [01/{total}] - {n} yEnc"#), n); + assert_eq!(vlad("185", 77, "P8250001.JPG").source, "subject"); + assert_ne!(vlad("185", 77, "P8250001.JPG").key, vlad("234", 77, "P5300085.JPG").key); + } + #[test] fn images_without_words_by_their_set() { let id = |n: &str, c: &str| release_identity("p", &format!(r#"##[ "{n}" ({c})"#), n); diff --git a/lib/headers/subject.ts b/lib/headers/subject.ts index 44b52a0..4434d5e 100644 --- a/lib/headers/subject.ts +++ b/lib/headers/subject.ts @@ -41,6 +41,8 @@ export function nameBesideQuotes(quoted: string, beforeCounter: string): string return name && EXTENSION.test(name) ? name : null } +const IMAGE_NAME = /\.(jpe?g|png|gif|bmp|webp|tiff?)$/i + export function unquotedName(beforeCounter: string): string | null { let text = beforeCounter.trimEnd() if (/yenc$/i.test(text)) text = text.slice(0, -4).trimEnd() @@ -67,6 +69,13 @@ export function unquotedName(beforeCounter: string): string | null { const arrow = text.lastIndexOf('-> ') const cut = Math.max(bracket === -1 ? 0 : bracket + 2, dash === -1 ? 0 : dash + 3, arrow === -1 ? 0 : arrow + 3) const name = text.slice(cut).trim() + // An image after numbered text without a separator: its last word (" … AB: part2 0573-1002 043.jpg"; the whole + // subject was the name, so each image a release). Not an index alone ("Teen Tease Self Shooter sad1 (10).jpg"). + if (cut === 0 && IMAGE_NAME.test(name) && name.includes(' ')) { + const space = name.lastIndexOf(' ') + 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 } for (const word of text.split(' ')) if (EXTENSION.test(word)) return word diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 133060f..54039d3 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -119,8 +119,8 @@ export function namesRelease(remainder: string): boolean { .split(/\s+/) .filter((w) => w && !isRandomToken(w)) .join(' ') - // At least two words of letters, joined the way release names are. - return /[A-Za-z]{2,}[._ -]+[A-Za-z0-9]{2,}/.test(words) + // At least two words of letters, joined the way release names are (or as text is: "Posting & deleting, read 0 file"). + return /[A-Za-z]{2,}[._ \-,&:;\\/+]+[A-Za-z0-9]{2,}/.test(words) } function hash(...parts: (string | number | undefined)[]) { @@ -158,7 +158,7 @@ export function imageSet(stem: string): string { // Suffixes that number no image: a copy's ("sexypattycake_0903040445[1]"), a resolution ("092_081_2000x1500", // "01079-106-2048px"), ImageVenue's ("…_17_123_244lo"), a constant one ("KP0027_197_00"). const plain = stem - .replace(/\s*[[(]\d+[\])]$/, '') + .replace(/(?:\s*[[(]\d+[\])])+$/, '') .replace(/[_-]\d{3,4}(?:x\d{3,4}|px)$/i, '') .replace(/_123_\d+lo$/i, '') .replace(/[_-]0+$/, '') @@ -183,7 +183,10 @@ export function imageSet(stem: string): string { if (repeat || renumbered || /^(?:19|20)\d\d$/.test(n) || n.length >= 4) s = before else break } - return /\d/.test(s) ? s.replace(/\d+\D*$/, '') : '' + const set = /\d/.test(s) ? s.replace(/\d+\D*$/, '') : '' + // A number of 5+ digits (without leading zeros) left in it is an id, per image ("sss-70556-3474182-23-10825418-pc", + // "Alena p 26663_…"): no set. A padded set number ("01079-106") is one. + return /(?>> CREATIVE COMMONS NZB <<< "0190 alarm 5.0 " - File 1 of 9: + // "creative_commons_nzb_0190_alarm_5.0_.rar"": 12,605 files of 1,400 uploads were one release; LOG.md 2026-10-12 (9)). + const named = ( + input.subject.includes(`"${input.name}"`) + ? input.subject.replace(`"${input.name}"`, ' ') + : input.subject.includes(input.name) + ? input.subject.replace(input.name, ' ') + : input.subject.replace(collapsed, ' ') + ).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)). diff --git a/tests/unit/headers/subject.spec.ts b/tests/unit/headers/subject.spec.ts index 0f5fcde..1c62b9d 100644 --- a/tests/unit/headers/subject.spec.ts +++ b/tests/unit/headers/subject.spec.ts @@ -65,6 +65,7 @@ test.group('subject / parseSubject', () => { ["pal! [Mivina high(1).wmv\" yEnc] (323/349)", 'Mivina high(1).wmv', 323, 349], ['[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], ] 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 58b454a..2808aa7 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -245,6 +245,9 @@ test.group('release / par2 names', () => { ['092_081_2000x1500', '092_'], ['KP0027_197_00', 'KP'], ['01079-106-2048px', '01079-'], + ['diana-050-052[1][1]', 'diana-050-'], + ['sss-70556-3474182-23-10825418-pc', ''], + ['Alena p 26663_alena-p-2-8323_123_39lo', ''], ['she has sensual legs 172 _ 172', 'she has sensual legs '], ['jamie.lee.curtis_the.tailor.of.panama_17_123_244lo', 'jamie.lee.curtis_the.tailor.of.panama_'], ['JimAndJoey Kindly Husband-to-Be', ''], @@ -262,6 +265,17 @@ test.group('release / par2 names', () => { assert.equal(destiny(63, 34).name, 'Newstar Destiny I set 63') }) + test('other quoted text and text separators name the release', ({ assert }) => { + const id = (subject: string, name: string) => releaseIdentity({ poster: 'p', subject, name, weak: false })! + const cc = (name: string, n: string) => + id(`>>> CREATIVE COMMONS NZB <<< "${name} " - File 1 of 9: "creative_commons_nzb_${n}.rar" yEnc`, `creative_commons_nzb_${n}.rar`) + assert.notEqual(cc('0190 alarm 5.0', '0190_alarm_5.0_').key, cc('portable irfanview 4.22', 'portable_irfanview_4.22').key) + const vlad = (dir: string, total: number, n: string) => + id(`Posting & deleting, read 0 file "E:\\VladModels\\y038_Irina\\${dir}" Day 5/6 [01/${total}] - ${n} yEnc`, n) + assert.equal(vlad('185', 77, 'P8250001.JPG').source, 'subject') + assert.notEqual(vlad('185', 77, 'P8250001.JPG').key, vlad('234', 77, 'P5300085.JPG').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)