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:
1 parent
bcb9be5da9
commit
be8a3d4580
97 files changed
+4454
-268
No files matched your search
+33
-18
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user