diff --git a/edge/src/names.rs b/edge/src/names.rs index 99f3200..54fb79a 100644 --- a/edge/src/names.rs +++ b/edge/src/names.rs @@ -674,7 +674,9 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { let code = (1..=5).contains(&letters) && (2..=6).contains(&code_digits) && matches!(b.get(letters + code_digits), Some(b'-' | b'_' | b' ')); index_at(0) || (code && index_at(letters + code_digits + 1)) }; - let tag = !image && !numbered && !test(&AUDIO, name) && describes(&stem) && !looks_random(&stem) && is_poster_tag(&remainder, &stem); + // A hashed stem under text without a counter is the upload, the text a tag ("Seit 1753 Tagen - usenet4ever.info - …"). + let hashed = file_total.is_none() && test(&HASHED, &stem); + let tag = hashed || (!image && !numbered && !test(&AUDIO, name) && describes(&stem) && !looks_random(&stem) && is_poster_tag(&remainder, &stem)); let prefix = if image { image_prefix(&stem) } else { String::new() }; // An image set's subject may be one word, the model's name: /^[A-Za-z]{3,}$/. let one_word = image && remainder.len() >= 3 && remainder.bytes().all(|c| c.is_ascii_alphabetic()); @@ -697,14 +699,30 @@ pub fn release_identity(poster: &str, subject: &str, name: &str) -> Release { } // An audiobook's or album's tracks without a name around them: by their name without its numbers. if test(&AUDIO, name) { + // Scene tracks under a bare counter ("02-repiet_-_dont_think_twice_(extended_mix).mp3"): the artist's album. + if let Some(total) = post_total.filter(|&n| n != 0) { + if let Some(c) = SCENE_TRACK.captures(stem.as_bytes()) { + let artist = String::from_utf8(c[1].to_vec()).unwrap(); + return Release { key: hash(&["stem", poster, &artist, &total.to_string()]), name: artist, source: "stem", file_total }; + } + } let tracks = image_prefix(&stem); if !tracks.is_empty() { return Release { key: hash(&["stem", poster, &tracks]), name: tracks, source: "stem", file_total }; } } - Release { key: hash(&["stem", poster, &stem]), name: stem, source: "stem", file_total } + // A numbered volume as the extension, within the counter's total ("DN.VPL PCK.1508" of 2195): one upload. + let base = match (VOLUME_NUMBER.captures(stem.as_bytes()), post_total.filter(|&n| n != 0)) { + (Some(c), Some(total)) if number(&c[2]) <= total as f64 => String::from_utf8(c[1].to_vec()).unwrap(), + _ => stem.clone(), + }; + Release { key: hash(&["stem", poster, &base]), name: stem, source: "stem", file_total } } +static HASHED: LazyLock = LazyLock::new(|| re(r"(?i)^[0-9a-f]{8,}(?:_[0-9a-f]{6,}){2,}(?:-[0-9a-z]+)?$")); +static SCENE_TRACK: LazyLock = LazyLock::new(|| re(r"^[0-9]{1,3}[-_ .]+(.+?)(?:_-_| - |-)")); +static VOLUME_NUMBER: LazyLock = LazyLock::new(|| re(r"^(.*[^0-9. ])[. ]([0-9]{1,5})\.?$")); + static GENERIC: LazyLock = LazyLock::new(|| { re(r"(?i)^(video_ts|audio_ts|bdmv|certificate|hvdvd_ts|samples?|subs?|subtitles?|proof|cover|covers|extras?|featurettes?|(cd|dvd|disc|disk)[ ._-]?[0-9]{1,2})$") }); @@ -1254,6 +1272,22 @@ mod image_set_tests { assert_ne!(vlad("185", 77, "P8250001.JPG").key, vlad("234", 77, "P5300085.JPG").key); } + #[test] + fn hashed_stems_volume_numbers_and_scene_tracks() { + let u4e = |n: &str| release_identity("p", &format!(r#"Seit 1753 Tagen - usenet4ever.info - "{n}" yEnc"#), n); + assert_eq!(u4e("055631a1_b6c820bc_a856086c_85112a5-u4e.part01.rar").key, u4e("055631a1_b6c820bc_a856086c_85112a5-u4e.par2").key); + assert_ne!(u4e("055631a1_b6c820bc_a856086c_85112a5-u4e.part01.rar").key, u4e("4cc7c4c6_d3c2fecf_0bff51d6_d10df86-u4e.part01.rar").key); + let pck = |i: &str, n: &str| release_identity("p", &format!(r#"(????) [{i}/2195] - "{n}" yEnc"#), n); + assert_eq!(pck("0659", "DN.VPL PCK.1508").key, pck("0660", "DN.VPL PCK.1509").key); + assert_eq!(pck("0659", "DN.VPL PCK.1508").name, "DN.VPL PCK.1508"); + let movie = |i: &str, n: &str| release_identity("p", &format!(r#"[{i}/30] - "{n}" yEnc"#), n); + assert_ne!(movie("01", "Movie.2008").key, movie("02", "Movie.2009").key); + let track = |i: &str, n: &str| release_identity("p", &format!(r#"[{i}/14] - "{n}" yEnc"#), n); + assert_eq!(track("06", "02-repiet_-_dont_think_twice_(extended_mix).mp3").key, track("07", "03-repiet_-_other_song.mp3").key); + assert_eq!(track("06", "02-repiet_-_dont_think_twice_(extended_mix).mp3").name, "repiet"); + assert_eq!(track("01", "01-2bm_mq-real_rap_(intro).mp3").name, "2bm_mq"); + } + #[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/release/identity.ts b/lib/release/identity.ts index 54039d3..a7ccfeb 100644 --- a/lib/release/identity.ts +++ b/lib/release/identity.ts @@ -127,6 +127,10 @@ function hash(...parts: (string | number | undefined)[]) { return digest('sha1', parts.map((p) => p ?? '').join('\n')) } +const HASHED = /^[0-9a-f]{8,}(?:_[0-9a-f]{6,}){2,}(?:-[0-9a-z]+)?$/i +const SCENE_TRACK = /^\d{1,3}[-_ .]+(.+?)(?:_-_| - |-)/ +const VOLUME_NUMBER = /^(.*[^\d. ])[. ](\d{1,5})\.?$/ + /** Image files: a set's images are named one by one ("IMG_3086.jpg"); its subject names the set. */ const IMAGE = /\.(jpe?g|png|gif|bmp|webp|tiff?)$/i @@ -300,7 +304,10 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { // lessons were releases of their own; LOG.md 2026-10-11 (7)). Uploads hours apart are apart anyway. // Also after a disc code ("SC8367-12 - Wynette, Tammy - Singing My Song", karaoke discs). const numbered = /^(?:[A-Z]{1,5}\d{2,6}[-_ ])?\d{1,3}[ ._-]/.test(stem) - const tag = !image && !numbered && !AUDIO.test(input.name) && describes(stem) && !looksRandom(stem) && isPosterTag(remainder, stem) + // A hashed stem under text without a counter is the upload, the text a tag ("Seit 1753 Tagen - usenet4ever.info - + // "055631a1_b6c820bc_a856086c_85112a5-u4e.part01.rar"": a day's uploads were one release; LOG.md 2026-10-12 (11)). + const hashed = fileTotal === undefined && HASHED.test(stem) + const tag = hashed || (!image && !numbered && !AUDIO.test(input.name) && describes(stem) && !looksRandom(stem) && isPosterTag(remainder, stem)) // An image set without a counter: the image's name up to its last number, without numbers // ("Kitchen Hottie 1 set233-76-lg" → "Kitchen Hottie set", "bm_jessica_blb_h087" → "bm jessica blb h"). const prefix = image ? stem.replace(/\d+\D*$/, '').replace(/\d+/g, ' ').replace(/[\s._-]+/g, ' ').trim() : '' @@ -332,12 +339,20 @@ export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { // 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)) { + // Scene tracks under a bare counter ("[06/14] - "02-repiet_-_dont_think_twice_(extended_mix).mp3""): the + // artist's album, bounded by the counter's total (each track was a release of its own). + 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' } } // The [i/M] total is informational, not part of the key: posters often // upload the par2 index without a counter, and it belongs to the same post. - return { key: hash('stem', poster, stem), name: stem, fileTotal, source: 'stem' } + // A numbered volume as the extension, within the counter's total ("(????) [0659/2195] - "DN.VPL PCK.1508"", + // "TKY.ASN PCK.691."): the volumes are one upload. + const volume = VOLUME_NUMBER.exec(stem) + const base = volume && postTotal && Number(volume[2]) <= postTotal ? volume[1] : stem + return { key: hash('stem', poster, base), name: stem, fileTotal, source: 'stem' } } /** Release of a par2 recovery set; every file listed in it belongs to it. */ diff --git a/tests/unit/release/identity.spec.ts b/tests/unit/release/identity.spec.ts index 2808aa7..4b9a7d0 100644 --- a/tests/unit/release/identity.spec.ts +++ b/tests/unit/release/identity.spec.ts @@ -276,6 +276,22 @@ test.group('release / par2 names', () => { assert.notEqual(vlad('185', 77, 'P8250001.JPG').key, vlad('234', 77, 'P5300085.JPG').key) }) + test('hashed stems under a tag, volume numbers as extensions, scene tracks under a bare counter', ({ assert }) => { + const id = (subject: string, name: string) => releaseIdentity({ poster: 'p', subject, name, weak: false })! + const u4e = (n: string) => id(`Seit 1753 Tagen - usenet4ever.info - "${n}" yEnc`, n) + assert.equal(u4e('055631a1_b6c820bc_a856086c_85112a5-u4e.part01.rar').key, u4e('055631a1_b6c820bc_a856086c_85112a5-u4e.par2').key) + assert.notEqual(u4e('055631a1_b6c820bc_a856086c_85112a5-u4e.part01.rar').key, u4e('4cc7c4c6_d3c2fecf_0bff51d6_d10df86-u4e.part01.rar').key) + const pck = (i: string, n: string) => id(`(????) [${i}/2195] - "${n}" yEnc`, n) + assert.equal(pck('0659', 'DN.VPL PCK.1508').key, pck('0660', 'DN.VPL PCK.1509').key) + assert.equal(pck('0659', 'DN.VPL PCK.1508').name, 'DN.VPL PCK.1508') + const movie = (i: string, n: string) => id(`[${i}/30] - "${n}" yEnc`, n) + assert.notEqual(movie('01', 'Movie.2008').key, movie('02', 'Movie.2009').key) + const track = (i: string, n: string) => id(`[${i}/14] - "${n}" yEnc`, n) + assert.equal(track('06', '02-repiet_-_dont_think_twice_(extended_mix).mp3').key, track('07', '03-repiet_-_other_song.mp3').key) + assert.equal(track('06', '02-repiet_-_dont_think_twice_(extended_mix).mp3').name, 'repiet') + assert.equal(track('01', '01-2bm_mq-real_rap_(intro).mp3').name, '2bm_mq') + }) + 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)