lib/nzb (buildNzb, parseNzb) from the current app; NzbService builds a release's NZB from its chunks; GET /api/v1/releases?q= (every word of the name) and GET /api/v1/releases/:id/nzb behind the api guard. A test stores a batch, finds the release and reads its NZB back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
73 lines
2.7 KiB
TypeScript
73 lines
2.7 KiB
TypeScript
export interface ParsedNzbFile {
|
|
poster: string
|
|
/** Posting time in seconds (as in the NZB). */
|
|
date: number
|
|
subject: string
|
|
groups: string[]
|
|
segments: { number: number; bytes: number; id: string }[]
|
|
}
|
|
|
|
export interface ParsedNzb {
|
|
/** <head><meta type="..."> values, e.g. name, password, category. */
|
|
meta: Record<string, string>
|
|
files: ParsedNzbFile[]
|
|
}
|
|
|
|
const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
|
|
|
|
function decode(text: string): string {
|
|
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
|
|
if (entity[0] === '#') {
|
|
const code = entity[1].toLowerCase() === 'x' ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10)
|
|
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
|
|
}
|
|
return ENTITIES[entity.toLowerCase()] ?? match
|
|
})
|
|
}
|
|
|
|
function attributes(tag: string): Record<string, string> {
|
|
const result: Record<string, string> = {}
|
|
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) result[m[1]] = decode(m[3] ?? m[4] ?? '')
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Parses an NZB (1.0/1.1 and the older 0.9 DTD). A tolerant scanner rather
|
|
* than a full XML parser: NZBs are flat, and this never resolves DTDs or
|
|
* external entities. Returns null when there is no <nzb> root.
|
|
*/
|
|
export function parseNzb(xml: string, limits = { maxFiles: 20_000 }): ParsedNzb | null {
|
|
if (!/<nzb[\s>]/i.test(xml)) return null
|
|
|
|
const meta: Record<string, string> = {}
|
|
const head = /<head>([\s\S]*?)<\/head>/i.exec(xml)?.[1] ?? ''
|
|
for (const m of head.matchAll(/<meta\s([^>]*)>([\s\S]*?)<\/meta>/gi)) {
|
|
const type = attributes(m[1]).type
|
|
if (type) meta[type.toLowerCase()] = decode(m[2].trim())
|
|
}
|
|
|
|
const files: ParsedNzbFile[] = []
|
|
for (const m of xml.matchAll(/<file\s([^>]*)>([\s\S]*?)<\/file>/gi)) {
|
|
if (files.length >= limits.maxFiles) break
|
|
const attrs = attributes(m[1])
|
|
const body = m[2]
|
|
const groups = [...body.matchAll(/<group>([^<]*)<\/group>/gi)].map((g) => decode(g[1].trim())).filter(Boolean)
|
|
const segments = [...body.matchAll(/<segment\s([^>]*)>([^<]*)<\/segment>/gi)]
|
|
.map((s) => {
|
|
const a = attributes(s[1])
|
|
return { number: Number(a.number), bytes: Number(a.bytes) || 0, id: decode(s[2].trim()).replace(/^<|>$/g, '') }
|
|
})
|
|
.filter((s) => Number.isInteger(s.number) && s.number > 0 && s.id)
|
|
.sort((x, y) => x.number - y.number)
|
|
if (segments.length === 0) continue
|
|
files.push({
|
|
poster: attrs.poster ?? '',
|
|
date: Number(attrs.date) || 0,
|
|
subject: attrs.subject ?? '',
|
|
groups,
|
|
segments,
|
|
})
|
|
}
|
|
return { meta, files }
|
|
}
|