diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index 53494a3..e9730cc 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -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', diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index b4e89e8..d8eac7a 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -55,6 +55,30 @@ export interface Registry { errorResponse: ExtractErrorResponse>> } } + 'releases.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/releases' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'releases.nzb': { + methods: ["GET","HEAD"] + pattern: '/api/v1/releases/:id/nzb' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } 'node.node.lease': { methods: ["POST"] pattern: '/api/v1/node/lease' diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index c5ff012..cd5411d 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -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'] diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 0ad50db..3c9dd4e 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -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'), } diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index da4dce0..b676ade 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -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: { diff --git a/app/controllers/releases_controller.ts b/app/controllers/releases_controller.ts new file mode 100644 index 0000000..1f6e1dd --- /dev/null +++ b/app/controllers/releases_controller.ts @@ -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 + } +} diff --git a/app/services/nzb_service.ts b/app/services/nzb_service.ts new file mode 100644 index 0000000..931fd06 --- /dev/null +++ b/app/services/nzb_service.ts @@ -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[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() diff --git a/lib/nzb/build.ts b/lib/nzb/build.ts new file mode 100644 index 0000000..5e0e9a4 --- /dev/null +++ b/lib/nzb/build.ts @@ -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 }) +} diff --git a/lib/nzb/index.ts b/lib/nzb/index.ts new file mode 100644 index 0000000..79b8861 --- /dev/null +++ b/lib/nzb/index.ts @@ -0,0 +1,2 @@ +export { buildNzb, type NzbFile, type NzbOptions, type Segment } from './build.js' +export { parseNzb, type ParsedNzb, type ParsedNzbFile } from './parse.js' diff --git a/lib/nzb/parse.ts b/lib/nzb/parse.ts new file mode 100644 index 0000000..fec1fec --- /dev/null +++ b/lib/nzb/parse.ts @@ -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 { + /** values, e.g. name, password, category. */ + meta: Record + files: ParsedNzbFile[] +} + +const ENTITIES: Record = { 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 { + const result: Record = {} + 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 root. + */ +export function parseNzb(xml: string, limits = { maxFiles: 20_000 }): ParsedNzb | null { + if (!/]/i.test(xml)) return null + + const meta: Record = {} + const head = /([\s\S]*?)<\/head>/i.exec(xml)?.[1] ?? '' + for (const m of head.matchAll(/]*)>([\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(/]*)>([\s\S]*?)<\/file>/gi)) { + if (files.length >= limits.maxFiles) break + const attrs = attributes(m[1]) + const body = m[2] + const groups = [...body.matchAll(/([^<]*)<\/group>/gi)].map((g) => decode(g[1].trim())).filter(Boolean) + const segments = [...body.matchAll(/]*)>([^<]*)<\/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 } +} diff --git a/package-lock.json b/package-lock.json index fd2ea24..33b91dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index bc5cba2..fd5ee95 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/start/routes.ts b/start/routes.ts index b8bcf5e..e436b13 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -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(() => { diff --git a/tests/functional/releases_api.spec.ts b/tests/functional/releases_api.spec.ts new file mode 100644 index 0000000..8293dd5 --- /dev/null +++ b/tests/functional/releases_api.spec.ts @@ -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 ', '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') + }) +}) diff --git a/tests/unit/nzb/nzb.spec.ts b/tests/unit/nzb/nzb.spec.ts new file mode 100644 index 0000000..d5d9499 --- /dev/null +++ b/tests/unit/nzb/nzb.spec.ts @@ -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, 'Some.Show.S01E01') + assert.include(xml, 'subject=""Some.Show.S01E01.mkv" yEnc (1/2)"') + assert.include(xml, 'date="1790000000"') + assert.include(xml, 'alt.binaries.test') + assert.isBelow(xml.indexOf('>a@x<'), xml.indexOf('>b@x<')) + assert.include(xml, 'a@x') + }) +}) + +test.group('nzb / parseNzb', () => { + test('reads meta, files, groups and segments, decoding entities', async ({ assert }) => { + const { parseNzb } = await import('#lib/nzb/index') + const xml = ` + + + Some.Show.S01E01s3cr&t + + alt.binaries.bloaf + + b@x.local + <a@x.local> + + + +` + 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('not an nzb')) + }) +}) +