Files
usenet-indexer/lib/release/identity.ts
T
Daan MeijerandClaude Opus 5.5 e1d21d1e7c
Test and deploy / test (push) Successful in 49s
Test and deploy / edge (push) Successful in 1m59s
Test and deploy / deploy (push) Successful in 2m6s
Identity: a set's number before yEnc is no size; names after an arrow
- The unit-less size before the closing yEnc ('"x.mp3" - 8123 yEnc') took
  the set number in 'Newstar Destiny I set 1 [1 of 60] "destiny-001-001.jpg"
  yEnc' once the quotes were gone: 97 sets of 60 were one release. A number
  of fewer than 4 digits after a word stays.
- unquotedName also cuts after "-> " ("… -> [0001 of 0076] ->
  Exclusive-tgirls_000000-lg.jpg"): the arrow was part of the file name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:46:19 +02:00

412 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
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
}
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)
if (plain) return 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): 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()
const named = input.subject.includes(`"${input.name}"`)
? input.subject
: (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' }
}