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 = [ /\.vol\d+[+-]\d+\.par2$/i, /\.par2$/i, /\.part\d+\.rar$/i, /\.r\d{2,3}$/i, /\.rar$/i, /\.7z(\.\d{3})?$/i, /\.zip(\.\d{3})?$/i, // Split volumes (x.mkv.001), not codec names (H.264, H.265). /(?<=\.(7z|zip|rar|tar|mkv|mp4|avi|m4v|ts|m2ts|iso|img|bin|vob))\.\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 { let stem = name.trim() 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')) } /** 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 /(?= 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): 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"). if (/\d{5,}/.test(remainder)) return false 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 if (word.length >= 4 ? compact.includes(word) : whole.has(word)) return false } return true } /** * 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, '')) } 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 ? Number.parseInt(counter[2], 10) : undefined const stem = fileStem(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() // 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 = ( input.subject.includes(`"${input.name}"`) ? input.subject.replace(`"${input.name}"`, ' ') : 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) // 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). 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) // 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' } } // 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)). if (image) return { key: hash('stem', poster, imageSet(stem) || prefix || stem), 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)) { 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' } } /** 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' } }