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:
1 parent
e9592dfd33
commit
fde1e6cc90
15 files changed
+446
-2
No files matched your search
@@ -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
@@ -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'
|
||||
|
||||
Vendored
+4
@@ -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']
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
Vendored
+6
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { buildNzb, type NzbFile, type NzbOptions, type Segment } from './build.js'
|
||||
export { parseNzb, type ParsedNzb, type ParsedNzbFile } from './parse.js'
|
||||
@@ -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 }
|
||||
}
|
||||
Generated
+93
-1
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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=""Some.Show.S01E01.mkv" 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&t</meta></head>
|
||||
<file poster="Yenc@power-post.org (Cosmo71)" date="1790598923" subject="Wond40 "Nachbe" x.part1.rar" 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"><a@x.local></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>'))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user