Files
usenet-indexer/lib/headers/subject.ts
T
Daan MeijerandClaude Opus 5.5 2814c77225
Test and deploy / test (push) Successful in 46s
Test and deploy / edge (push) Successful in 1m57s
Test and deploy / deploy (push) Successful in 1m41s
A quoted word that isn't a file name; split parts with two digits
From the re-index's single-file releases:
- A quoted text without an extension ("My \"Official\" remaining set's
  of Inna … 025.jpg [25/74]", "… lr140x16.jpg ( file 046 of 922)
  \"yEnc\"", "\"Dr. Lust\" - … - [12/21] - Ed_DRL_VDR_LM.part09.rar") was
  the file name, the real one stayed in the subject: every file a release.
  It gives way to a file name with an extension beside it
  (nameBesideQuotes); random quoted names with none beside keep theirs.
- Split parts numbered with two digits ("Admiral.EP05.2008.720p.Rus.
  HDCLUB.mkv.21", a.b.movies.divx.russian) share their stem, as .001.
Both implementations; tests; fixtures identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:46:43 +02:00

114 lines
4.9 KiB
TypeScript

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).
*/
/**
* The file name of a subject whose quoted text has no extension ("My
* \"Official\" remaining set's of Inna … 025.jpg", "\"Dr. Lust\" - … - Ed_DRL_VDR_LM.part09.rar"):
* a name with one elsewhere, else null (random quoted names keep theirs).
*/
export function nameBesideQuotes(quoted: string, beforeCounter: string): string | null {
if (EXTENSION.test(quoted)) return null
const name = unquotedName(beforeCounter.replace(/"[^"]*"/g, ' '))
return name && EXTENSION.test(name) ? name : null
}
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 quoted = nameStart !== -1 ? subject.slice(nameStart + 1, nameEnd) : null
const name = quoted !== null ? (nameBesideQuotes(quoted, subject.slice(0, counter.index)) ?? quoted) : 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,
}
}