Files
usenet-indexer/lib/release/identity.ts
T
Daan MeijerandClaude Opus 5.5 9dc0a27bb4 Rebuild: a lease never waits for prefetching; one-shot hashes
A lease was only granted for a range with spooled chunks, and the
prefetcher kept ranges ahead per active lease: once the spool ran dry a
worker got none and slept 30 s, fewer ranges were leased, fewer
prefetched. Three workers held one lease between them (~50k headers/s).
A lease now takes a spooled range first, else the next planned one
(leased ranges are fetched first; the stream waits).

crypto.hash instead of createHash().update().digest() for file and
release keys: same digests, ~2.5x faster (~4.6% of a worker's time).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:08:19 +02:00

224 lines
8.8 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'
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'))
}
/**
* 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)
if (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 (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' }
}