import { gunzipSync } from 'node:zlib' import db from '@adonisjs/lucid/services/db' import { decodeChunk, type ChunkFile } from '#lib/chunks/index' import { buildNzb } from '#lib/nzb/index' import { SOURCES } from '#lib/release/source' /** Where a known password came from, the most trusted first: the spot's NZB meta, its title, its text. */ const PASSWORD_SOURCES = ['nzb', 'spot-title', 'spot-text'] /** * A release's files and 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. A known password (release_passwords) goes into the NZB's meta, where * SABnzbd and NZBGet take it for unpacking. */ class NzbService { /** The release's known passwords, the most trusted first. */ async passwords(releaseId: number): Promise<{ password: string; source: string }[]> { const rows = await db.from('release_passwords').where('release_id', releaseId).orderBy('created_at').select('password', 'source') const rank = (source: string) => (PASSWORD_SOURCES.indexOf(source) + PASSWORD_SOURCES.length + 1) % (PASSWORD_SOURCES.length + 1) return rows.sort((a, b) => rank(a.source) - rank(b.source)).map((r) => ({ password: r.password, source: r.source })) } async files(releaseId: number, client: any = db): Promise { const chunks = await client.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload') const files = new Map() for (const chunk of chunks) { for (const file of decodeChunk(chunk.payload)) files.set(`${file.name}\n${file.segments[0]?.[1] ?? ''}`, file) } return [...files.values()].sort((a, b) => a.name.localeCompare(b.name)) } async build(releaseId: number): Promise<{ name: string; xml: string } | null> { const release = await db.from('releases').where('id', releaseId).select('name', 'source').first() if (!release) return null const password = (await this.passwords(releaseId))[0]?.password // A release a spot made of its own (no files matched the header pass's): the spot's NZB as read. if (Number(release.source) === SOURCES.spot) { const spot = await db.from('spots').where('release_id', releaseId).whereNotNull('nzb').select('nzb').first() if (spot) return { name: release.name, xml: withPassword(gunzipSync(spot.nzb).toString('utf8'), password) } } const xml = buildNzb( (await this.files(releaseId)).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, password } ) return { name: release.name, xml } } } /** A read NZB with the password in its meta, when it has none (the spot's title or text named it, not its NZB). */ export function withPassword(xml: string, password: string | undefined): string { if (!password || /${password.replace(/&/g, '&').replace(//g, '>')}` if (/]*>/i.test(xml)) return xml.replace(/]*>/i, (head) => `${head}${meta}`) return xml.replace(/]*>/i, (nzb) => `${nzb}${meta}`) } export default new NzbService()