diff --git a/edge/src/names.rs b/edge/src/names.rs index 4622554..397a304 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -418,6 +418,9 @@ static DOCUMENT: LazyLock = LazyLock::new(|| re(r"(?i)\.(pdf|epub|mobi|az pub fn image_set(stem: &str) -> String { // .replace(/(\d+)\s*of\s*\d+/gi, '$1') let mut s = OF_TOTAL.replace_all(stem.as_bytes(), &b"$1"[..]).into_owned(); + // A camera's timestamp ("kurozo-2006-7-26-15-21"): the date and the time after it number no set. + let s_ts = TIMESTAMP.replace(&s, &b""[..]).into_owned(); + s = s_ts; loop { // /[_-](\d+)$/ let digits = s.iter().rev().take_while(|c| c.is_ascii_digit()).count(); @@ -432,8 +435,10 @@ pub fn image_set(stem: &str) -> String { let previous = &before[start..=last]; // 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 year = n.len() == 4 && (n.starts_with(b"19") || n.starts_with(b"20")); - if same || year || n.len() >= 4 { + if same || renumbered || year || n.len() >= 4 { let cut = before.len(); s.truncate(cut); } else { @@ -455,6 +460,7 @@ fn trim_zeros(d: &[u8]) -> &[u8] { t } +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 OF_TOTAL: LazyLock = LazyLock::new(|| re(r"(?i)([0-9]+)SP*ofSP*[0-9]+")); fn image_prefix(stem: &str) -> String { @@ -604,18 +610,21 @@ 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()); 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 remainder = if subject.contains(&format!("\"{name}\"")) { - subject_remainder(subject) + let named = if subject.contains(&format!("\"{name}\"")) { + subject.to_string() } 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) }; - subject_remainder(&without.replace('"', " ")) + 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); + let file_total = total.as_ref().and_then(|t| t.parse::().ok()); // A document or comic is a release of its own. if test(&DOCUMENT, name) { let shown = replace(&DOCUMENT, &stem, "", false); @@ -643,7 +652,7 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { let one_word = image && remainder.len() >= 3 && remainder.bytes().all(|c| c.is_ascii_alphabetic()); if !tag && (names_release(&remainder) || one_word) { // Without a counter an image's set: its name up to its last number, digits kept; none without numbers. - let bound = counter_total(subject).unwrap_or_else(|| if image { image_set(&stem) } else { String::new() }); + let bound = total.clone().unwrap_or_else(|| if image { image_set(&stem) } else { String::new() }); return Release { key: hash(&["subject", poster, &remainder, &bound]), name: remainder, source: "subject", file_total }; } if let Some(total) = post_total.filter(|&n| n != 0 && !tag) { @@ -1175,6 +1184,9 @@ mod image_set_tests { ("IMG_2676", "IMG_"), ("2877-041", "2877-"), ("051 - White leather kate054001_1_1", "051 - White leather kate"), + ("108 - Schoolwall kate108lg029_28", "108 - Schoolwall kate108lg"), + ("kurozo-2006-7-26-15-21", ""), + ("bwp2_037_5", "bwp2_"), ("JimAndJoey Kindly Husband-to-Be", ""), ] { assert_eq!(image_set(stem), want, "{stem}"); @@ -1190,6 +1202,13 @@ mod image_set_tests { assert_eq!(mint("JimAndJoey Kindly Husband-to-Be.JPG").key, mint("Oops OnlyOneMore.JPG").key); } + #[test] + fn unquoted_names_are_no_counters() { + let shy = |n: &str| release_identity("p", &format!("{n} hardcore SHYLA STYLEZ & SHY LOVE"), n); + assert_eq!(shy("shyla-shy-09-0195.jpg").key, shy("shyla-shy-09-0766.jpg").key); + assert_eq!(shy("shyla-shy-09-0195.jpg").file_total, None); + } + #[test] fn dates_are_no_counters() { use super::counter_total; diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 1ec42da..d01c15c 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -145,10 +145,14 @@ const DATES = /\b(?:(?:19|20)\d{2}[-._ ](?:0?[1-9]|1[0-2])[-._ ]\d{1,2}|\d{1,2}[ * An image's set: its name up to the number that counts the images, digits before it kept ("stp250x001" → * "stp250x", "cutie-071-032" → "cutie-071-"); empty for a name without numbers. Suffixes after it that don't * count the images go first: a repeat of the number before (katelg010_10), a year (hgrscans1097_2009), a - * size of 4+ digits (…_24of40_1600), and an "of" total (24of40) (LOG.md 2026-10-12 (3)). + * size of 4+ digits (…_24of40_1600), and an "of" total (24of40) (LOG.md 2026-10-12 (3)); also a renumbering + * right after it (kate108lg029_28) and a camera's timestamp (kurozo-2006-7-26-15-21) (LOG.md 2026-10-12 (5)). */ +const TIMESTAMP = /[-_. ]?(?:19|20)\d{2}[-._ ](?:0?[1-9]|1[0-2])[-._ ]\d{1,2}(?:[-._ ]\d{1,2})*$/ + export function imageSet(stem: string): string { - let s = stem.replace(/(\d+)\s*of\s*\d+/gi, '$1') + // A camera's timestamp ("kurozo-2006-7-26-15-21"): the date and the time after it number no set. + let s = stem.replace(/(\d+)\s*of\s*\d+/gi, '$1').replace(TIMESTAMP, '') while (true) { const m = /[_-](\d+)$/.exec(s) if (!m) break @@ -158,7 +162,9 @@ export function imageSet(stem: string): string { const n = m[1] // 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) - if (repeat || /^(?:19|20)\d\d$/.test(n) || n.length >= 4) s = before + // 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 + if (repeat || renumbered || /^(?:19|20)\d\d$/.test(n) || n.length >= 4) s = before else break } return /\d/.test(s) ? s.replace(/\d+\D*$/, '') : '' @@ -235,7 +241,6 @@ 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 ? Number.parseInt(counter[2], 10) : undefined - const fileTotal = counterTotal(input.subject) const stem = fileStem(input.name) if (input.weak) { @@ -250,11 +255,13 @@ 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. // The subject here has its spaces collapsed; the name may not ("lara_bath 15of19 larabath_2.5.jpg"). const collapsed = input.name.replace(/\s+/g, ' ').trim() - const remainder = subjectRemainder( - input.subject.includes(`"${input.name}"`) - ? input.subject - : (input.subject.includes(input.name) ? input.subject.replace(input.name, ' ') : input.subject.replace(collapsed, ' ')).replace(/"/g, ' ') - ) + const named = input.subject.includes(`"${input.name}"`) + ? input.subject + : (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)). + const fileTotal = counterTotal(named) // 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', poster, stem), name: stem.replace(DOCUMENT, '') || stem, fileTotal, source: 'stem' } const image = IMAGE.test(input.name) @@ -276,7 +283,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { if (!tag && (namesRelease(remainder) || (image && /^[A-Za-z]{3,}$/.test(remainder)))) { // Bounded by the counter's total, so the uploads a poster names alike are apart while the files // of one upload (par2 and nfo included) share it (LOG.md 2026-10-09 (9)). - const total = counterTotal(input.subject) + const total = fileTotal // Without a counter an image's set bounds it: its name up to its last number, digits kept ("stp250x001.jpg" // → "stp250x": STIRILING SILVER's 300 sets were one release of 44k images); a name without numbers names no // set ("Kindly Husband-to-Be.JPG": a thread of 1,177 releases), so the subject alone (LOG.md 2026-10-12 (2)). diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index 178f603..ac9ac12 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -235,10 +235,19 @@ test.group('release / par2 names', () => { ['IMG_2676', 'IMG_'], ['2877-041', '2877-'], ['051 - White leather kate054001_1_1', '051 - White leather kate'], + ['108 - Schoolwall kate108lg029_28', '108 - Schoolwall kate108lg'], + ['kurozo-2006-7-26-15-21', ''], + ['bwp2_037_5', 'bwp2_'], ['JimAndJoey Kindly Husband-to-Be', ''], ]) assert.equal(imageSet(stem), set, stem) }) + 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) + assert.isUndefined(shy('shyla-shy-09-0195.jpg').fileTotal) + }) + test('dates are no file counters', async ({ assert }) => { const { counterTotal } = await import('#lib/release/identity') assert.isUndefined(counterTotal('Avira AntiVir Rescue System 08-29-2011.rar'))