From 9f255e6ac0d8ba63b15744b57dc7ab8e59359eab Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Tue, 13 Oct 2026 23:54:02 +0200 Subject: [PATCH] Images and tracks named by a prefix alone are bounded by the counter's total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "[…/559] - "lop1001.jpg"", "pic004.jpg", "DSC_0002[1].jpg", and tracks "r86_022 - Gwen Guthrie - ….mp3" ("r"): the stem route keyed them by the letters before the number alone, so every post of a poster was one release (5,731 images for 559; 6,433 tracks for 457). Under a counter its total is part of the key; a cover named as its archive's stem keeps the stem's key. Co-Authored-By: Claude Opus 5.5 --- edge/src/names.rs | 25 +++++++++++++++++++++++-- lib/release/identity.ts | 12 ++++++++++-- tests/unit/release/identity.spec.ts | 10 ++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/edge/src/names.rs b/edge/src/names.rs index f7e64a0..0bcaad3 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -847,7 +847,16 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { // Under a post token ("MDG005b - (01/90) "IMG_2307.JPG""): the post's too. // A word in it, not a size ("… alexis001.jpg 57295b"). let post = is_post_token(&remainder) && remainder.as_bytes().windows(2).any(|w| w[0].is_ascii_alphabetic() && w[1].is_ascii_alphabetic()); - let key = if post { hash(&["stem", poster, &set, &remainder]) } else { hash(&["stem", poster, &set]) }; + // Under a counter, bounded by its total ("lop1001.jpg", "pic004.jpg": every post of the poster one release). + let mut parts: Vec<&str> = vec!["stem", poster, &set]; + if post { + parts.push(&remainder); + } + // Not a cover named as its archive's stem ("x.jpg" beside "x.part01.rar"). + if let Some(t) = total.as_deref().filter(|_| set != stem) { + parts.push(t); + } + let key = hash(&parts); 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. @@ -861,7 +870,12 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { } let tracks = image_prefix(&stem); if !tracks.is_empty() { - return Release { key: hash(&["stem", poster, &tracks]), name: tracks, source: "stem", file_total }; + // Bounded by a counter's total too ("r86_022 - Gwen Guthrie - … .mp3" → "r"). + let key = match total.as_deref() { + Some(t) => hash(&["stem", poster, &tracks, t]), + None => hash(&["stem", poster, &tracks]), + }; + return Release { key, name: tracks, source: "stem", file_total }; } } // A season's episodes under a bare counter ("Pokemon - S02E01 - Pallet Party Panic.mkv"): per show, season and total. @@ -1546,6 +1560,13 @@ mod image_set_tests { ); let jb = |i: &str, n: &str| super::release_identity("JBinUp", &format!(r#"[{i}/58] - Summer Power Up - "{n}" yEnc"#), n); assert_eq!(jb("01", "www.realmom.info - XVID- Farbiges Krabbeltier.r00").key, jb("54", "www.realmom.info - XVID-Farbiges Krabbeltier.vol03+04.par2").key); + let lop = |i: &str, total: &str, n: &str| super::release_identity("p", &format!(r#"[{i}/{total}] - "{n}" yEnc"#), n); + assert_eq!(lop("001", "559", "lop1001.jpg").key, lop("002", "559", "lop1002.jpg").key); + assert_ne!(lop("001", "559", "lop1001.jpg").key, lop("001", "1018", "lop5001.jpg").key); + assert_ne!( + lop("022", "457", "r86_022 - Gwen Guthrie - Ain't Nothing Going on but the Rent.mp3").key, + lop("022", "333", "r98_333 - Jellie - Roller Coaster.mp3").key + ); let mdg = |post: &str, i: &str, n: &str| super::release_identity("p", &format!(r#"{post} - ({i}/90) "{n}" - 16,14 MB - yEnc"#), n); assert_eq!(mdg("MDG005b", "01", "IMG_2307.JPG").key, mdg("MDG005b", "02", "IMG_2308.jpg").key); assert_ne!(mdg("MDG005b", "01", "IMG_2307.JPG").key, mdg("MDG006a", "01", "IMG_4410.JPG").key); diff --git a/lib/release/identity.ts b/lib/release/identity.ts index 44bcdc8..fe5723a 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -437,7 +437,13 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { const set = imageSet(stem) || prefix || stem // A word in it, not a size ("01of37 ## Alexi … ## alexis001.jpg 57295b": each image was a release of its own). const post = isPostToken(remainder) && /[A-Za-z]{2}/.test(remainder) - return { key: post ? hash('stem', poster, set, remainder) : hash('stem', poster, set), name: prefix || stem, fileTotal, source: 'stem' } + // Under a counter, bounded by its total: a set named only by its prefix ("lop1001.jpg", "pic004.jpg", "DSC_0002[1].jpg") + // made every post of the poster one release (5,731 images for 559; LOG.md 2026-10-13 (50)). + const parts: (string | number)[] = ['stem', poster, set] + if (post) parts.push(remainder) + // Not a cover named as its archive's stem ("x.jpg" beside "x.part01.rar"): that keeps the stem's key. + if (fileTotal !== undefined && set !== stem) parts.push(fileTotal) + return { key: hash(...parts), 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"). @@ -447,7 +453,9 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { const scene = postTotal ? SCENE_TRACK.exec(stem) : null if (scene) return { key: hash('stem', poster, scene[1], postTotal), name: scene[1], fileTotal, source: 'stem' } const tracks = stem.replace(/\d+\D*$/, '').replace(/\d+/g, ' ').replace(/[\s._-]+/g, ' ').trim() - if (tracks) return { key: hash('stem', poster, tracks), name: tracks, fileTotal, source: 'stem' } + // Bounded by a counter's total too ("r86_022 - Gwen Guthrie - Ain't Nothing….mp3" → "r": a chart's 457 tracks a post, + // every post one release). + if (tracks) return { key: fileTotal !== undefined ? hash('stem', poster, tracks, fileTotal) : hash('stem', poster, tracks), name: tracks, fileTotal, source: 'stem' } } // A season's episodes under a bare counter ("[01/36] "Pokemon - S02E01 - Pallet Party Panic.mkv""): one release per // show, season and counter total (each episode was a release of its own). diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index 2fad40b..73d8db2 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -433,6 +433,16 @@ test.group('release / par2 names', () => { assert.equal(alexi('01', '57295b').key, alexi('02', '32202b').key) }) + test('a set named by its prefix alone is bounded by its counter', ({ assert }) => { + const id = (subject: string, name: string) => releaseIdentity({ poster: 'p', subject, name, weak: false })! + const lop = (i: string, total: string, n: string) => id(`[${i}/${total}] - "${n}" yEnc`, n) + assert.equal(lop('001', '559', 'lop1001.jpg').key, lop('002', '559', 'lop1002.jpg').key) + assert.notEqual(lop('001', '559', 'lop1001.jpg').key, lop('001', '1018', 'lop5001.jpg').key) + const r = (i: string, total: string, n: string) => id(`[${i}/${total}] - "${n}" yEnc`, n) + assert.equal(r('022', '457', "r86_022 - Gwen Guthrie - Ain't Nothing Going on but the Rent.mp3").key, r('085', '457', "r88_085 - Gregory Abbott - I'll Prove It to You.mp3").key) + assert.notEqual(r('022', '457', "r86_022 - Gwen Guthrie - Ain't Nothing Going on but the Rent.mp3").key, r('022', '333', "r98_333 - Jellie - Roller Coaster.mp3").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)