Random-named uploads that never get a real name or password were ~90% of what seeding stores. Two filters: - before storing (seeding, ingest): random-named uploads of a dropped signature (posting tool from the Message-ID, first group) or of a release dropped after its probe aren't stored; 2% of a dropped signature's releases are still kept and probed. Stored files of dropped signatures aren't probed. - after a probe: a random-named release that is still unnamed, without password, names from files or spot, with all its probes done, and encrypted, a container or missing its first article, is deleted and tombstoned in dropped_releases. Signatures are learned by gc:scheduler from a sample of probed releases (signature_probes); garbage:learn --seed fills it from existing files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
11 KiB
TypeScript
218 lines
11 KiB
TypeScript
import db from '@adonisjs/lucid/services/db'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
import indexerConfig from '#config/indexer'
|
|
import { looksRandom } from '#services/ArchiveService'
|
|
import { fileStem } from '#lib/release/index'
|
|
import { explored, signatureGroup, signatureKey, uploadTool } from '#lib/garbage/index'
|
|
|
|
/** What the filter needs to know about an upload before it is stored. */
|
|
export interface Upload {
|
|
/** The posted file name. */
|
|
name: string
|
|
/** Any of its articles' Message-IDs (one tool posts all of a file). */
|
|
messageId: string | null | undefined
|
|
groups: unknown
|
|
/** Its release's key (the file key when it has none): kept or dropped as a whole. */
|
|
releaseKey: string
|
|
}
|
|
|
|
/** Contents that can't name a random-named release without a key (LOG.md 2026-10-08 (5)). */
|
|
const CONTAINERS = ['rclone', 'nzbe', 'yencbin']
|
|
|
|
/** How long the dropped signatures are cached per process. */
|
|
const CACHE_MS = 5 * 60 * 1000
|
|
|
|
export function isRandomName(name: string) {
|
|
return looksRandom(fileStem(name))
|
|
}
|
|
|
|
/**
|
|
* Garbage: random-named uploads that never get a real name and have no
|
|
* known password (CONCEPT.md D42). Two filters:
|
|
* - before storing (seeding, ingest): random-named uploads whose signature
|
|
* (posting tool, first group) is dropped, or whose release was dropped
|
|
* after its probe, aren't stored;
|
|
* - after a probe: a random-named release whose probes are all done, that
|
|
* is still without a real name, password, names from its files or spot,
|
|
* and is encrypted, a container or missing its first article, is deleted.
|
|
* The dropped signatures are learned from a sample of probed releases
|
|
* (learn(), gc:scheduler).
|
|
*/
|
|
class GarbageFilter {
|
|
private dropped: Set<string> | null = null
|
|
private loadedAt = 0
|
|
|
|
/** The dropped signatures, cached for CACHE_MS. */
|
|
async droppedSignatures(): Promise<Set<string>> {
|
|
if (this.dropped && Date.now() - this.loadedAt < CACHE_MS) return this.dropped
|
|
const rows = await db.from('upload_signatures').where('dropped', true).select('tool', 'group_name')
|
|
this.dropped = new Set(rows.map((row) => signatureKey(row.tool, row.group_name)))
|
|
this.loadedAt = Date.now()
|
|
return this.dropped
|
|
}
|
|
|
|
/** Forgets the cached signatures (after learn(), and in tests). */
|
|
reset() {
|
|
this.dropped = null
|
|
}
|
|
|
|
/** Whether a random-named upload's signature is dropped and it isn't in the explored share. */
|
|
private droppedBySignature(upload: Upload, signatures: Set<string>) {
|
|
if (signatures.size === 0) return false
|
|
if (!signatures.has(signatureKey(uploadTool(upload.messageId), signatureGroup(upload.groups)))) return false
|
|
return !explored(upload.releaseKey, indexerConfig.garbage.explorePercent)
|
|
}
|
|
|
|
/**
|
|
* The items worth storing: drops random-named uploads of dropped signatures
|
|
* (but explorePercent of their releases) and of dropped releases.
|
|
*/
|
|
async keep<T>(items: T[], upload: (item: T) => Upload): Promise<{ kept: T[]; dropped: number }> {
|
|
if (!indexerConfig.garbage.enabled || items.length === 0) return { kept: items, dropped: 0 }
|
|
const signatures = await this.droppedSignatures()
|
|
// Per release, kept as a whole when any of its files has a readable name. A file is
|
|
// looked at once: an ingest batch has many articles of each.
|
|
const verdicts = new Map<string, 'keep' | 'drop' | 'check'>()
|
|
const seen = new Set<string>()
|
|
for (const item of items) {
|
|
const u = upload(item)
|
|
if (verdicts.get(u.releaseKey) === 'keep' || seen.has(`${u.releaseKey}\n${u.name}`)) continue
|
|
seen.add(`${u.releaseKey}\n${u.name}`)
|
|
if (!isRandomName(u.name)) verdicts.set(u.releaseKey, 'keep')
|
|
else if (!verdicts.has(u.releaseKey)) verdicts.set(u.releaseKey, this.droppedBySignature(u, signatures) ? 'drop' : 'check')
|
|
}
|
|
const check = [...verdicts].filter(([, v]) => v === 'check').map(([key]) => key)
|
|
for (let i = 0; i < check.length; i += 5000) {
|
|
const rows = await db.from('dropped_releases').whereIn('release_key', check.slice(i, i + 5000)).select('release_key')
|
|
for (const row of rows) verdicts.set(row.release_key, 'drop')
|
|
}
|
|
const kept = items.filter((item) => verdicts.get(upload(item).releaseKey) !== 'drop')
|
|
return { kept, dropped: items.length - kept.length }
|
|
}
|
|
|
|
/**
|
|
* Whether a stored file's probe can be skipped: a random-named file of a
|
|
* dropped signature, outside the explored share (files stored before its
|
|
* signature was dropped). Its binary stops waiting for inspection.
|
|
*/
|
|
async skipProbe(file: { id: number | string; filename: string; real_name?: string | null; first_message_id?: string | null; groups: unknown; release_id?: number | string | null }) {
|
|
if (!indexerConfig.garbage.enabled || file.real_name || !isRandomName(file.filename)) return false
|
|
const signatures = await this.droppedSignatures()
|
|
if (!signatures.has(signatureKey(uploadTool(file.first_message_id), signatureGroup(file.groups)))) return false
|
|
const release = file.release_id ? await db.from('releases').where('id', file.release_id).select('release_key').first() : null
|
|
if (explored(release?.release_key ?? String(file.id), indexerConfig.garbage.explorePercent)) return false
|
|
await db.from('binaries').where('file_id', file.id).where('awaiting_inspection', true).update({ awaiting_inspection: null })
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* After a file's probe has been applied: notes its release for learning
|
|
* (a sample) and deletes the release when it is garbage. Never throws:
|
|
* the probe's result is stored already. Returns why the release was
|
|
* deleted, or null.
|
|
*/
|
|
async afterProbe(fileId: number, contentType: string | null): Promise<string | null> {
|
|
try {
|
|
const file = await db.from('files').where('id', fileId).select('id', 'filename', 'real_name', 'first_message_id', 'groups', 'release_id').first()
|
|
if (!file?.release_id || !isRandomName(String(file.filename))) return null
|
|
const releaseId = Number(file.release_id)
|
|
if (releaseId % indexerConfig.garbage.probeSample === 0) {
|
|
await db.rawQuery(`INSERT INTO signature_probes (release_id, tool, group_name) VALUES (?, ?, ?) ON CONFLICT DO NOTHING`, [
|
|
releaseId,
|
|
uploadTool(file.first_message_id),
|
|
signatureGroup(file.groups),
|
|
])
|
|
}
|
|
if (!indexerConfig.garbage.enabled) return null
|
|
return await this.dropIfGarbage(releaseId, contentType)
|
|
} catch (error: any) {
|
|
logger.warn(`Garbage check after probing file ${fileId}: ${error.message}`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
private async dropIfGarbage(releaseId: number, contentType: string | null): Promise<string | null> {
|
|
const { rows } = await db.rawQuery(
|
|
`SELECT r.release_key, r.container, r.encrypted,
|
|
is_real_release_name(r.name) OR r.password IS NOT NULL OR coalesce(r.search_files, '') <> ''
|
|
OR EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id) AS named,
|
|
EXISTS (SELECT 1 FROM binaries b WHERE b.release_id = r.id AND b.awaiting_inspection) AS waiting
|
|
FROM releases r WHERE r.id = ?`,
|
|
[releaseId]
|
|
)
|
|
const release = rows[0]
|
|
if (!release || release.named || release.waiting) return null
|
|
const reason = CONTAINERS.includes(release.container) ? release.container : release.encrypted === true ? 'encrypted' : contentType === 'missing' ? 'missing' : null
|
|
if (!reason) return null
|
|
await db.transaction(async (trx) => {
|
|
await trx.rawQuery(`INSERT INTO dropped_releases (release_key, reason) VALUES (?, ?) ON CONFLICT DO NOTHING`, [release.release_key, reason])
|
|
await trx.rawQuery(`DELETE FROM binaries WHERE release_id = ?`, [releaseId])
|
|
await trx.rawQuery(`DELETE FROM files WHERE release_id = ?`, [releaseId])
|
|
await trx.rawQuery(`DELETE FROM releases WHERE id = ?`, [releaseId])
|
|
})
|
|
return reason
|
|
}
|
|
|
|
/**
|
|
* Learns the signatures from the probed releases of the last windowDays:
|
|
* dropped when at least minProbes are known and at most maxNamedRate got a
|
|
* real name, password, names from their files or a spot. A signature with
|
|
* fewer probes keeps its verdict (a dropped one only gets explored probes).
|
|
* Releases deleted as garbage count as unnamed. Returns the signatures
|
|
* whose verdict changed.
|
|
*/
|
|
async learn(): Promise<{ signatures: number; dropped: number; changed: string[] }> {
|
|
const { minProbes, maxNamedRate, windowDays } = indexerConfig.garbage
|
|
await db.rawQuery(`DELETE FROM signature_probes WHERE probed_at < now() - make_interval(days => ?)`, [windowDays])
|
|
const before = new Map((await db.from('upload_signatures').select('tool', 'group_name', 'dropped')).map((row) => [signatureKey(row.tool, row.group_name), row.dropped]))
|
|
await db.rawQuery(
|
|
`INSERT INTO upload_signatures (tool, group_name, probed, named, dropped, updated_at)
|
|
SELECT tool, group_name, probed, named, probed >= ?::int AND named <= probed * ?::float8, now()
|
|
FROM (
|
|
SELECT p.tool, p.group_name, count(*)::int AS probed,
|
|
(count(*) FILTER (WHERE is_real_release_name(r.name) OR r.password IS NOT NULL OR coalesce(r.search_files, '') <> ''
|
|
OR EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id)))::int AS named
|
|
FROM signature_probes p LEFT JOIN releases r ON r.id = p.release_id
|
|
GROUP BY p.tool, p.group_name
|
|
) s
|
|
ON CONFLICT (tool, group_name) DO UPDATE SET
|
|
probed = excluded.probed, named = excluded.named, updated_at = now(),
|
|
dropped = CASE WHEN excluded.probed >= ?::int THEN excluded.dropped ELSE upload_signatures.dropped END`,
|
|
[minProbes, maxNamedRate, minProbes]
|
|
)
|
|
const after = await db.from('upload_signatures').select('tool', 'group_name', 'dropped', 'probed', 'named')
|
|
const changed = after
|
|
.filter((row) => (before.get(signatureKey(row.tool, row.group_name)) ?? false) !== row.dropped)
|
|
.map((row) => `${row.tool} ${row.group_name || '(no group)'}: ${row.dropped ? 'dropped' : 'kept'} (${row.named}/${row.probed} named)`)
|
|
this.reset()
|
|
return { signatures: after.length, dropped: after.filter((row) => row.dropped).length, changed }
|
|
}
|
|
|
|
/**
|
|
* Fills signature_probes from a sample of files probed before it existed
|
|
* (garbage:learn --seed): random-named files with a content type, from
|
|
* TABLESAMPLE SYSTEM (percent) of files. Returns the releases added.
|
|
*/
|
|
async seed(percent: number): Promise<number> {
|
|
const { rows } = await db.rawQuery(
|
|
`SELECT DISTINCT ON (f.release_id) f.release_id, f.first_message_id, f.groups
|
|
FROM files f TABLESAMPLE SYSTEM (?)
|
|
WHERE f.content_type IS NOT NULL AND f.release_id IS NOT NULL AND NOT is_readable_name(f.filename)`,
|
|
[percent]
|
|
)
|
|
let added = 0
|
|
for (let i = 0; i < rows.length; i += 2000) {
|
|
const chunk = rows.slice(i, i + 2000)
|
|
const result = await db.rawQuery(
|
|
`INSERT INTO signature_probes (release_id, tool, group_name)
|
|
SELECT * FROM unnest(?::bigint[], ?::text[], ?::text[]) ON CONFLICT DO NOTHING`,
|
|
[chunk.map((row: any) => row.release_id), chunk.map((row: any) => uploadTool(row.first_message_id)), chunk.map((row: any) => signatureGroup(row.groups))]
|
|
)
|
|
added += result.rowCount ?? 0
|
|
}
|
|
return added
|
|
}
|
|
}
|
|
|
|
export default new GarbageFilter()
|