Files
usenet-indexer/lib/release/identity.ts
T
Daan MeijerandClaude Opus 5.5 59481037fb
Test and deploy / test (push) Successful in 28s
Test and deploy / deploy (push) Skipped
Rebuild: a poster's constant tag no longer makes one release of all their uploads
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>
2026-10-09 11:23:33 +02:00

264 lines
11 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{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' }
}