Release 145637 held four albums: the poster puts '[KiLL.dA.FuCkIn.
EaStErRaBbIt]----' before every upload, and the text outside the quotes
(when it names a release) was the key with the poster. The same made
one release of a poster's albums under '(PowBeertje80 Posted)' (a post
id, with the same [i/M]), of masterposts' films, and of one volume of
every set in a DVD9 post.
Now, when the file's stem describes something itself (12+ letters and
digits, two words of 3+ letters) and the text outside the quotes shares
no word with it (4+ letters as part of the stem, 3-letter words and
numbers whole; boilerplate like www/net/posted not counted; no 5+ digit
per-post id), that text is taken for the poster's tag and the stem
says which release the file is. Albums of track files ('FTA … Ketchup'),
'DIE_ZEIT_35' with '35_China_dl', 'FTD769997' posts, scene names with
abbreviated file names ('grp-sm') and generic sidecars ('file_id.diz')
keep their grouping.
releases:regroup re-keys a sample of the catalog with the current rule:
on homelab's test data 145637 → 4 albums, 111566 → 17 albums of 27
files, the rest of the subject releases that change mostly lose an ad
image ('Flyer-download-2-Day.gif'); stem releases unchanged. Ported to
edge-bench-rs: check.ts still finds all captures identical (ath 2013:
16 → 76 releases on both sides).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
11 KiB
TypeScript
264 lines
11 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 = [
|
||
/\.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{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.
|
||
*/
|
||
export function subjectRemainder(subject: string): string {
|
||
return subject
|
||
.replace(/"[^"]*"/g, ' ')
|
||
.replace(/\[\d+\/\d+\]|\(\d+\/\d+\)/g, ' ')
|
||
// 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, ' ')
|
||
.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'))
|
||
}
|
||
|
||
/** 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).
|
||
*/
|
||
export function releaseIdentity(input: ReleaseInput): ReleaseIdentity | null {
|
||
const counter = /\[(\d+)\/(\d+)\]/.exec(input.subject)
|
||
const fileTotal = 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"".
|
||
const remainder = subjectRemainder(input.subject)
|
||
// Unless it's the poster's constant tag ("[KiLL.dA…]---- (01/10) "Album.rar"", "(X Posted)"),
|
||
// which 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").
|
||
const tag = describes(stem) && !looksRandom(stem) && isPosterTag(remainder, stem)
|
||
if (!tag && namesRelease(remainder)) {
|
||
return {
|
||
key: hash('subject', input.poster, remainder),
|
||
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 && fileTotal && isPostToken(remainder)) {
|
||
return { key: hash('post', input.poster, remainder, fileTotal), name: remainder, fileTotal, source: 'post' }
|
||
}
|
||
// 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', input.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' }
|
||
}
|