diff --git a/edge/src/names.rs b/edge/src/names.rs index 101b245..e3afa69 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -414,18 +414,48 @@ static AUDIO: LazyLock = LazyLock::new(|| re(r"(?i)\.(mp3|flac|m4a|m4b|og static DOCUMENT: LazyLock = LazyLock::new(|| re(r"(?i)\.(pdf|epub|mobi|azw3?|djvu|cbr|cbz|cb7|chm|lit|docx?)$")); /// An image set's name without a counter: up to the last number, without numbers. -/// `/\d/.test(stem) ? stem.replace(/\d+\D*$/, '') : ''`: an image's set, digits kept. -fn image_set(stem: &str) -> String { - let b = stem.as_bytes(); - match b.iter().rposition(u8::is_ascii_digit) { +/// imageSet (lib/release/identity.ts): the name up to the number that counts the images, digits kept. +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(); + loop { + // /[_-](\d+)$/ + 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'-') { + break; + } + let n = &s[s.len() - digits..]; + let before = &s[..s.len() - digits - 1]; + // /(\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); + let previous = &before[start..=last]; + let same = trim_zeros(n) == trim_zeros(previous); + let year = n.len() == 4 && (n.starts_with(b"19") || n.starts_with(b"20")); + if same || year || n.len() >= 4 { + let cut = before.len(); + s.truncate(cut); + } else { + break; + } + } + match s.iter().rposition(u8::is_ascii_digit) { Some(last) => { - let start = b[..=last].iter().rposition(|c| !c.is_ascii_digit()).map_or(0, |i| i + 1); - stem[..start].to_string() + 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(), } } +/// Number(digits) equality for digit strings: without leading zeros (all zeros: "0"). +fn trim_zeros(d: &[u8]) -> &[u8] { + let t = d.iter().position(|&c| c != b'0').map_or(&d[d.len().saturating_sub(1)..], |i| &d[i..]); + t +} + +static OF_TOTAL: LazyLock = LazyLock::new(|| re(r"(?i)([0-9]+)SP*ofSP*[0-9]+")); + fn image_prefix(stem: &str) -> String { let b = stem.as_bytes(); let cut = match b.iter().rposition(u8::is_ascii_digit) { @@ -1130,6 +1160,25 @@ mod image_set_tests { assert!(!random_poster("w.merklein@freenet.de (muffin)")); } + #[test] + fn image_sets_without_suffixes() { + use super::image_set; + for (stem, want) in [ + ("stp250x001", "stp250x"), + ("cutie-071-032", "cutie-071-"), + ("katelg010_10", "katelg"), + ("kate112lg035_35", "kate112lg"), + ("!!hgrscans1097_2009", "!!hgrscans"), + ("pj02kristina_24of40_1600", "pj02kristina_"), + ("keira_s04_l keira_s04_0040_l", "keira_s04_l keira_s04_"), + ("IMG_2676", "IMG_"), + ("2877-041", "2877-"), + ("JimAndJoey Kindly Husband-to-Be", ""), + ] { + assert_eq!(image_set(stem), want, "{stem}"); + } + } + #[test] fn image_sets_by_their_numbered_name() { let silver = |n: &str| release_identity("p", &format!(r#"The excellent STIRILING SILVER - Always popular - "{n}" yEnc 320794 bytes"#), n); diff --git a/lib/release/identity.ts b/lib/release/identity.ts index f82feae..4a350c7 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -141,6 +141,27 @@ const DOCUMENT = /\.(pdf|epub|mobi|azw3?|djvu|cbr|cbz|cb7|chm|lit|docx?)$/i /** Dates in names: 2011-08-29, 08-29-2011 or 29.08.2011, 02-2011. */ const DATES = /\b(?:(?:19|20)\d{2}[-._ ](?:0?[1-9]|1[0-2])[-._ ]\d{1,2}|\d{1,2}[-._ ]\d{1,2}[-._ ](?:19|20)\d{2}|\d{1,2}[-._](?:19|20)\d{2})\b/g +/** + * 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)). + */ +export function imageSet(stem: string): string { + let s = stem.replace(/(\d+)\s*of\s*\d+/gi, '$1') + while (true) { + const m = /[_-](\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] + if (Number(n) === Number(previous[1]) || /^(?:19|20)\d\d$/.test(n) || n.length >= 4) s = before + else break + } + return /\d/.test(s) ? s.replace(/\d+\D*$/, '') : '' +} + export function counterTotal(subject: string): number | undefined { const s = subject.replace(/"[^"]*"/g, ' ') const plain = /\[(\d+)\/(\d+)\]|\((\d+)\/(\d+)\)/.exec(s) @@ -257,7 +278,7 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { // 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)). - const set = image && /\d/.test(stem) ? stem.replace(/\d+\D*$/, '') : '' + const set = image ? imageSet(stem) : '' return { key: hash('subject', poster, remainder, total ?? set), name: remainder, diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index e9c55a3..0d7bad1 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -222,6 +222,22 @@ test.group('release / par2 names', () => { assert.equal(mint('JimAndJoey Kindly Husband-to-Be.JPG').key, mint('Oops OnlyOneMore.JPG').key) }) + test("an image's set: suffixes that don't count the images go first", async ({ assert }) => { + const { imageSet } = await import('#lib/release/identity') + for (const [stem, set] of [ + ['stp250x001', 'stp250x'], + ['cutie-071-032', 'cutie-071-'], + ['katelg010_10', 'katelg'], + ['kate112lg035_35', 'kate112lg'], + ['!!hgrscans1097_2009', '!!hgrscans'], + ['pj02kristina_24of40_1600', 'pj02kristina_'], + ['keira_s04_l keira_s04_0040_l', 'keira_s04_l keira_s04_'], + ['IMG_2676', 'IMG_'], + ['2877-041', '2877-'], + ['JimAndJoey Kindly Husband-to-Be', ''], + ]) assert.equal(imageSet(stem), set, stem) + }) + 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'))