implemented a lot of features

This commit is contained in:
Daan Meijer committed 2026-09-29 00:32:01 +02:00
1 parent 62f1767df3
commit bcb9be5da9
141 files changed
+10705 -385

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
import { create } from 'xmlbuilder2'
export interface NzbFile {
/** Name the downloader should use; the real name when it is known. */
name: string
poster: string
/** Posting time in milliseconds. */
date: number
groups: string[]
/** Segment number → message-id (without angle brackets) and article size. */
segments: Record<string, { id: string; size: number }>
}
export interface NzbOptions {
/** Shown by downloaders as the job name. */
title?: string
/** Archive password, when known. */
password?: string
}
/**
* Builds an NZB 1.1 document (newzbin DTD). The subject follows the common
* `"name" yEnc (1/N)` form, which downloaders use to name the file.
*/
export function buildNzb(files: NzbFile[], options: NzbOptions = {}): string {
const root = create({ version: '1.0', encoding: 'UTF-8' })
.dtd({ pubID: '-//newzBin//DTD NZB 1.1//EN', sysID: 'http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd' })
.ele('nzb', { xmlns: 'http://www.newzbin.com/DTD/2003/nzb' })
if (options.title || options.password) {
const head = root.ele('head')
if (options.title) head.ele('meta', { type: 'name' }).txt(options.title)
if (options.password) head.ele('meta', { type: 'password' }).txt(options.password)
}
for (const file of files) {
const numbers = Object.keys(file.segments)
.map(Number)
.sort((a, b) => a - b)
const element = root.ele('file', {
poster: file.poster,
date: String(Math.floor(file.date / 1000)),
subject: `"${file.name}" yEnc (1/${numbers.length})`,
})
const groups = element.ele('groups')
for (const group of file.groups) groups.ele('group').txt(group)
const segments = element.ele('segments')
for (const number of numbers) {
const segment = file.segments[String(number)]
segments.ele('segment', { bytes: String(segment.size), number: String(number) }).txt(segment.id)
}
}
return root.end({ prettyPrint: true })
}
+5
View File
@@ -0,0 +1,5 @@
/**
* NZB generation, independent of AdonisJS.
*/
export { buildNzb, type NzbFile, type NzbOptions } from './build.js'
export { parseNzb, type ParsedNzb, type ParsedNzbFile } from './parse.js'
+72
View File
@@ -0,0 +1,72 @@
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 }
}