59% of settled releases had complete = NULL: without an [i/N] counter the settler left it unknown, and the "phase 2 reads par2 sets for those" step was never built. A release is now complete when every file its par2 sets describe is there by name or by its first-16-KiB hash, or, for an upload renamed after its par2 was made, when it has as many non-par2 files and bytes. Decided when a par2 index is probed, after a par2 merge, and before the pruner drops a set's file list; the batch store, settler and merger keep a decided value instead of resetting it to NULL. releases:par2-complete decides the releases probed before this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65 lines
3.3 KiB
TypeScript
65 lines
3.3 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'
|
|
|
|
/** 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 (release.source === '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()
|