d4e53ca's .[s-z]NN took "Electric.Rain.Swift.3D.PS.v1.0.142.x64" for a volume, so x86 and x64 builds would be one release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
506 lines
28 KiB
TypeScript
506 lines
28 KiB
TypeScript
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).
|
||
/\.part\d+(?:\.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; not x or z (".x64", ".x86" builds; ".z01" goes with .zip).
|
||
let stem = name.trim().replace(/\.[s-v]\d{2}$/, '')
|
||
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)", "<Sponsored by Secretusenet>". */
|
||
const BOILERPLATE = /\(?\bwww(?: [A-Za-z0-9]+)*? (?:org|com|net|info|nl|de|eu|to|biz|us)\b\)?|<?\bsponsored by [A-Za-z0-9]+>?/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}[-_ .]+(.+?)(?:_-_| - |-)/
|
||
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 /(?<!\d)0*[1-9]\d{4,}/.test(set) ? '' : set
|
||
}
|
||
|
||
/**
|
||
* A total a posting tool wrote wrong: zero-padded wider than the index ("[01/010]", "[02/020]", "[07/070]": each
|
||
* track of an album had a total of its own, so a release of its own; LOG.md 2026-10-12 (15)).
|
||
*/
|
||
function brokenTotal(index: string, total: string): boolean {
|
||
return total.length > 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").
|
||
if (/\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, ''))
|
||
}
|
||
|
||
/** 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(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< <Sponsored by Secretusenet> -
|
||
// "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' }
|
||
// 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)) {
|
||
// 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' }
|
||
}
|
||
// 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)).
|
||
return { key: hash('stem', poster, base.toLowerCase().replace(/^00[-_ .]+/, '')), 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' }
|
||
}
|