import { createHash } from 'node:crypto' import { gzipSync } from 'node:zlib' import db from '@adonisjs/lucid/services/db' import logger from '@adonisjs/core/services/logger' import env from '#start/env' import coordinatorConfig from '#config/coordinator' import { XoverClient } from '#lib/nntp/index' import { decodeSpotBinary } from '#lib/spotnet/index' import { parseNzb, type ParsedNzb } from '#lib/nzb/index' import { parseSubject } from '#lib/headers/index' import { releaseIdentity } from '#lib/release/index' import { identityKey, releaseKey } from '#lib/release/keys' import { SOURCES } from '#lib/release/source' import CatalogRefs from '#services/catalog_refs' /** Spots claimed per round, newest first. */ const BATCH = 20 /** Between rounds when there was nothing to do. */ const IDLE_MS = 60_000 /** Unmatched spots are matched again this often (the header pass may have built their releases since). */ const REMATCH_MS = 60 * 60 * 1000 /** * Unmatched spots matched again per hour: after a reset every spot is (16.5k at 500 an hour took 33 h). Never tried * first, then the newest: the oldest inspection first tried a 3-month backfill before today's spots, the ones whose * releases the tail has (2 of 16.7k matched 3 h after reset 24). */ const REMATCH_BATCH = 5000 /** Unmatched spots are matched again for this long after their inspection. */ const REMATCH_DAYS = 30 /** A file's release posted further apart than this from the NZB's date is another upload of the same name. */ const MAX_SPREAD_MS = 48 * 3600 * 1000 export interface InspectRound { inspected: number matched: number standalone: number outcomes: Record } interface SpotFile { idx: number name: string subject: string poster: string groups: string[] postedAt: Date | null bytes: number segments: number } /** * A placeholder NZB (Dummy.rar + Dummy.nzb, shared by many spots): under 1% of the size the spot announces, or under * 16 KB in all (text-only spots). Its files aren't listed (voyager 1's isDummyNzb). */ export function isDummyNzb(nzb: ParsedNzb, spotSize: number): boolean { const bytes = nzb.files.reduce((sum, f) => sum + f.segments.reduce((s, x) => s + x.bytes, 0), 0) return bytes < 16 * 1024 || (spotSize > 0 && bytes < spotSize / 100) } /** A file of an NZB as the header pass names it: the subject's file name, by the same parser. */ export function nzbFileName(subject: string): { name: string; base: string } { const parsed = parseSubject(subject) ?? parseSubject(`${subject} (1/1)`) return parsed ? { name: parsed.name, base: parsed.base } : { name: subject, base: subject } } /** * The spot inspector (REBUILD.md, spots): looks inside each listed spot, on * one NNTP connection of its own. It reads the spot's compressed NZB (its * articles in alt.binaries.ftd), keeps the list of files in `spot_files`, * and matches every file to the release the header pass built for it: the * release identity of the file's subject and poster (lib/release, as the * edge workers compute it), in one of the file's groups, posted within 48 h. * Each matched release is linked in `release_spots`, and gets the spot's * password in `release_passwords` (the NZB's own `` * first, else what the spot's title or text names) and `passworded`. A spot * whose files match nothing is matched again every hour, for 30 days; once * SPOT_STANDALONE_AFTER_HOURS have passed since its inspection it becomes a * release of its own (source `spot`) with its NZB, until the header pass * builds the releases after all (a spot older than what the catalog has * indexed): then it moves to those, and its own release goes. */ export class SpotInspector { private stopped = false private client?: XoverClient private groupIds = new Map() private rematchedAt = 0 stop() { this.stopped = true this.client?.close() } async run() { while (!this.stopped) { let round: InspectRound | null = null try { round = await this.round() if (round.inspected > 0 || round.standalone > 0) { const outcomes = Object.entries(round.outcomes).map(([k, n]) => `${n} ${k}`).join(', ') logger.info(`Spots inspected: ${round.inspected} (${outcomes}); ${round.matched} matched releases, ${round.standalone} of their own.`) } } catch (error: any) { logger.error(`Spot inspection failed: ${error.message}`) this.client?.close() this.client = undefined } if (!round || round.inspected === 0) { // Nothing to inspect for a while: closed (nntpIdleCloseMs), opened again with work. const idleMs = coordinatorConfig.nntpIdleCloseMs if (idleMs > 0 && IDLE_MS >= idleMs) { this.client?.close() this.client = undefined } for (let waited = 0; waited < IDLE_MS && !this.stopped; waited += 1000) await new Promise((r) => setTimeout(r, 1000)) } } } private async connect(): Promise { this.client ??= await XoverClient.connect({ host: env.get('NNTP_HOST')!, port: env.get('NNTP_PORT', 563), user: env.get('NNTP_USER'), password: env.get('NNTP_PASSWORD'), secure: env.get('NNTP_SECURE', true), compress: false, timeoutMs: coordinatorConfig.nntpTimeoutMs, }) return this.client } async round(): Promise { const round: InspectRound = { inspected: 0, matched: 0, standalone: 0, outcomes: {} } const spots = await db.from('spots').where('status', 'listed').orderBy('posted_at', 'desc').limit(BATCH) for (const spot of spots) { if (this.stopped) break const outcome = await this.inspect(spot) round.inspected++ round.outcomes[outcome.status] = (round.outcomes[outcome.status] ?? 0) + 1 round.matched += outcome.matched } if (Date.now() - this.rematchedAt >= REMATCH_MS && !this.stopped) { this.rematchedAt = Date.now() const again = await this.rematch() round.matched += again.matched round.standalone += again.standalone } return round } /** One spot: its NZB read, its files listed and matched. */ async inspect(spot: any): Promise<{ status: string; matched: number }> { const done = async (status: string, extra: Record = {}) => { await db.from('spots').where('message_id', spot.message_id).update({ status, inspected_at: new Date(), ...extra }) return { status, matched: 0 } } const segments: string[] = spot.nzb_segments ?? [] if (segments.length === 0) return done('no-nzb') if (segments.length > coordinatorConfig.spots.maxNzbSegments) return done('no-nzb', { error: `${segments.length} NZB articles` }) const client = await this.connect() const bodies = await client.bodies(segments) if (bodies.some((b) => b === null)) return done('nzb-gone', { error: `${bodies.filter((b) => b === null).length} of ${segments.length} NZB articles gone` }) let xml: string try { xml = decodeSpotBinary(bodies as Buffer[]).toString('utf8') } catch (error: any) { return done('nzb-unreadable', { error: String(error.message).slice(0, 500) }) } return this.inspectNzb(spot, xml) } /** The files of a spot's NZB listed and matched (the tests feed this directly). */ async inspectNzb(spot: any, xml: string): Promise<{ status: string; matched: number }> { const nzb = parseNzb(xml) const finish = (status: string, extra: Record = {}) => db.from('spots').where('message_id', spot.message_id).update({ status, inspected_at: new Date(), ...extra }) if (!nzb || nzb.files.length === 0) { await finish('no-nzb') return { status: 'no-nzb', matched: 0 } } if (isDummyNzb(nzb, Number(spot.size ?? 0))) { await finish('dummy') return { status: 'dummy', matched: 0 } } const files: SpotFile[] = nzb.files.map((f, idx) => ({ idx, name: nzbFileName(f.subject).name.slice(0, 1000), subject: f.subject.slice(0, 2000), poster: f.poster, groups: f.groups, postedAt: f.date ? new Date(f.date * 1000) : null, bytes: f.segments.reduce((s, x) => s + x.bytes, 0), segments: f.segments.length, })) // The NZB's own password over what the spot's text names. const password = nzb.meta.password ? { password: nzb.meta.password.slice(0, 200), source: 'nzb' } : spot.password ? { password: spot.password, source: spot.password_source } : null await db.transaction(async (trx) => { await trx.from('spot_files').where('spot_id', spot.message_id).delete() for (let i = 0; i < files.length; i += 1000) { await trx.table('spot_files').multiInsert( files.slice(i, i + 1000).map((f) => ({ spot_id: spot.message_id, idx: f.idx, name: f.name, subject: f.subject, poster: f.poster, groups: f.groups, posted_at: f.postedAt, bytes: f.bytes, segments: f.segments, })) ) } await trx .from('spots') .where('message_id', spot.message_id) .update({ status: 'inspected', inspected_at: new Date(), files: files.length, matched: 0, nzb: gzipSync(xml), ...(password && password.source === 'nzb' ? { password: password.password, password_source: 'nzb' } : {}), }) }) const matched = await this.match(spot.message_id) return { status: 'inspected', matched } } private async groupId(name: string): Promise { if (!this.groupIds.has(name)) { const row = await db.from('groups').where('name', name).select('id').first() this.groupIds.set(name, row ? Number(row.id) : null) } return this.groupIds.get(name)! } /** * Matches a spot's listed files to releases and links them; how many releases. A file's release: the same * identity (releaseIdentity over its subject and poster) in one of its groups, the one posted nearest the file. */ async match(spotId: string): Promise { const spot = await db.from('spots').where('message_id', spotId).first() if (!spot || spot.status !== 'inspected') return 0 const files = await db.from('spot_files').where('spot_id', spotId).orderBy('idx') const wanted: { idx: number; key: string; groups: number[]; at: number | null }[] = [] for (const f of files) { if (!f.subject) continue const { name, base } = nzbFileName(f.subject) const identity = releaseIdentity({ poster: f.poster ?? '', subject: base, name, weak: false }) if (!identity) continue const groups = (await Promise.all((f.groups ?? []).map((g: string) => this.groupId(g)))).filter((g): g is number => g !== null) if (groups.length > 0) wanted.push({ idx: f.idx, key: identity.key, groups, at: f.posted_at ? new Date(f.posted_at).getTime() : null }) } if (wanted.length === 0) return 0 const { rows } = await db.rawQuery( `SELECT id, encode(identity_key, 'hex') AS identity_key, group_id, posted_at FROM releases WHERE identity_key = ANY(?::bytea[]) AND group_id = ANY(?)`, [[...new Set(wanted.map((w) => w.key))].map((k) => identityKey(k)), [...new Set(wanted.flatMap((w) => w.groups))]] ) const byKey = new Map() for (const r of rows) { const list = byKey.get(r.identity_key) ?? [] list.push({ id: Number(r.id), group: Number(r.group_id), at: r.posted_at ? new Date(r.posted_at).getTime() : null }) byKey.set(r.identity_key, list) } const fileRelease = new Map() for (const w of wanted) { const near = (byKey.get(w.key) ?? []) .filter((r) => w.groups.includes(r.group)) .map((r) => ({ ...r, spread: r.at === null || w.at === null ? 0 : Math.abs(r.at - w.at) })) .filter((r) => r.spread <= MAX_SPREAD_MS) .sort((a, b) => a.spread - b.spread) if (near[0]) fileRelease.set(w.idx, near[0].id) } const perRelease = new Map() for (const id of fileRelease.values()) perRelease.set(id, (perRelease.get(id) ?? 0) + 1) if (perRelease.size === 0) return 0 await db.transaction(async (trx) => { // A release of the spot's own goes now that the header pass built its files' releases. if (spot.release_id) { const own = await trx.from('releases').where('id', spot.release_id).where('source', SOURCES.spot).select('id').first() if (own) { await trx.from('release_spots').where('release_id', own.id).delete() await trx.from('release_passwords').where('release_id', own.id).delete() await trx.from('releases').where('id', own.id).delete() } } for (const [idx, releaseId] of fileRelease) await trx.from('spot_files').where({ spot_id: spotId, idx }).update({ release_id: releaseId }) for (const [releaseId, n] of perRelease) { await trx.table('release_spots').insert({ release_id: releaseId, spot_id: spotId, files: n }).onConflict(['release_id', 'spot_id']).merge({ files: n }) } if (spot.password && perRelease.size > 0) { await trx .table('release_passwords') .multiInsert([...perRelease.keys()].map((id) => ({ release_id: id, password: spot.password, source: spot.password_source ?? 'spot-text', spot_id: spotId }))) .onConflict(['release_id', 'password']) .ignore() await trx.from('releases').whereIn('id', [...perRelease.keys()]).update({ passworded: true }) } // The spot's category for the ones whose names say nothing (migration 5200: spot_category). await trx.rawQuery(`UPDATE releases SET category = c FROM (SELECT spot_category(?::integer, ?::jsonb) AS c) s WHERE id = ANY(?) AND category = 8010 AND c IS NOT NULL`, [ spot.category, JSON.stringify(spot.subcats ?? {}), [...perRelease.keys()], ]) const top = [...perRelease.entries()].sort((a, b) => b[1] - a[1])[0] // The NZB is only kept for a release of the spot's own: the header pass's releases have their chunks. await trx.from('spots').where('message_id', spotId).update({ matched: fileRelease.size, release_id: top ? top[0] : null, nzb: null }) }) return perRelease.size } /** Unmatched inspected spots matched again; the ones that waited long enough become releases of their own. */ async rematch(): Promise<{ matched: number; standalone: number }> { const out = { matched: 0, standalone: 0 } const { rows: spots } = await db.rawQuery( `SELECT message_id, inspected_at, release_id FROM spots WHERE status = 'inspected' AND matched = 0 AND inspected_at > now() - make_interval(days => ?) ORDER BY rematched_at NULLS FIRST, posted_at DESC NULLS LAST LIMIT ?`, [REMATCH_DAYS, REMATCH_BATCH] ) const cutoff = Date.now() - coordinatorConfig.spots.standaloneAfterHours * 3600 * 1000 for (const spot of spots) { if (this.stopped) break const n = await this.match(spot.message_id) await db.from('spots').where('message_id', spot.message_id).update({ rematched_at: new Date() }) out.matched += n if (n === 0 && !spot.release_id && new Date(spot.inspected_at).getTime() < cutoff && (await this.standalone(spot.message_id))) out.standalone++ } return out } /** A release of the spot's own (source `spot`), from its listed files; false when it has none. */ async standalone(spotId: string): Promise { const spot = await db.from('spots').where('message_id', spotId).first() const files = await db.from('spot_files').where('spot_id', spotId) if (!spot || files.length === 0) return false const groups = [...new Set(files.flatMap((f: any) => f.groups ?? []))] const groupId = groups.length > 0 ? await this.groupId(groups[0]) : null const key = createHash('sha1').update(`spot\n${spotId}`).digest('hex') // Its poster and group set by id (voyager 3, migration 6000), as the batch store stores its releases'. const posterId = spot.poster ? await CatalogRefs.posterId(db, spot.poster) : null const groupSetId = await CatalogRefs.groupSetId(db, groups) const [row] = await db .table('releases') .insert({ release_key: releaseKey(key, groupId, null), identity_key: identityKey(key), group_id: groupId, group_set_id: groupSetId, poster_id: posterId, name: spot.title.slice(0, 1000), source: SOURCES.spot, posted_at: spot.posted_at, last_posted_at: spot.posted_at, files: files.length, segments: files.reduce((s: number, f: any) => s + Number(f.segments), 0), bytes: files.reduce((s: number, f: any) => s + Number(f.bytes), 0), readable: true, passworded: spot.password ? true : null, settled_at: new Date(), // Kept when its title says nothing (the category trigger, migration 5200). category: db.raw('coalesce(spot_category(?::integer, ?::jsonb), 8010)', [spot.category, JSON.stringify(spot.subcats ?? {})]), }) .onConflict('release_key') .ignore() .returning('id') if (!row) return false const releaseId = Number(row.id) await db.transaction(async (trx) => { await trx.table('release_spots').insert({ release_id: releaseId, spot_id: spotId, files: files.length }).onConflict(['release_id', 'spot_id']).ignore() // Its title, searchable (release_texts). await trx.rawQuery(`INSERT INTO release_texts (release_id, kind, text) VALUES (?, 1, left(?, 255)) ON CONFLICT DO NOTHING`, [releaseId, spot.title]) if (spot.password) { await trx.table('release_passwords').insert({ release_id: releaseId, password: spot.password, source: spot.password_source ?? 'spot-text', spot_id: spotId }).onConflict(['release_id', 'password']).ignore() } await trx.from('spots').where('message_id', spotId).update({ release_id: releaseId }) }) return true } }