Each release chunk is a release inserted once: release_key the SHA-1 of the identity, the owner group and the first article (deterministic; no numbering under a group lock), identity_key the identity's SHA-1 bytes, poster_id and group_set_id through catalog_refs (cached per process), source as a code (weak-stem added), the name into release_texts. A finished release's candidates are claimable at once; a provisional tail release is finished by a later chunk of its key, a finished one sent again is left alone. Gone: joins into earlier releases and across groups, key numbering, the group lock, the fragment merger. Interim: chunks, counts and samples keyed by ranges.block_id, a range without a block gets one. Readers follow: search (posters and group_sets joined, words through release_texts, posters by id, source by code), the par2 merger, the sidecar folder, the judge's scraped rule, the spot inspector, the spotter, the NZB service, releases:regroup, edge:bench. Tests insert fixtures through tests/functional/helpers/releases.ts; the suites pass again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
66 lines
3.4 KiB
TypeScript
66 lines
3.4 KiB
TypeScript
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<ChunkFile[]> {
|
|
const chunks = await client.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload')
|
|
const files = new Map<string, ChunkFile>()
|
|
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 || /<meta\s+type=["']password["']/i.test(xml)) return xml
|
|
const meta = `<meta type="password">${password.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</meta>`
|
|
if (/<head\b[^>]*>/i.test(xml)) return xml.replace(/<head\b[^>]*>/i, (head) => `${head}${meta}`)
|
|
return xml.replace(/<nzb\b[^>]*>/i, (nzb) => `${nzb}<head>${meta}</head>`)
|
|
}
|
|
|
|
export default new NzbService()
|