import { hash as digest } from 'node:crypto' import { looksLikeFilename } from '../headers/file_key.js' import { looksRandom } from './random.js' export interface ReleaseIdentity { key: string /** Human-readable name: the subject text or the shared filename stem. */ name: string /** Number of files announced by the `[i/M]` counter, when present. */ fileTotal?: number /** Which rule recognised the release. */ source: 'subject' | 'post' | 'stem' | 'weak-stem' | 'par2' | 'rar' | 'nzb' | 'spot' } export interface ReleaseInput { poster: string /** Subject without segment counter (the binary's `subject`). */ subject: string /** Filename of the binary. */ name: string /** True when the identity came from the yEnc body (obfuscated post). */ weak: boolean } // Suffixes that differ between the files of one release. Stripped repeatedly, // so "x.mkv.vol03+04.par2" and "x.part07.rar" both reduce to "x". const SUFFIXES = [ // A part's par2 files ("X.part1.par2", "X.part1.vol00+34.par2" beside "X.part1.rar": 683 single files in a.b.test), and // a par2 named after "X.part" or "X.par" ("goh1Phoz5ahXu.par.vol006+08.par2" beside "goh1Phoz5ahXu.r00", NewsUP). /\.part\d+(?:\.vol\d+[+-]\d+)?\.par2$/i, /\.(?:part|par)(?:\.vol\d+[+-]\d+)?\.par2$/i, /\.vol\d+[+-]\d+\.par2$/i, /\.par2$/i, // Recovery volumes too ("chappelle1.part02.rev"). /\.part\d+\.(?:rar|rev)$/i, /\.r\d{2,3}$/i, /\.(?:rar|rev)$/i, // BCArchive volumes ("NZPECQUztWOponTpCratvvTPDBXBjvHIULOUPvSH.180.bc"). /(?:\.\d{1,4})?\.bc$/i, /\.7z(\.\d{3})?$/i, /\.zip(\.\d{3})?$/i, // Split volumes (x.mkv.001), not codec names (H.264, H.265). // Also a split rar volume ("rld-gtre.r13.002": 1,620 single files of usenet-town's posts). /(?<=\.(7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob|r\d{2,3}))\.\d{2,3}$/i, /\.(nfo|sfv|nzb|srr|srs|txt|jpg|png|md5|sha1|srt|sub|idx|ass|ssa|m3u|m3u8|cue|log)$/i, /\.tar$/i, /\.(zst|gz|xz|bz2)$/i, /\.(mkv|mp4|avi|m4v|ts|iso|img|bin|flac|mp3|epub|pdf)$/i, ] /** Strips per-file suffixes (volume numbers, archive and par2 extensions) from a filename. */ export function fileStem(name: string): string { // Old-style rar volumes after .r99 ("BD_NoTHing018.s41", "….s00", ".t91", ".v81"): only as the file's own extension, in // lower case, so a season ("show.s01.rar", "Show.S01") stays. Split zip volumes (".z18"), and .xNN but not the builds // ".x32", ".x64", ".x86" ("Licence 700790900887990.z18", "….x31"; "….v1.0.142.x64" stays). // Also three digits ("Reply 987986177631730.x004"), but not ".x264", ".x265". let stem = name.trim().replace(/\.(?:[s-v]\d{2}|z\d{2,3}|x(?!(?:32|64|86|264|265)$)\d{2,3})$/, '') let changed = true while (changed) { changed = false for (const suffix of SUFFIXES) { const next = stem.replace(suffix, '') if (next !== stem && next.length > 0) { stem = next changed = true } } } return stem } /** * What remains of a subject once the filename, counters, "yEnc" and sizes are * removed. Posters that name the release outside the quotes put it here. */ /** A file counter's text when its first number is at most its second, else the text itself (a date like [1948-12] stays). */ const counter = (text: string, i: string, n: string) => (Number(i) <= Number(n) ? ' ' : text) export function subjectRemainder(subject: string): string { return subject .replace(/"[^"]*"/g, ' ') .replace(/\[\d+\/\d+\]|\(\d+\/\d+\)/g, ' ') // Counters written out: "File 027 of 132", "[21 of 25]", "104 of 472", "[2239-6389]", "02/17", // "0097-1574" (each image of a set was a release of its own; LOG.md 2026-10-09 (9)). .replace(/[[(]?\b(?:file\s+)?(\d+)\s*of\s*(\d+)\b[\])]?/gi, counter) .replace(/\[(\d+)-(\d+)\]/g, counter) .replace(/\b(\d+)\/(\d+)\b/g, counter) .replace(/\b(0\d*)-(\d+)\b/g, counter) // File sizes ("76.88 MB", "335555 Bytes", "336573 byt" cut off, "[347K]") differ per file. .replace(/\b\d+(?:[.,]\d+)?\s*(?:[kmgt]i?b|bytes?|byt)\b/gi, ' ') .replace(/\[\s*\d+(?:[.,]\d+)?\s*[kmgt]\s*\]/gi, ' ') // A size without a unit right before the closing yEnc ("…mp3" - 8123 yEnc"); a short number after a word is a // set's ("… set 1 [1 of 60] "destiny-001-001.jpg" yEnc": 97 sets were one release; LOG.md 2026-10-12 (8)). .replace(/\b(\d+)(\s+yEnc\s*)$/i, (text, n: string, end: string, at: number, s: string) => n.length >= 4 || !/[A-Za-z0-9]\s*$/.test(s.slice(0, at)) ? end : text ) // File sizes follow "yEnc" at the end; other numbers (years, post ids) stay. .replace(/\byEnc\b(\s+\d+)?\s*$/i, ' ') .replace(/\byEnc\b/gi, ' ') .replace(/[\s\-–.:|]+/g, ' ') // What's left of "["name.jpg" yEnc]" and of counters and sizes in brackets. .replace(/ ?(?:\[ ?\]|\( ?\)) ?/g, ' ') .replace(/ {2,}/g, ' ') .trim() } /** * Posting tools put random per-file tokens and poster IDs in subjects (e.g. * `elys-de81bcabef5faf6a - [Nyh5Q07Y]`); only text that still reads like a * name once those are removed identifies a release. */ function isRandomToken(token: string): boolean { const t = token.replace(/^\[|\]$/g, '') if (t.length < 6) return false if (/^[0-9a-f]+$/i.test(t) && /\d/.test(t)) return true return /\d/.test(t) && /[a-z]/.test(t) && /[A-Z]/.test(t) && !/[._-]/.test(t) } /** * A stable per-post token: one short run of letters/digits with at least one * digit. Requiring a digit keeps constant labels like `[PRiVATE]` from * grouping different posts of the same poster. */ export function isPostToken(remainder: string): boolean { const compact = remainder.replace(/[\s\[\]()_-]+/g, '') return compact.length >= 6 && compact.length <= 40 && /\d/.test(compact) && /^[A-Za-z0-9]+$/.test(compact) } export function namesRelease(remainder: string): boolean { const words = remainder .split(/\s+/) .filter((w) => w && !isRandomToken(w)) .join(' ') // At least two words of letters, joined the way release names are (or as text is: "Posting & deleting, read 0 file"). return /[A-Za-z]{2,}[._ \-,&:;\\/+]+[A-Za-z0-9]{2,}/.test(words) } function hash(...parts: (string | number | undefined)[]) { return digest('sha1', parts.map((p) => p ?? '').join('\n')) } const DATE_LED = /^\d{1,2}[-._]\d{1,2}[-._](?:19|20)\d{2}(?!\d)/ const VIDEO = /\.(mkv|mp4|avi|m4v|ts|wmv|mov)$/i const EPISODE = /^(.+?)[ ._-]+S(\d{1,2})E\d{1,3}/i /** A host and a sponsor in a subject's remainder (its dots and dashes spaces): "(www Thunder News org)", "". */ const BOILERPLATE = /\(?\bwww(?: [A-Za-z0-9]+)*? (?:org|com|net|info|nl|de|eu|to|biz|us)\b\)?|?/gi // Also one long hex run ("German Board - [Usenet4ever.info] und [SecretUsenet.com] - "6c57319fbae505679737bd46a76193a1-u4e.part6.rar"": // 11 uploads were one release of 14,450 files; LOG.md 2026-10-12 (24)). const HASHED = /^(?:[0-9a-f]{8,}(?:_[0-9a-f]{6,}){2,}|[0-9a-f]{24,})(?:-[0-9a-z]+)?$/i const SCENE_TRACK = /^\d{1,3}[-_ .]+(.+?)(?:_-_| - |-)/ /** A scene music release's name: the artist, then its source (web, cd, vinyl, …) and the year, last the group. */ const SCENE_RELEASE = /^(.+?)(?:_-_| - |-).*-(?:web|cd[a-z]?|vinyl|vls|sat|cable|fm|dab|single|ep|lp|promo|bootleg)-.*(?:19|20)\d{2}(?:-[A-Za-z0-9_]+)?$/i 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 /** Audio tracks: parts of an album or audiobook that the subject names ("072 - Die Teufelsuhr.MP3 - by DeckaR"). */ const AUDIO = /\.(mp3|flac|m4a|m4b|ogg|opus|wav|aac|wma|ape)(?:$|[^a-z0-9])/i /** Documents and comics: each file is an item of its own, whatever the subject says ("cw-misc [020/109] "Action Comics 710.cbr""). */ const DOCUMENT = /\.(pdf|epub|mobi|azw3?|djvu|cbr|cbz|cb7|chm|lit|docx?)$/i /** * The total of the subject's file counter, in the forms subjectRemainder * removes, the first form found first: [i/M] or (i/M), "i of M", [i-M], i/M, * 0i-M (the last four only when i <= M). */ /** 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)); 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)); a * counter in the name ends the set, and copy markers, resolutions and "_00" go first (LOG.md 2026-10-12 (6)). */ 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", // "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) if (counted) return plain.slice(0, counted.index) // 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) { // 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[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[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 } const set = /\d/.test(s) ? s.replace(/\d+\D*$/, '') : '' // A number of 5+ digits (without leading zeros) left in it is an id, per image ("sss-70556-3474182-23-10825418-pc", // "Alena p 26663_…"): no set. A padded set number ("01079-106") is one. return /(? 1 && total.startsWith('0') && total.length > index.length } export function counterTotal(subject: string): number | undefined { const s = subject.replace(/"[^"]*"/g, ' ') const plain = /\[(\d+)\/(\d+)\]|\((\d+)\/(\d+)\)/.exec(s) if (plain) return brokenTotal(plain[1] ?? plain[3], plain[2] ?? plain[4]) ? undefined : Number(plain[2] ?? plain[4]) // Dates are no counters ("Avira AntiVir Rescue System 08-29-2011.rar", "… 27-02-2011", "18-08-2012"): // read as "8 of 29", such a release showed a wrong file total and counted as incomplete. const undated = s.replace(DATES, ' ') for (const pattern of [/[[(]?\b(?:file\s+)?(\d+)\s*of\s*(\d+)\b/gi, /\[(\d+)-(\d+)\]/g, /\b(\d+)\/(\d+)\b/g, /\b(0\d*)-(\d+)\b/g]) { for (const m of undated.matchAll(pattern)) if (Number(m[1]) <= Number(m[2])) return Number(m[2]) } return undefined } /** A name that says what it is: 12+ letters and digits, two words of 3+ letters. */ function describes(stem: string): boolean { return stem.replace(/[^A-Za-z0-9]/g, '').length >= 12 && /[A-Za-z]{3,}[^A-Za-z0-9]+[A-Za-z]{3,}/.test(stem) } /** Words of a subject that say nothing about what is posted: hosts, posting boilerplate. */ const TAG_WORDS = new Set(['www', 'http', 'https', 'com', 'net', 'org', 'info', 'the', 'and', 'presents', 'posted', 'post', 'powered', 'yenc', 'part', 'rar', 'par2', 'nzb', 'voor', 'for', 'from']) /** * Whether the subject's text outside the quotes is a poster's constant tag * rather than about the file: it shares no word with the file's stem. A word * of 4+ letters may be part of the stem ("ketchup" in "ketchup_dance"); a * 3-letter word or a number ("35" in "35_China_dl") must be a whole word; * boilerplate doesn't count. A number of 5+ digits is a per-post id * ("FTD 769997", "(781442)"), so never a constant tag. */ export function isPosterTag(remainder: string, stem: string, counted = false): boolean { const words = stem.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() const compact = words.replace(/ /g, '') const whole = new Set(words.split(' ')) // A per-post id, also inside a word ("FTD772544"), under a counter. Without one it's the board's or the dump's // (" - ID - 1855891249 - Along.The.Ridge.2006.Limited.PAL.DVDr-TWCiSO.rar": thousands of // releases were one of 9,444 files; LOG.md 2026-10-13 (36)). if (counted && /\d{5,}/.test(remainder)) return false let significant = 0 let shared = 0 for (const word of remainder.toLowerCase().split(/[^a-z0-9]+/)) { if (/^\d+$/.test(word)) { if (word.length >= 2 && whole.has(word)) return false continue } if (word.length < 3 || TAG_WORDS.has(word)) continue significant++ if (word.length >= 4 ? compact.includes(word) : whole.has(word)) shared++ } // Without a counter, a tag of 4+ words may share one with the stem ("German Board - [Usenet4ever.info] und [SecretUsenet.com] - // "21.Jump.Street.2012.BDRip.AC3.German.XviD-POE.part01.rar"": the poster's uploads were releases of 1,535 files). // With one, that's a name ("[ MacGyver.S01E03.DVDRip.XviD-MEDiEVAL ]-[04/38] - "sample.macgy.s01e03-med.avi""). return shared === 0 || (shared === 1 && significant >= 4 && !counted) } /** * Decides which release (download) a file belongs to. Returns null when the * file cannot be grouped from its name alone (random extensionless names in * obfuscated posts; those need par2 matching). See CONCEPT.md (D16). */ /** * A poster whose own name (before the "@") is a random token, as uploaders * that draw a new poster per file do ("N9vplIaavBCz@aCTVoAsk.JSE"): it says * nothing about which upload a file is in, so it's no part of a release key * (two files of one FTD upload were releases apart; LOG.md 2026-10-11 (8)). */ export function randomPoster(from: string): boolean { const open = from.indexOf('<') const close = open === -1 ? -1 : from.indexOf('>', open + 1) const address = close !== -1 ? from.slice(open + 1, close) : from.trim().split(' ')[0] const at = address.indexOf('@') // Separators don't make a token less random ("SOFZRw1-OTEd", "vMgGYlZB9ZcTNk8WxYFGV_OOW"). return looksRandom((at === -1 ? address : address.slice(0, at)).replace(/[-_.]/g, '')) } /** An archive's or par2's extension, where a name that runs on into a title is cut (runOn). */ const RUN_ON = /\.(?:part\d+\.rar|vol\d+[+-]\d+\.par2|par2|rar|r\d{2,3}|7z(?:\.\d{3})?|zip|nzb|nfo|sfv)/gi /** * A quoted name that runs on past its extension into a title ("lois 1258 Billy Joel - Extremes.part01.rar lois 1258 Billy * Joel - Extremes ", "GrooveCoverage.part26.rarGrooveCoveragebestof", "Goga Sekulic.part134.rar - Goga Sekulic - …": each * part a release of its own): the name up to that extension, when a space follows it, or the name's own start. Not a * bare dash ("Angry.Birds.Rio.v1.2.2.cracked.READ.NFO-THETA.rar" keeps its group). */ export function runOn(name: string): string { const head = name.slice(0, 4).toLowerCase() const ascii = /^[\x00-\x7f]{4}$/.test(head) for (const m of name.matchAll(RUN_ON)) { const end = m.index + m[0].length const rest = name.slice(end) if (rest.length === 0) return name if (/^\s/.test(rest) || (ascii && rest.slice(0, 4).toLowerCase() === head)) return name.slice(0, end) } return name } /** The subject without a number after the file's unquoted name at its end (" - 28": a size in kB). */ export function sizeAfterName(subject: string, name: string): string { const end = subject.trimEnd() const digits = end.length - end.replace(/\d+$/, '').length if (digits === 0) return subject const before = end.slice(0, end.length - digits).replace(/\s*-\s*$/, '') return before.length < end.length - digits && name.length > 0 && before.endsWith(name) && !before.endsWith(`"${name}"`) ? before : subject } export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null { const poster = randomPoster(input.poster) ? '' : input.poster 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 && !brokenTotal(counter[1], counter[2]) ? Number.parseInt(counter[2], 10) : undefined const stem = fileStem(runOn(input.name)) if (input.weak) { // Obfuscating posters randomise the poster per article; a real filename // stem is specific enough on its own. if (!looksLikeFilename(input.name)) return null return { key: hash('weak', stem), name: stem, source: 'weak-stem' } } // Text outside the quotes that names the release, e.g. "Some.Release [01/45] - "x.rar"". A name that isn't // quoted (unquotedName: "DVDFab 8.2.0.5.rar (006/332)", "[keira_s04_0037_l.jpg" yEnc] (2/2)") is taken out // 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() // A size after a name that isn't quoted, at the end ("[27/70] - 2407.jpg - 28": each image a release of its own). const subject = sizeAfterName(input.subject, input.name) // Other quoted text stays: it names the release (">>> CREATIVE COMMONS NZB <<< "0190 alarm 5.0 " - File 1 of 9: // "creative_commons_nzb_0190_alarm_5.0_.rar"": 12,605 files of 1,400 uploads were one release; LOG.md 2026-10-12 (9)). const named = ( subject.includes(`"${input.name}"`) ? subject.replace(`"${input.name}"`, ' ') : subject.includes(input.name) ? subject.replace(input.name, ' ') : subject.replace(collapsed, ' ') ) // The name repeated elsewhere ("(NELET11.par2) [01/58] - "NELET11.par2"": each file a release of its own). .split(input.name.length >= 5 ? input.name : '\0') .join(' ') .replace(/"/g, ' ') // A file's size in brackets after its counter ("[26/32] - [4929] "…part24.rar"": each file a release; Kingdn). .replace(/(\[\d+\/\d+\])\s*-\s*\[\d+\]/g, '$1') // A per-file hex id in brackets ("[PRiVATE] Rosewood.S02E08… [1f1793f68d5321] [newzNZB] [01/22]": 32,101 single files). .replace(/\[[0-9a-f]{8,}\]/gi, ' ') // 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) // Without a counter a host and a sponsor name no upload ("(www.Thunder-News.org) >XXX< - // "ps-itb10b.r00"": with no total to bound them, a poster's uploads were one release of 1,135 files; LOG.md 2026-10-12 (23)). const remainder = fileTotal === undefined ? subjectRemainder(named).replace(BOILERPLATE, ' ').replace(/ {2,}/g, ' ').trim() : subjectRemainder(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) // The poster's constant tag ("[KiLL.dA…]---- (01/10) "Album.rar"", "(X Posted)") would make one // release of all their uploads: a readable file name that shares no word with it says which // release the file is (LOG.md 2026-10-09 (8)). Only when the stem itself describes something // ("Cephalgy.-.Weisses.Fleisch.2003", not a scene abbreviation like "grp-sm" under // "Some.Movie.2008-GRP", nor "file_id.diz"), and not for images and audio tracks, which the // subject names as a set or album ("IMG_3086.jpg", "072 - Die Teufelsuhr.MP3"), nor for numbered items of // a set ("ai-engineer-complete [401/465] - "003 Defining Tools with JSON Schema.mp4"": a course's 465 // 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). // Not a date ("09-30-2008_BAK_JRS.r00": a daily show's 32,621 files were one release under the poster's tag). const dated = DATE_LED.test(stem) const numbered = /^(?:[A-Z]{1,5}\d{2,6}[-_ ])?\d{1,3}[ ._-]/.test(stem) && !dated // 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) // A dated stem says which upload it is, short as it is ("09-30-2008_CTC.r00" under "#sterntuary - Coast To Coast Radio Show"). const tag = hashed || (!image && !numbered && !AUDIO.test(input.name) && (describes(stem) || dated) && !looksRandom(stem) && isPosterTag(remainder, stem, fileTotal !== undefined)) // 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() : '' // An image set's subject may be one word, the model's name ("Corie - "2877-041.jpg" yEnc [290K]"). 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 = 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)). const set = image ? imageSet(stem) : '' return { key: hash('subject', poster, remainder, total ?? set), name: remainder, fileTotal, source: 'subject', } } // Not a name, but a post id (e.g. `83110629-n [10/18] - "random"`): with the // [i/M] counter it groups the files of one post whose names are random. if (!tag && postTotal && isPostToken(remainder)) { return { key: hash('post', poster, remainder, postTotal), name: remainder, fileTotal, source: 'post' } } // A scene music post's index files under a bare counter go with its tracks: the artist and the total ("[01/13] - // "00-ammar-turk_uit_de_kroeg-single-web-nl-2022-cover.jpg"" with "[05/13] - "01-ammar-turk_uit_de_kroeg.mp3"": the // cover, nfo, sfv and m3u of 2,600 Meow posts were releases of their own; LOG.md 2026-10-12 (28)). const sceneIndex = postTotal && /^00[-_ .]/.test(stem) ? SCENE_TRACK.exec(stem) : null if (sceneIndex) return { key: hash('stem', poster, sceneIndex[1], postTotal), name: sceneIndex[1], fileTotal, source: 'stem' } // And its par2 and sfv under the release's own name ("[07/14] - "blaise_mc-in_hellstill_3-ep-web-2022-known.par2"": the // par2 of 1,900 Meow and Cat posts was a release apart; LOG.md 2026-10-13 (40)), when the name reads as a scene music release. // Only a track post's lower-case name: a rar upload ("VA - Best Of Shamania Pro 2K16-(SHB 006)-WEB-2016-ZzZz.part01.rar", // "Ace_Of_Base-…-CDM-FLAC-1992-REETKEVER.par2") groups by its full name, and by artist and total its albums merged. const sceneRelease = postTotal && !AUDIO.test(input.name) && !/[A-Z\s]/.test(stem) ? SCENE_RELEASE.exec(stem) : null if (sceneRelease) return { key: hash('stem', poster, sceneRelease[1], postTotal), name: sceneRelease[1], fileTotal, source: 'stem' } // An image without words around it: its set by its name (keyed as a stem, so "x.jpg" joins "x.part01.rar"). // 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)). // Under a post token ("MDG005b - (01/90) "IMG_2307.JPG"": a counter in parentheses, no post key): the post's too, else // every set named "IMG" was one (609 images for 90; LOG.md 2026-10-13 (42)). if (image) { 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) // 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"). 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() // 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). const episode = postTotal && VIDEO.test(input.name) ? EPISODE.exec(stem) : null if (episode) { const season = `${episode[1].trim()} S${episode[2].padStart(2, '0')}` return { key: hash('stem', poster, season.toLowerCase(), postTotal), name: season, 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. // 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 // Without case and a scene index's "00-" ("00-laura_van_dam_-_capture_radio_113-cable-10-10-2026-talion.m3u" beside // "Laura_van_Dam_-_Capture_Radio_113-CABLE-10-10-2026-TALiON.par2": one upload, two releases; LOG.md 2026-10-12 (19)). // Nor spaces at a dash ("www.realmom.info - XVID- Farbiges Krabbeltier.r00" beside "… XVID-Farbiges Krabbeltier.vol03+04.par2": // 52 uploads were two releases each; LOG.md 2026-10-13 (46)). return { key: hash('stem', poster, base.toLowerCase().replace(/^00[-_ .]+/, '').replace(/ *- */g, '-')), name: stem, fileTotal, source: 'stem' } } /** Release of a par2 recovery set; every file listed in it belongs to it. */ export function par2ReleaseIdentity(recoverySetId: string, fileNames: string[]): ReleaseIdentity { return { key: hash('par2', recoverySetId), name: par2ReleaseName(fileNames) ?? recoverySetId, source: 'par2' } } const SEPARATOR = /[._ -]/ /** * A release name from the files a par2 set lists. Files that differ only in * the middle (episodes S03E01…S03E20, parts) give their shared name, with * the episode reduced to the season: SEAL.Team.S03E01.1080p…-nitrato.mkv to * SEAL.Team.S03E20.1080p…-nitrato.mkv give SEAL.Team.S03.1080p…-nitrato. * Otherwise the longest stem, which tends to be the most descriptive (the * video file). */ export function par2ReleaseName(fileNames: string[]): string | undefined { const stems = [...new Set(fileNames.map((n) => fileStem(n.split(/[\\/]/).pop() ?? n)))].filter(Boolean) if (stems.length === 0) return undefined const longest = [...stems].sort((a, b) => b.length - a.length)[0] if (stems.length < 2) return longest let prefix = stems.reduce((p, s) => { let i = 0 while (i < p.length && i < s.length && p[i] === s[i]) i++ return p.slice(0, i) }) let suffix = stems.reduce((p, s) => { let i = 0 while (i < p.length && i < s.length && p[p.length - 1 - i] === s[s.length - 1 - i]) i++ return p.slice(p.length - i) }) const shortest = Math.min(...stems.map((s) => s.length)) if (prefix.length + suffix.length > shortest) suffix = suffix.slice(prefix.length + suffix.length - shortest) // Season packs: "Show.S03E" → "Show.S03". Otherwise back to a whole token. const season = /^(.*[._ -]S\d{1,2})E\d*$/i.exec(prefix) if (season) prefix = season[1] else if (prefix.length < stems[0].length && !SEPARATOR.test(stems[0][prefix.length] ?? '.')) { prefix = prefix.replace(/[^._ -]*$/, '') } suffix = SEPARATOR.test(suffix[0] ?? '') ? suffix : suffix.replace(/^[^._ -]*/, '') prefix = prefix.replace(/[._ -]+$/, '') const name = suffix ? prefix + suffix : prefix // Only when the shared part carries most of the name (not just "The."). return name.length >= 8 && name.length >= 0.6 * shortest ? name : longest } /** * Release of a RAR volume set, from what its headers list: the shared * top-level directory if there is one, otherwise the first entry. For * volumes after the first, the first entry is the file that continues from * the previous volume, so all volumes of a single-file archive agree. */ /** * Folder and file names that say nothing about what a release is: disc * structures (VIDEO_TS on every DVD), samples, subtitles, disc numbers. * Kept in step with SQL's is_readable_name(). */ export const GENERIC_NAMES = /^(video_ts|audio_ts|bdmv|certificate|hvdvd_ts|samples?|subs?|subtitles?|proof|cover|covers|extras?|featurettes?|(cd|dvd|disc|disk)[ ._-]?\d{1,2})$/i export function isGenericName(name: string): boolean { return GENERIC_NAMES.test(name.trim()) } export function rarReleaseIdentity(entries: { name: string; size: number | null }[]): ReleaseIdentity | null { if (entries.length === 0) return null const tops = new Set(entries.map((e) => e.name.split(/[\\/]/)[0])) const nested = entries.some((e) => /[\\/]/.test(e.name)) // A generic top folder (VIDEO_TS) is shared by unrelated archives: key on the first entry instead. if (nested && tops.size === 1 && !isGenericName([...tops][0])) { const [top] = tops return { key: hash('rar-dir', top), name: top, source: 'rar' } } const first = entries[0] const base = first.name.split(/[\\/]/).pop() ?? first.name return { key: hash('rar-entry', first.name, first.size ?? ''), name: fileStem(base), source: 'rar' } }