diff --git a/edge/src/names.rs b/edge/src/names.rs index 0b7d71a..aaec84d 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -419,7 +419,7 @@ pub fn image_set(stem: &str) -> String { // .replace(/(\d+)\s*of\s*\d+/gi, '$1') // Suffixes that number no image: a copy's ("…[1]"), a resolution ("…_2000x1500"), a constant one ("…_00"). let mut s = stem.as_bytes().to_vec(); - for re in [&*COPY_MARK, &*RESOLUTION, &*ZERO_SUFFIX] { + for re in [&*COPY_MARK, &*RESOLUTION, &*IMAGEVENUE, &*ZERO_SUFFIX] { s = re.replace(&s, &b""[..]).into_owned(); } // A counter in the name counts the images: the set is the text before it ("Set 008 10of63 new0464"). @@ -430,13 +430,20 @@ pub fn image_set(stem: &str) -> String { let s_ts = TIMESTAMP.replace(&s, &b""[..]).into_owned(); s = s_ts; loop { - // /[_-](\d+)$/ + // /\s*([_-])\s*(\d+)$/ (also spaced: "she has sensual legs 172 _ 172") let digits = s.iter().rev().take_while(|c| c.is_ascii_digit()).count(); - if digits == 0 || s.len() <= digits || !matches!(s[s.len() - digits - 1], b'_' | b'-') { + if digits == 0 { break; } + let ws = |c: &u8| matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c); + let sep_end = s.len() - digits - s[..s.len() - digits].iter().rev().take_while(|c| ws(c)).count(); + if sep_end == 0 || !matches!(s[sep_end - 1], b'_' | b'-') { + break; + } + let sep = s[sep_end - 1]; + let before_end = sep_end - 1 - s[..sep_end - 1].iter().rev().take_while(|c| ws(c)).count(); let n = &s[s.len() - digits..]; - let before = &s[..s.len() - digits - 1]; + let before = &s[..before_end]; // /(\d+)\D*$/: the last digit run before let Some(last) = before.iter().rposition(u8::is_ascii_digit) else { break }; let start = before[..=last].iter().rposition(|c| !c.is_ascii_digit()).map_or(0, |i| i + 1); @@ -444,7 +451,7 @@ pub fn image_set(stem: &str) -> String { // A repeat: the same number, or the end of the one before (054001 ends in 1). let same = trim_zeros(n) == trim_zeros(previous) || previous.ends_with(n); // A renumbering right after the number ("kate108lg029_28"): both count the images. - let renumbered = s[s.len() - digits - 1] == b'_' && n[0] != b'0' && last + 1 == before.len(); + let renumbered = sep == b'_' && n[0] != b'0' && last + 1 == before.len(); let year = n.len() == 4 && (n.starts_with(b"19") || n.starts_with(b"20")); if same || renumbered || year || n.len() >= 4 { let cut = before.len(); @@ -470,7 +477,8 @@ 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 RESOLUTION: LazyLock = LazyLock::new(|| re(r"(?i)[_-][0-9]{3,4}x[0-9]{3,4}$")); +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+$")); static NAME_COUNTER: LazyLock = LazyLock::new(|| re(r"(?i)([0-9]+)SP*ofSP*[0-9]+|[\[(]?SP*[0-9]+SP*/SP*[0-9]+SP*[\])]?")); @@ -672,8 +680,11 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { } } if image { + // Keyed by its set when its name has one ("diana-040-001.jpg"). let name = if prefix.is_empty() { stem.clone() } else { prefix.clone() }; - return Release { key: hash(&["stem", poster, &name]), name, source: "stem", file_total }; + let set = image_set(&stem); + let key = if set.is_empty() { hash(&["stem", poster, &name]) } else { hash(&["stem", poster, &set]) }; + return Release { key, name, source: "stem", file_total }; } // An audiobook's or album's tracks without a name around them: by their name without its numbers. if test(&AUDIO, name) { @@ -1204,6 +1215,9 @@ mod image_set_tests { ("New Folder (17) sexypattycake_0903040445[1]", "New Folder ("), ("092_081_2000x1500", "092_"), ("KP0027_197_00", "KP"), + ("01079-106-2048px", "01079-"), + ("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", ""), ] { assert_eq!(image_set(stem), want, "{stem}"); @@ -1219,6 +1233,13 @@ mod image_set_tests { assert_eq!(mint("JimAndJoey Kindly Husband-to-Be.JPG").key, mint("Oops OnlyOneMore.JPG").key); } + #[test] + fn images_without_words_by_their_set() { + let id = |n: &str, c: &str| release_identity("p", &format!(r#"##[ "{n}" ({c})"#), n); + assert_eq!(id("diana-040-001.jpg", "01/68").key, id("diana-040-002.jpg", "02/68").key); + assert_ne!(id("diana-040-001.jpg", "01/68").key, id("diana-407-081.jpg", "81/82").key); + } + #[test] fn unquoted_names_are_no_counters() { let shy = |n: &str| release_identity("p", &format!("{n} hardcore SHYLA STYLEZ & SHY LOVE"), n); diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 3722b8c..4a3a917 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -152,9 +152,13 @@ const DATES = /\b(?:(?:19|20)\d{2}[-._ ](?:0?[1-9]|1[0-2])[-._ ]\d{1,2}|\d{1,2}[ const TIMESTAMP = /[-_. ]?(?:19|20)\d{2}[-._ ](?:0?[1-9]|1[0-2])[-._ ]\d{1,2}(?:[-._ ]\d{1,2})*$/ export function imageSet(stem: string): string { - // Suffixes that number no image: a copy's ("sexypattycake_0903040445[1]"), a resolution ("092_081_2000x1500"), - // a constant one ("KP0027_197_00"). - const plain = stem.replace(/\s*[[(]\d+[\])]$/, '').replace(/[_-]\d{3,4}x\d{3,4}$/i, '').replace(/[_-]0+$/, '') + // 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(/[_-]\d{3,4}(?:x\d{3,4}|px)$/i, '') + .replace(/_123_\d+lo$/i, '') + .replace(/[_-]0+$/, '') // A counter in the name counts the images: the set is the text before it ("Set 008 10of63 new0464", // "202/430 princess-079-058", "( 098 /500) 002 148"; each image was a release of its own). const counted = /(\d+)\s*of\s*\d+|[[(]?\s*\d+\s*\/\s*\d+\s*[\])]?/i.exec(plain) @@ -162,16 +166,17 @@ export function imageSet(stem: string): string { // A camera's timestamp ("kurozo-2006-7-26-15-21"): the date and the time after it number no set. let s = plain.replace(TIMESTAMP, '') while (true) { - const m = /[_-](\d+)$/.exec(s) + // Also spaced ("she has sensual legs 172 _ 172"). + const m = /\s*([_-])\s*(\d+)$/.exec(s) if (!m) break const before = s.slice(0, m.index) const previous = /(\d+)\D*$/.exec(before) if (!previous) break - const n = m[1] + const n = m[2] // A repeat: the same number, or the end of the one before ("kate054001_1_1": 054001 ends in 1). const repeat = Number(n) === Number(previous[1]) || previous[1].endsWith(n) // A renumbering right after the number ("kate108lg029_28" once an image is skipped): both count the images. - const renumbered = m[0][0] === '_' && !n.startsWith('0') && previous.index + previous[1].length === before.length + const renumbered = m[1] === '_' && !n.startsWith('0') && previous.index + previous[1].length === before.length if (repeat || renumbered || /^(?:19|20)\d\d$/.test(n) || n.length >= 4) s = before else break } @@ -309,7 +314,9 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { 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', poster, prefix || stem), name: prefix || stem, fileTotal, source: 'stem' } + // Keyed by its set when its name has one ("##[ "diana-040-001.jpg" (01/68)": all of a poster's 263 diana sets were + // one release of 17,808 images; LOG.md 2026-10-12 (7)). + if (image) return { key: hash('stem', poster, imageSet(stem) || prefix || stem), name: prefix || stem, fileTotal, source: 'stem' } // An audiobook's or album's tracks without a name around them: by their name without its numbers, as an // image set ("… Seeing A Large Cat - D11.16-22.mp3", "Candy Dulfer - Candy Store - 11 - everytime.mp3"). if (AUDIO.test(input.name)) { diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index d8ce05a..7f58d94 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -244,10 +244,19 @@ test.group('release / par2 names', () => { ['New Folder (17) sexypattycake_0903040445[1]', 'New Folder ('], ['092_081_2000x1500', '092_'], ['KP0027_197_00', 'KP'], + ['01079-106-2048px', '01079-'], + ['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', ''], ]) assert.equal(imageSet(stem), set, stem) }) + 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) + assert.notEqual(id('diana-040-001.jpg', '01/68').key, id('diana-407-081.jpg', '81/82').key) + }) + test('an unquoted file name is no file counter', ({ assert }) => { const shy = (name: string) => releaseIdentity({ poster: 'p', subject: `${name} hardcore SHYLA STYLEZ & SHY LOVE`, name, weak: false })! assert.equal(shy('shyla-shy-09-0195.jpg').key, shy('shyla-shy-09-0766.jpg').key)