Rebuild: NZBs from the chunk store, and a catalog search

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>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 02:33:27 +02:00
1 parent e9592dfd33
commit fde1e6cc90
15 files changed
+446 -2

No files matched your search

+12
View File
@@ -30,6 +30,18 @@ const routes = {
tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}],
types: placeholder as Registry['profile.access_tokens.destroy']['types'],
},
'releases.index': {
methods: ["GET","HEAD"],
pattern: '/api/v1/releases',
tokens: [{"old":"/api/v1/releases","type":0,"val":"api","end":""},{"old":"/api/v1/releases","type":0,"val":"v1","end":""},{"old":"/api/v1/releases","type":0,"val":"releases","end":""}],
types: placeholder as Registry['releases.index']['types'],
},
'releases.nzb': {
methods: ["GET","HEAD"],
pattern: '/api/v1/releases/:id/nzb',
tokens: [{"old":"/api/v1/releases/:id/nzb","type":0,"val":"api","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"v1","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"releases","end":""},{"old":"/api/v1/releases/:id/nzb","type":1,"val":"id","end":""},{"old":"/api/v1/releases/:id/nzb","type":0,"val":"nzb","end":""}],
types: placeholder as Registry['releases.nzb']['types'],
},
'node.node.lease': {
methods: ["POST"],
pattern: '/api/v1/node/lease',
+24
View File
@@ -55,6 +55,30 @@ export interface Registry {
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
}
}
'releases.index': {
methods: ["GET","HEAD"]
pattern: '/api/v1/releases'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['index']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['index']>>>
}
}
'releases.nzb': {
methods: ["GET","HEAD"]
pattern: '/api/v1/releases/:id/nzb'
types: {
body: {}
paramsTuple: [ParamValue]
params: { id: ParamValue }
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['nzb']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/releases_controller').default['nzb']>>>
}
}
'node.node.lease': {
methods: ["POST"]
pattern: '/api/v1/node/lease'
+4
View File
@@ -18,6 +18,10 @@ export interface ApiDefinition {
destroy: typeof routes['profile.access_tokens.destroy']
}
}
releases: {
index: typeof routes['releases.index']
nzb: typeof routes['releases.nzb']
}
node: {
node: {
lease: typeof routes['node.node.lease']
+1
View File
@@ -8,4 +8,5 @@ export const controllers = {
NewAccount: () => import('#controllers/new_account_controller'),
Node: () => import('#controllers/node_controller'),
Profile: () => import('#controllers/profile_controller'),
Releases: () => import('#controllers/releases_controller'),
}
+6
View File
@@ -8,6 +8,8 @@ export type ScannedRoutes = {
'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} }
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.lease': { paramsTuple?: []; params?: {} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.batch': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
@@ -17,10 +19,14 @@ export type ScannedRoutes = {
}
GET: {
'profile.profile.show': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
}
HEAD: {
'profile.profile.show': { paramsTuple?: []; params?: {} }
'releases.index': { paramsTuple?: []; params?: {} }
'releases.nzb': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
'node.node.stream': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
}
POST: {
+27
View File
@@ -0,0 +1,27 @@
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import NzbService from '#services/nzb_service'
/** The catalog (REBUILD.md, part 9): a name search and a release's NZB. */
export default class ReleasesController {
/** Releases whose name has every word of `q` (dots, dashes, underscores and spaces alike), newest first. */
async index({ request }: HttpContext) {
const q = String(request.input('q', '')).trim()
const limit = Math.min(Number(request.input('limit', 50)) || 50, 200)
const query = db
.from('releases')
.select('id', 'name', 'poster', 'groups', 'posted_at', 'files', 'file_total', 'bytes', 'complete', 'readable')
.orderBy('posted_at', 'desc')
.limit(limit)
for (const word of q.split(/[\s._-]+/).filter(Boolean)) query.whereILike('name', `%${word.replace(/[\\%_]/g, '\\$&')}%`)
return { releases: await query }
}
async nzb({ params, response }: HttpContext) {
const nzb = await NzbService.build(Number(params.id))
if (!nzb) return response.notFound({ error: 'No such release' })
response.header('Content-Type', 'application/x-nzb')
response.header('Content-Disposition', `attachment; filename="${nzb.name.replace(/["\\/]/g, '_')}.nzb"`)
return nzb.xml
}
}
+35
View File
@@ -0,0 +1,35 @@
import db from '@adonisjs/lucid/services/db'
import { decodeChunk } from '#lib/chunks/index'
import { buildNzb } from '#lib/nzb/index'
/**
* A release's NZB (REBUILD.md, part 9), from its chunks in the chunk store:
* every chunk decoded, its files in name order. A file stored twice (the same
* name and first article, e.g. a range read again) is taken once.
*/
class NzbService {
async build(releaseId: number): Promise<{ name: string; xml: string } | null> {
const release = await db.from('releases').where('id', releaseId).select('name').first()
if (!release) return null
const chunks = await db.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload')
const files = new Map<string, ReturnType<typeof decodeChunk>[number]>()
for (const chunk of chunks) {
for (const file of decodeChunk(chunk.payload)) files.set(`${file.name}\n${file.segments[0]?.[1] ?? ''}`, file)
}
const xml = buildNzb(
[...files.values()]
.sort((a, b) => a.name.localeCompare(b.name))
.map((f) => ({
name: f.name,
poster: f.poster,
date: f.postedAt ?? 0,
groups: f.groups,
segments: f.segments.map(([number, id, size]) => ({ number, id, size })),
})),
{ title: release.name }
)
return { name: release.name, xml }
}
}
export default new NzbService()
+56
View File
@@ -0,0 +1,56 @@
import { create } from 'xmlbuilder2'
/** One article of a file: its number, message-id (without <>) and size in bytes. */
export interface Segment {
number: number
id: string
size: number
}
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[]
segments: Segment[]
}
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 sorted = [...file.segments].sort((a, b) => a.number - b.number)
const element = root.ele('file', {
poster: file.poster,
date: String(Math.floor(file.date / 1000)),
subject: `"${file.name}" yEnc (1/${sorted.length})`,
})
const groups = element.ele('groups')
for (const group of file.groups) groups.ele('group').txt(group)
const segments = element.ele('segments')
for (const segment of sorted) {
segments.ele('segment', { bytes: String(segment.size), number: String(segment.number) }).txt(segment.id)
}
}
return root.end({ prettyPrint: true })
}
+2
View File
@@ -0,0 +1,2 @@
export { buildNzb, type NzbFile, type NzbOptions, type Segment } 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 }
}
+93 -1
View File
@@ -20,7 +20,8 @@
"@vinejs/vine": "^4.4.0",
"luxon": "^3.7.2",
"pg": "^8.23.1",
"reflect-metadata": "^0.2.2"
"reflect-metadata": "^0.2.2",
"xmlbuilder2": "^4.0.3"
},
"devDependencies": {
"@adonisjs/assembler": "^8.5.2",
@@ -1450,6 +1451,54 @@
"node": ">= 8"
}
},
"node_modules/@oozcitak/dom": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz",
"integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "^2.0.2",
"@oozcitak/url": "^3.0.0",
"@oozcitak/util": "^10.0.0"
},
"engines": {
"node": ">=20.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz",
"integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "^10.0.0"
},
"engines": {
"node": ">=20.0"
}
},
"node_modules/@oozcitak/url": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz",
"integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "^2.0.2",
"@oozcitak/util": "^10.0.0"
},
"engines": {
"node": ">=20.0"
}
},
"node_modules/@oozcitak/util": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz",
"integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==",
"license": "MIT",
"engines": {
"node": ">=20.0"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
@@ -2554,6 +2603,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
@@ -4770,6 +4825,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -7180,6 +7257,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xmlbuilder2": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz",
"integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "^2.0.2",
"@oozcitak/infra": "^2.0.2",
"@oozcitak/util": "^10.0.0",
"js-yaml": "^4.1.1"
},
"engines": {
"node": ">=20.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2 -1
View File
@@ -68,7 +68,8 @@
"@vinejs/vine": "^4.4.0",
"luxon": "^3.7.2",
"pg": "^8.23.1",
"reflect-metadata": "^0.2.2"
"reflect-metadata": "^0.2.2",
"xmlbuilder2": "^4.0.3"
},
"hotHook": {
"boundaries": [
+8
View File
@@ -34,6 +34,14 @@ router
.as('profile')
.use(middleware.auth())
// The catalog (REBUILD.md, part 9).
router
.group(() => {
router.get('releases', [controllers.Releases, 'index'])
router.get('releases/:id/nzb', [controllers.Releases, 'nzb'])
})
.use(middleware.auth({ guards: ['api'] }))
// Edge workers (REBUILD.md, parts 1–2).
router
.group(() => {
+48
View File
@@ -0,0 +1,48 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import User from '#models/user'
import BatchStore from '#services/batch_store'
import { RangeIndexer } from '#lib/edge/index'
import { parseNzb } from '#lib/nzb/index'
import { DEFAULT_RULES } from '#lib/rules/index'
import type { Overview } from '#lib/nntp/index'
const GROUP = 'alt.binaries.test'
function file(name: string, counter: string, parts: number, at: number): Overview[] {
return Array.from({ length: parts }, (_, i) => [at + i, {
'subject': `${counter} - "${name}" yEnc (${i + 1}/${parts})`, 'from': 'p <p@x>', 'date': 'Mon, 05 Oct 2026 10:00:00 +0000',
'message-id': `<${name}-${i + 1}@test>`, ':bytes': String(700000 + i), 'xref': `news.example ${GROUP}:${at + i}`,
}] as Overview)
}
test.group('Releases API', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('finds a release by the words of its name and builds its NZB from the chunk store', async ({ client, assert }) => {
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
const [n] = await db.table('nodes').insert({ name: 'api-node' }).returning('id')
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: n.id }).returning('id')
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
indexer.add([...file('Some.Show.S01E01.1080p.WEB.part1.rar', 'Some.Show.S01E01 [1/2]', 3, 100), ...file('Some.Show.S01E01.1080p.WEB.par2', 'Some.Show.S01E01 [2/2]', 1, 200)], 1001)
indexer.finish()
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
const user = await User.create({ email: 'u@example.invalid', password: 'secret-password' })
const token = (await User.accessTokens.create(user)).value!.release()
;(await client.get('/api/v1/releases?q=show s01e01')).assertStatus(401)
const found = await client.get('/api/v1/releases?q=show s01e01').bearerToken(token)
found.assertStatus(200)
const releases = (found.body() as { releases: { id: number; name: string }[] }).releases
assert.lengthOf(releases, 1)
const nzb = await client.get(`/api/v1/releases/${releases[0].id}/nzb`).bearerToken(token)
nzb.assertStatus(200)
assert.include(nzb.header('content-type'), 'application/x-nzb')
const parsed = parseNzb(nzb.text())!
assert.equal(parsed.meta.name, releases[0].name)
assert.deepEqual(parsed.files.map((f) => f.segments.length), [1, 3])
assert.equal(parsed.files[1].segments[2].id, 'Some.Show.S01E01.1080p.WEB.part1.rar-3@test')
})
})
+56
View File
@@ -0,0 +1,56 @@
import { test } from '@japa/runner'
import { buildNzb } from '#lib/nzb/index'
test.group('nzb / buildNzb', () => {
test('writes files, groups and ordered segments', ({ assert }) => {
const xml = buildNzb(
[
{
name: 'Some.Show.S01E01.mkv',
poster: 'poster@example.invalid',
date: 1_790_000_000_000,
groups: ['alt.binaries.test'],
segments: [{ number: 2, id: 'b@x', size: 700 }, { number: 1, id: 'a@x', size: 800 }],
},
],
{ title: 'Some.Show.S01E01' }
)
assert.include(xml, '<!DOCTYPE nzb PUBLIC "-//newzBin//DTD NZB 1.1//EN"')
assert.include(xml, '<meta type="name">Some.Show.S01E01</meta>')
assert.include(xml, 'subject="&quot;Some.Show.S01E01.mkv&quot; yEnc (1/2)"')
assert.include(xml, 'date="1790000000"')
assert.include(xml, '<group>alt.binaries.test</group>')
assert.isBelow(xml.indexOf('>a@x<'), xml.indexOf('>b@x<'))
assert.include(xml, '<segment bytes="800" number="1">a@x</segment>')
})
})
test.group('nzb / parseNzb', () => {
test('reads meta, files, groups and segments, decoding entities', async ({ assert }) => {
const { parseNzb } = await import('#lib/nzb/index')
const xml = `<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE nzb PUBLIC "-//newzBin//DTD NZB 0.9//EN" "http://www.newzbin.com/DTD/nzb/nzb-0.9.dtd">
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<head><meta type="name">Some.Show.S01E01</meta><meta type="password">s3cr&amp;t</meta></head>
<file poster="Yenc@power-post.org (Cosmo71)" date="1790598923" subject="Wond40 &#x22;Nachbe&#x22; x.part1.rar&#x22; yEnc (0001/2)">
<groups><group>alt.binaries.bloaf</group></groups>
<segments>
<segment bytes="1000" number="2">b@x.local</segment>
<segment bytes="2000" number="1">&lt;a@x.local&gt;</segment>
</segments>
</file>
<file poster="p" date="1" subject="empty"><groups/><segments></segments></file>
</nzb>`
const nzb = parseNzb(xml)!
assert.deepEqual(nzb.meta, { name: 'Some.Show.S01E01', password: 's3cr&t' })
assert.lengthOf(nzb.files, 1)
assert.equal(nzb.files[0].subject, 'Wond40 "Nachbe" x.part1.rar" yEnc (0001/2)')
assert.deepEqual(nzb.files[0].groups, ['alt.binaries.bloaf'])
assert.deepEqual(nzb.files[0].segments, [
{ number: 1, bytes: 2000, id: 'a@x.local' },
{ number: 2, bytes: 1000, id: 'b@x.local' },
])
assert.isNull(parseNzb('<html>not an nzb</html>'))
})
})