Spotnet import, header backfill, staging and counter grouping, faster search

Spotnet (CONCEPT D29):
- Index free.pt fully and import spots with their descriptions, categories
  and NZBs; a spot renames its release, since Spotnet names are more
  trustworthy than names derived from subjects.
- Find passwords in spot descriptions too, and tell "needs a password we
  have" from "needs a password we don't have".

Content inspection:
- List the contents of 7z (.7z.001) archives, SFV sets, nicovideo uploads
  and other archive-like files; mark yEncBin .bin containers as encrypted.
- Take real names from yEnc headers for randomly named files, and merge
  uploads that were split across posts.

Storage (D31, D32):
- Store segment lists zstd-compressed behind the FileStore accessor.
- group:backfill indexes older headers at low priority and pauses when
  the NVMe or HDD part of the database gets too large.

Grouping (D35, D36):
- Lone header segments wait in staged_segments until a second segment of
  their file arrives, which drops the per-article junk binaries.
- Counter grouping joins random-subject uploads by poster, [i/M] counter,
  part count and day.

Search and maintenance (D33, D34):
- A trigram-indexed search_text column, smaller indexes, estimated stats.
- Passes that clean up nonsense names; weekly index maintenance; GC budgets
  per kind.
- Web UI: grey out stale results while a search runs and cancel superseded
  requests. Count only up to 1,000 matches for the page, which made filter
  clicks take seconds (Newznab keeps the 10,000 cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-03 19:59:23 +02:00
1 parent bcb9be5da9
commit be8a3d4580
97 files changed
+4454 -268

No files matched your search

+4
View File
@@ -49,6 +49,10 @@ export default class CollectionWorker extends BaseCommand {
if (content?.named) this.logger.info(`"${file.filename}" got its real name from par2.`)
if (content?.contentType === 'nzbe') this.logger.info(`"${file.filename}" is an encrypted NZB container.`)
if (content?.contentType === 'rclone') this.logger.info(`"${file.filename}" is rclone-encrypted.`)
if (content?.yencName) this.logger.info(`"${file.filename}" is "${content.yencName}" (yEnc name).`)
if (content?.nicovideo) this.logger.info(`"${file.filename}" → "${content.nicovideo}".`)
if (content?.sfvMoved) this.logger.info(`SFV "${file.filename}" moved ${content.sfvMoved} files into its release.`)
if (content?.contentType === 'yencbin') this.logger.info(`"${file.filename}" is a yEncBin encrypted container.`)
if (content?.nzb) {
const n = content.nzb
this.logger.info(`NZB "${file.filename}": ${n.listed} files (${n.known} known, ${n.added} added) → "${n.releaseName}".`)
+13 -5
View File
@@ -12,6 +12,7 @@ import FetchRangeService from '#services/FetchRangeService'
import IngestService, { segmentFromHeader, type IngestSegment } from '#services/IngestService'
import DeferredService, { bodyJob, isDeferred, type DeferredArticle } from '#services/DeferredService'
import SpotService, { isSpotGroup } from '#services/SpotService'
import StagingService from '#services/StagingService'
export default class FetchWorker extends BaseCommand {
public static commandName = 'worker:fetch'
@@ -36,8 +37,10 @@ export default class FetchWorker extends BaseCommand {
let failed = false
try {
conn = await pool.acquire()
await conn.group(groupName)
overviews = (await conn.xover(startId, endId)).overviews
await pool.timed(conn.group(groupName), `GROUP ${groupName}`)
const reply = await pool.timed<any>(conn.xover(startId, endId), `XOVER ${groupName} ${startId}-${endId}`)
// An empty reply parses as one line without fields ([null, {}]).
overviews = reply.overviews.filter(([number, header]: [number | null, Record<string, string>]) => number !== null && header?.['message-id'])
} catch (error: any) {
failed = true
this.logger.error(`Error fetching headers for ${groupName}: ${error.message}`)
@@ -56,7 +59,8 @@ export default class FetchWorker extends BaseCommand {
}
const group = await db.from('groups').where('name', groupName).first()
const fetchBodies = Boolean(group?.fetch_bodies)
// Backfill is header-only: old bodies would be far more than the link carries (CONCEPT.md D32).
const fetchBodies = Boolean(group?.fetch_bodies) && !job.data.backfill
const segments: IngestSegment[] = []
const obfuscated: { articleNumber: number; header: Record<string, string> }[] = []
@@ -72,7 +76,11 @@ export default class FetchWorker extends BaseCommand {
else obfuscated.push({ articleNumber, header })
}
const completed = await IngestService.ingest(segments)
// Lone segments of new files wait in staging until their file proves real (D35).
const { ingest, staged } = StagingService.enabled(groupName)
? await StagingService.filter(segments, { counters: StagingService.countersEnabled(groupName) })
: { ingest: segments, staged: 0 }
const completed = await IngestService.ingest(ingest)
const deferredFiles = await DeferredService.record(groupName, deferred)
if (obfuscated.length > 0) {
@@ -82,7 +90,7 @@ export default class FetchWorker extends BaseCommand {
if (job.data.rangeId) await FetchRangeService.markDone(job.data.rangeId)
this.logger.info(
`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${segments.length} stored, ` +
`${groupName} ${startId}-${endId}: ${overviews.length} headers, ${ingest.length} stored${staged ? `, ${staged} staged` : ''}, ` +
`${obfuscated.length} sent to body-queue${skipped ? ` (${skipped} skipped)` : ''}` +
`${deferred.length ? `, ${deferred.length} deferred (${deferredFiles} files)` : ''}, ${completed.length} files completed.`
)
+33 -18
View File
@@ -2,6 +2,9 @@ import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import FileStore from '#services/FileStore'
import SfvService from '#services/SfvService'
import queueConfig from '#config/queue'
import Shutdown from '#services/Shutdown'
import QueueService from '#services/QueueService'
@@ -42,15 +45,10 @@ export default class FileWorker extends BaseCommand {
throw new Error(`Binary ${binaryId} has ${parts.length}/${binary.total_parts} parts.`)
}
const messageIds = Object.fromEntries(
parts.map((part) => [part.number, { id: part.message_id, size: part.bytes }])
)
const segments = parts.map((part) => ({ number: Number(part.number), id: part.message_id, size: Number(part.bytes) }))
// Already known as a file, e.g. added from a posted NZB before its own articles completed.
const existing = await db
.from('files')
.whereRaw(`(message_ids->'1'->>'id') = ?`, [messageIds['1']?.id ?? ''])
.first()
const existing = await db.from('files').where('first_message_id', segments[0]?.id ?? '').first()
if (existing) {
await db.from('binaries').where('id', binaryId).update({ file_id: existing.id })
await dropCopiedParts(binary)
@@ -59,26 +57,23 @@ export default class FileWorker extends BaseCommand {
}
// file_key is unique: a second completion of the same upload inserts nothing.
const knex = db.connection().getWriteClient()
const now = new Date()
const [inserted] = await knex('files')
.insert({
const [inserted] = await FileStore.insert([
{
file_key: binary.file_key,
filename: binary.name,
poster: binary.poster,
date: binary.posted_at ? new Date(binary.posted_at).getTime() : now.getTime(),
parts: parts.length,
message_ids: JSON.stringify(messageIds),
segments,
groups: JSON.stringify(binary.groups),
release_id: binary.release_id,
real_name: binary.real_name,
size: parts.reduce((sum, part) => sum + Number(part.bytes), 0),
created_at: now,
updated_at: now,
})
.onConflict('file_key')
.ignore()
.returning('id')
},
])
const fileId =
inserted?.id ?? (await db.from('files').where('file_key', binary.file_key).first())?.id
@@ -91,8 +86,21 @@ export default class FileWorker extends BaseCommand {
}
this.logger.info(`Saved file "${binary.name}" (${parts.length} parts) as ID ${fileId}.`)
// An SFV posted earlier may already list it (D34).
await SfvService.claim({
id: Number(fileId),
name: binary.real_name ?? binary.name,
poster: binary.poster,
date: binary.posted_at ? new Date(binary.posted_at).getTime() : null,
release_id: binary.release_id,
})
await collectionQueue.add('process-collection', { fileId }, { jobId: `file-${fileId}` })
}, { connection: queueConfig.connection })
}, {
connection: queueConfig.connection,
// One job at a time could not keep up with the backfill (130k waiting); jobs are per binary and
// the file insert ignores duplicates, so they can run side by side.
concurrency: env.get('FILE_CONCURRENCY', 8),
})
worker.on('failed', (job, err) => {
this.logger.error(`File job ${job?.id} failed: ${err.message}`)
@@ -112,6 +120,13 @@ export default class FileWorker extends BaseCommand {
* ones keep them, so articles of an equal-sized next volume are recognised
* as a conflict and split off (CONCEPT.md D20). LOG.md 2026-09-28 (17).
*/
async function dropCopiedParts(binary: { id: number; size: number | null }) {
if (binary.size === null) await db.from('parts').where('binary_id', binary.id).delete()
/**
* The file holds the segment list now. Header-derived parts (no per-part
* CRC) aren't needed any more; body-derived ones are kept for split-volume
* verification (D20). Going by the CRC, not the binary's size: content
* inspection gives header-derived binaries a size too, which kept ~20M
* parts around (LOG.md 2026-10-02).
*/
async function dropCopiedParts(binary: { id: number }) {
await db.from('parts').where('binary_id', binary.id).whereNull('pcrc32').delete()
}
+13 -4
View File
@@ -6,6 +6,10 @@ import NntpService from '#services/NntpService'
import SpotService from '#services/SpotService'
const IDLE_MS = 30_000
/** Spots claimed at a time; most of a history backfill is listed in bulk. */
const BATCH = 500
/** Imported spots whose details are read per idle round. */
const DETAILS_BATCH = 50
/**
* Works through queued Spotnet spots (CONCEPT.md D29): verifies each one,
@@ -29,17 +33,22 @@ export default class SpotsWorker extends BaseCommand {
let done = 0
try {
while (!Shutdown.requested && (!this.limit || done < this.limit)) {
const batch = await SpotService.claim(Math.min(concurrency * 4, this.limit ? this.limit - done : Infinity))
const batch = await SpotService.claim(Math.min(BATCH, this.limit ? this.limit - done : Infinity))
if (batch.length === 0) {
if (this.limit) break
await Shutdown.sleep(IDLE_MS)
// Idle: read the details of imported spots that don't have them yet (newest first).
if ((await SpotService.fillDetails(DETAILS_BATCH)) === 0) await Shutdown.sleep(IDLE_MS)
continue
}
// Old, verified spots are listed in bulk; the rest need their header (and maybe NZB) read.
const listable = batch.filter((spot) => SpotService.isListable(spot))
const single = batch.filter((spot) => !SpotService.isListable(spot))
counts.listed = (counts.listed ?? 0) + (await SpotService.list(listable))
let next = 0
await Promise.all(
Array.from({ length: concurrency }, async () => {
while (next < batch.length) {
const status = await SpotService.process(batch[next++])
while (next < single.length) {
const status = await SpotService.process(single[next++])
counts[status] = (counts[status] ?? 0) + 1
}
})