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

+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()