import { fileStem } from './identity.js' import { looksRandom } from './random.js' /** * Readable or not: whether a name says what a release is (the catalog's readable flag, the prober's inner names). * looksRandom (random.ts) knows the single-token shapes; this reads a name token by token, so separators, spaces * and random "extensions" don't hide a scramble (LOG.md (129)). */ /** * Tokens that don't make a name readable on their own: packaging, volume numbers and the posting tools' fixed * words ("Description - Lt5nHjlo3RiuBb3C", "bd5b4a59f3aa87114dcf75c0-addon"). */ const NOISE = /^(addon|samples?|proof|subs?|nfo|part\d+|vol\d+|description|repost|rep|extras?|bonus|cd\d*|dvd\d*|dis[ck]\d*|par2|rar|zip|7z|mkv|mp4|avi|vol)$/i /** Mixed tokens that are codes, not scrambles: S02E10, 1080p, 10bit, x264, Win10x64, BDRip720p, 3DMark03, v2, 5th. */ const CODE = /^([sS]\d{1,2}[eE]\d{1,3}|[xh]26[45]|[A-Za-z]+\d+([a-z]{1,3}|x(64|86))?|\d+[A-Za-z]{1,3}|\d+[A-Za-z]+\d+)$/ /** The NZBSPLIT tools' hash between their markers is no part of the name. */ const NZBSPLIT = /__NZBSPLIT__[0-9a-f]{32}__NZBSPLIT__/i /** Scene tags write "i" for "I" between capitals (XCiTE, PRESCRiPTiON): read it as a capital there. */ function sceneCase(t: string): string { let out = '' for (let i = 0; i < t.length; i++) { out += t[i] === 'i' && i > 0 && i < t.length - 1 && /[A-Z]/.test(t[i - 1]) && /[A-Z]/.test(t[i + 1]) ? 'I' : t[i] } return out } /** * The signals of a scrambled token: letter/digit and lower-to-upper switches; case switches in both directions; lone * lowercase letters between capitals ("NtpUgCmS"); an inner run of 2+ capitals with lowercase on both sides * ("tBWTuz", "YDoaROv"); the longest consonant run; vowels (a lowercase y too: "McCartney"); runs of one character class. */ function signals(t: string) { let switches = 0 let caseSwitches = 0 let lone = 0 let consonants = 0 let maxConsonants = 0 for (let i = 0; i < t.length; i++) { const c = t[i] if (/[b-df-hj-np-tv-z]/i.test(c)) maxConsonants = Math.max(maxConsonants, ++consonants) else consonants = 0 if (i === 0) continue const a = t[i - 1] if (/\d/.test(a) !== /\d/.test(c)) switches++ else if (/[a-z]/.test(a) && /[A-Z]/.test(c)) switches++ if ((/[a-z]/.test(a) && /[A-Z]/.test(c)) || (/[A-Z]/.test(a) && /[a-z]/.test(c))) caseSwitches++ if ( i < t.length - 1 && /[a-z]/.test(c) && c !== 'i' && /[A-Z]/.test(a) && /[A-Z]/.test(t[i + 1]) ) lone++ } const innerUpper = /[a-z][A-Z]{2,}[a-z]/.test(t) const vowels = (t.match(/[aeiouAEIOU]|y/g) ?? []).length const runs = (t.match(/[a-z]+|[A-Z]+|\d+/g) ?? []).length const letters = (t.match(/[A-Za-z]/g) ?? []).length const maxLower = Math.max(0, ...(t.match(/[a-z]+/g) ?? []).map((r) => r.length)) return { switches, caseSwitches, lone, innerUpper, maxConsonants, vowels, runs, letters, maxLower, } } /** * What one alphanumeric token of a name is: a word (letters only, in one case or Capitalized, 3+ long, not a packaging * word), a scramble, or neither (digits, codes, CamelCase, short tokens). Kept in step with names.rs token_kind. */ export function tokenKind(t: string): 'word' | 'random' | null { if (/^\d+$/.test(t) || NOISE.test(t) || CODE.test(t)) return null if (/^[A-Za-z]+$/.test(t)) { const s = signals(sceneCase(t)) if (s.caseSwitches === 0 || (s.caseSwitches === 1 && /^[A-Z][a-z]+$/.test(t))) { // A base32 consonant soup ("mrrtmzrzmi4dgmrzmyytqmlbztczbx" without its digit) is no word. if (t.length >= 10 && s.vowels * 6 < t.length) return 'random' return t.length >= 3 ? 'word' : null } // Mixed case beyond Capitalization: CamelCase (neutral) or a scramble, which has few vowels and short runs. if ( t.length >= 6 && s.caseSwitches >= 2 && (s.innerUpper || (s.vowels * 4 < t.length && (s.runs >= 4 || s.lone >= 1))) ) return 'random' return null } if (t.length >= 8 && /^[0-9a-f]+$/i.test(t) && (t.match(/\d/g)?.length ?? 0) >= 2) return 'random' if (/^[A-Z0-9]+$/.test(t)) { // 51294P1CUKR2R35X; COD4MW2REMASTERED (4 switches) and BEATLES1964REMASTER (2) stay. return t.length >= 12 && signals(t).switches >= 6 ? 'random' : null } const s = signals(sceneCase(t)) if (t.length >= 5 && s.switches >= 3) return 'random' if (t.length >= 6 && s.switches >= 2 && (s.lone >= 1 || s.innerUpper || s.maxConsonants >= 4)) return 'random' // Few vowels and no lowercase run of 3+ ("Jk5Pud", "RKi9BCg"; not "HDR10Plus"). if (t.length >= 6 && s.switches >= 2 && s.vowels * 4 < s.letters && s.maxLower <= 2) return 'random' return null } /** * A file or release name no one can read: looksRandom's shapes, or more scrambled tokens than words by letter count * ("Zx5AjNaK-mh7CFg6Hf8uu", "Description - Lt5nHjlo3RiuBb3C", "PsNdnYiugfZ"; not "Paul McCartney - Band on the Run", * "ShesSoWild - Ester Ana 1080p"). Separators and a random "extension" of any length don't hide a scramble, which * looksRandom's single-token shapes miss (15% of the readable catalog on 2026-11-02; LOG.md (129)). The readable * flag; the drop rules keep looksRandom, which is narrower. */ export function isUnreadableName(name: string): boolean { const stem = fileStem(name).replace(NZBSPLIT, ' ') if (looksRandom(stem)) return true let words = 0 let random = 0 for (const t of stem.split(/[^A-Za-z0-9]+/)) { if (!t) continue const kind = tokenKind(t) if (kind === 'word') words += t.length else if (kind === 'random') random += t.length } return random > 0 && random > words }