- lib/nntp/xover_client: a minimal one-connection client for seed:range with XFEATURE COMPRESS GZIP (a zlib stream after "224 … [COMPRESS=GZIP]", then a "." line; 420 for an empty range). About 6x less header traffic. Overviews come out with the field names nntp-js uses. - SfvService.claimAll: one sfv_entries lookup per stored batch. - Fragments go through the regular ingest path beside fetching (at most two batches in flight); a failure fails the run at the end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96 lines
4.6 KiB
TypeScript
96 lines
4.6 KiB
TypeScript
import db from '@adonisjs/lucid/services/db'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
import { parseSfv, sfvReleaseName } from '#lib/content/index'
|
|
|
|
/** Releases made by name rules alone; an SFV's listing outranks them. */
|
|
const WEAK_SOURCES = ['stem', 'weak-stem', 'subject']
|
|
/** Files further apart than this from the SFV in posting time aren't taken. */
|
|
const WINDOW_MS = 2 * 24 * 60 * 60 * 1000
|
|
|
|
/**
|
|
* SFV grouping (CONCEPT.md D34). An uploader that posts every track of an
|
|
* album as its own file (13.5k such releases from one poster on homelab)
|
|
* still posts the album's SFV, which names them all.
|
|
*/
|
|
class SfvService {
|
|
/**
|
|
* Records an SFV file's listing on its release, moves the listed files that
|
|
* sit in weak releases into it, and names the release after the album when
|
|
* it's still named after a track or the SFV itself. Returns how many files
|
|
* moved, or null when it isn't an SFV.
|
|
*/
|
|
async attach(sfvFile: any, text: string): Promise<number | null> {
|
|
const entries = parseSfv(text)
|
|
if (entries.length === 0 || !sfvFile.release_id) return null
|
|
const releaseId = Number(sfvFile.release_id)
|
|
const postedAt = sfvFile.date ? new Date(Number(sfvFile.date)) : null
|
|
await db
|
|
.knexQuery()
|
|
.table('sfv_entries')
|
|
.insert(entries.map((e) => ({ release_id: releaseId, name: e.name, crc: e.crc, poster: sfvFile.poster, posted_at: postedAt })))
|
|
.onConflict(['release_id', 'name'])
|
|
.ignore()
|
|
await db.from('files').where('id', sfvFile.id).update({ content_type: 'sfv' })
|
|
|
|
const candidates = await db
|
|
.from('files as f')
|
|
.join('releases as r', 'r.id', 'f.release_id')
|
|
.whereIn(db.raw('coalesce(f.real_name, f.filename)') as any, entries.map((e) => e.name))
|
|
.whereNot('f.release_id', releaseId)
|
|
.whereIn('r.source', WEAK_SOURCES)
|
|
.select('f.id', 'f.poster', 'f.date')
|
|
const moving = candidates.filter((f) => this.sameUpload(f, sfvFile.poster, postedAt))
|
|
await this.move(moving.map((f) => Number(f.id)), releaseId)
|
|
|
|
const release = await db.from('releases').where('id', releaseId).first()
|
|
const album = sfvReleaseName(String(sfvFile.real_name ?? sfvFile.filename))
|
|
if (release && WEAK_SOURCES.includes(release.source) && album !== release.name && /^\d{1,3}-/.test(release.name)) {
|
|
await db.from('releases').where('id', releaseId).update({ name: album })
|
|
}
|
|
logger.info(`SFV "${sfvFile.filename}": ${entries.length} files listed, ${moving.length} moved into release ${releaseId} "${album}".`)
|
|
return moving.length
|
|
}
|
|
|
|
/** A new file an SFV already listed joins that SFV's release. Returns the release, or null. */
|
|
/**
|
|
* claim() for a batch of new files: one lookup for all their names, the
|
|
* per-file work only for the few an SFV lists (seeding stores thousands
|
|
* of files per batch; CONCEPT.md D37).
|
|
*/
|
|
async claimAll(files: { id: number; name: string; poster?: string | null; date?: number | null; release_id?: number | null }[]): Promise<number> {
|
|
if (files.length === 0) return 0
|
|
const listed = new Set(
|
|
(await db.from('sfv_entries').whereIn('name', [...new Set(files.map((f) => f.name))]).select('name')).map((e) => e.name as string)
|
|
)
|
|
let moved = 0
|
|
for (const file of files) if (listed.has(file.name) && (await this.claim(file)) !== null) moved++
|
|
return moved
|
|
}
|
|
|
|
async claim(file: { id: number; name: string; poster?: string | null; date?: number | null; release_id?: number | null }): Promise<number | null> {
|
|
const entries = await db.from('sfv_entries').where('name', file.name)
|
|
const entry = entries.find((e) => this.sameUpload(file, e.poster, e.posted_at ? new Date(e.posted_at) : null))
|
|
if (!entry || Number(entry.release_id) === Number(file.release_id)) return null
|
|
if (file.release_id) {
|
|
const current = await db.from('releases').where('id', file.release_id).first()
|
|
if (current && !WEAK_SOURCES.includes(current.source)) return null
|
|
}
|
|
await this.move([file.id], Number(entry.release_id))
|
|
return Number(entry.release_id)
|
|
}
|
|
|
|
private sameUpload(file: { poster?: string | null; date?: number | string | null }, poster: string | null, postedAt: Date | null) {
|
|
if (poster && file.poster && file.poster !== poster) return false
|
|
if (postedAt && file.date && Math.abs(Number(file.date) - postedAt.getTime()) > WINDOW_MS) return false
|
|
return true
|
|
}
|
|
|
|
private async move(fileIds: number[], releaseId: number) {
|
|
if (fileIds.length === 0) return
|
|
await db.from('files').whereIn('id', fileIds).update({ release_id: releaseId })
|
|
await db.from('binaries').whereIn('file_id', fileIds).update({ release_id: releaseId })
|
|
}
|
|
}
|
|
|
|
export default new SfvService()
|