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>
81 lines
3.9 KiB
TypeScript
81 lines
3.9 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. */
|
|
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()
|