export interface ParsedSubject { /** Filename from the quoted part of the subject. */ name: string /** Segment number `k` from `(k/N)`. */ segment: number /** Segment count `N` from `(k/N)`. */ totalSegments: number /** File counter `[i/M]`, when present. */ fileIndex?: number fileTotal?: number /** * The subject with the segment counter removed. Identical for every segment * of one uploaded file, so it is part of the file identity (see fileKey). */ base: string } const SEGMENT_COUNTER = /\((\d+)\/(\d+)\)/g /** A file name's extension: a dot and 2 to 4 letters or digits at the end. */ const EXTENSION = /[^.\s]\.[A-Za-z0-9]{2,4}$/ /** * The file name of a subject without a quoted one, from the text before its * segment counter (LOG.md 2026-10-11 (5): most "obfuscated" subjects of older * groups): with one quote only, the name before it (after a "[", else after * the last space: `[keira_s04_0037_l.jpg" yEnc] (2/2)`); without quotes, the * text when it ends in a file name (after a "] " or " - ": `DVDFab * 8.2.0.5.rar (006/332)`, `[33/117] 674a37548b09.dat (1/1)`), else its * first word that is one (`Emily_18_-_Bed.part01.rar Emily 18 bed video * (01/24)`). Null when no file name is found: the subject stays obfuscated. * Words are split on spaces only (both implementations alike). */ export function unquotedName(beforeCounter: string): string | null { let text = beforeCounter.trimEnd() if (/yenc$/i.test(text)) text = text.slice(0, -4).trimEnd() const quote = text.indexOf('"') if (quote !== -1) { if (text.indexOf('"', quote + 1) !== -1) return null const before = text.slice(0, quote) const bracket = before.lastIndexOf('[') if (bracket !== -1) { // A file counter that lost its closing bracket ("[03/21 Name.part01.rar") isn't part of the name. const inBracket = before.slice(bracket + 1).trim().replace(/^\d+\/\d+ +/, '') if (EXTENSION.test(inBracket)) return inBracket } const word = before.slice(before.lastIndexOf(' ') + 1) return EXTENSION.test(word) ? word : null } if (EXTENSION.test(text)) { const bracket = text.lastIndexOf('] ') // A track or part number ("Album - CD2 - 07 - Artist - Title.mp3"): the name starts there, so the rest // is the album, the same for every track (LOG.md 2026-10-11 (10)); else after the last " - ". const track = / - \d{1,3} - /.exec(text) const dash = track ? track.index : text.lastIndexOf(' - ') const cut = Math.max(bracket === -1 ? 0 : bracket + 2, dash === -1 ? 0 : dash + 3) const name = text.slice(cut).trim() return name && EXTENSION.test(name) ? name : null } for (const word of text.split(' ')) if (EXTENSION.test(word)) return word return null } const FILE_COUNTER = /\[(\d+)\/(\d+)\]/ /** * Parses a binary post subject such as * `[07/45] - "Some.Release.part06.rar" yEnc (12/137) 51315352`. * * Returns null for subjects without a quoted filename and segment counter * (obfuscated posts), which need their yEnc header read from the body instead. */ export function parseSubject(subject: string): ParsedSubject | null { const nameEnd = subject.lastIndexOf('"') const nameStart = nameEnd > 0 ? subject.lastIndexOf('"', nameEnd - 1) : -1 // The segment counter is the last "(k/N)" after the filename (anywhere without a quote); a file size may follow it. let counter: RegExpExecArray | undefined for (const match of subject.matchAll(SEGMENT_COUNTER)) { if (match.index > nameEnd) counter = match } if (!counter) return null const name = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : unquotedName(subject.slice(0, counter.index)) if (!name) return null const segment = Number.parseInt(counter[1], 10) const totalSegments = Number.parseInt(counter[2], 10) if (totalSegments < 1 || segment < 1 || segment > totalSegments) return null const base = (subject.slice(0, counter.index) + subject.slice(counter.index + counter[0].length)) .replace(/\s+/g, ' ') .trim() const fileCounter = FILE_COUNTER.exec(subject.slice(0, nameStart !== -1 ? nameStart : counter.index)) return { name, segment, totalSegments, fileIndex: fileCounter ? Number.parseInt(fileCounter[1], 10) : undefined, fileTotal: fileCounter ? Number.parseInt(fileCounter[2], 10) : undefined, base, } }