import { createHash } from 'node:crypto' import db from '@adonisjs/lucid/services/db' import NntpService from '#services/NntpService' import Par2Service, { type Par2Result } from '#services/Par2Service' import ArchiveService, { extractEntry, looksRandom, type ArchiveResult } from '#services/ArchiveService' import NameService from '#services/NameService' import { decodeSegment, YencCrcError, YencError, YencFile } from '#lib/yenc/index' import NzbImportService, { type NzbImportResult } from '#services/NzbImportService' import { isArchiveStart, NICOVIDEO_FILE, NICOVIDEO_RELEASE, nicovideoInfo, nicovideoReleaseName, sniffContentType, zstdPrefix, type ContentType, type NicovideoInfo, } from '#lib/content/index' import { parseRarHeaders, type RarEntry, type RarInfo } from '#lib/rar/index' import { fileStem, releaseIdentity } from '#lib/release/index' import IngestService from '#services/IngestService' import { looksLikeFilename } from '#lib/subject/index' import { sevenZipVolume } from '#lib/sevenzip/index' import SevenZipService from '#services/SevenZipService' import SfvService from '#services/SfvService' import { segmentsOf } from '#services/FileStore' const FIRST_CHUNK = 16 * 1024 /** * MD5 of the first 16 KiB of a file, as par2 records it, from the body of * the file's first part. Undefined when that part is shorter than 16 KiB but * the file is not (the chunk would span two parts). */ export function firstChunkMd5(body: Buffer, fileSize: number): string | undefined { return chunkMd5(decodeSegment(body).data, fileSize) } function chunkMd5(data: Buffer, fileSize: number): string | undefined { if (data.length < Math.min(FIRST_CHUNK, fileSize)) return undefined return createHash('md5').update(data.subarray(0, FIRST_CHUNK)).digest('hex') } export interface ContentResult { par2?: Par2Result archive?: ArchiveResult named: number contentType: ContentType | null nzb?: NzbImportResult /** NZBs read from inside an archive. */ archiveNzbs?: NzbImportResult[] /** Files an SFV's listing moved into its release. */ sfvMoved?: number /** A niconico capture's release name from its API response (D34). */ nicovideo?: string /** The real name from the yEnc header, for a file posted under a random subject (D36). */ yencName?: string } /** * Takes names and groupings from file contents (CONCEPT.md D21). For a * completed file with a random name, one article (the first) is enough: * - its fingerprint (size + first-16-KiB MD5) matches it to a par2 entry, * - par2 files are recognised by content and name every file of their set, * - RAR headers give the release grouping, name and contents. */ class ContentService { /** Random or missing names, and named first RAR volumes (for contents and password flag). */ isCandidate(name: string) { if (!looksLikeFilename(name) || looksRandom(fileStem(name))) return true if (isArchiveStart(name)) return true if (/\.nzb$/i.test(name)) return true // SFVs name the files of their release; niconico captures carry their title (D34). if (/\.sfv$/i.test(name) || NICOVIDEO_FILE.test(name)) return true // Every 7z volume: the set is listed when its last volume is in (see inspect). if (sevenZipVolume(name) !== null) return true return /\.part0*1\.rar$/i.test(name) || (/\.rar$/i.test(name) && !/\.part\d+\.rar$/i.test(name)) } /** * Uploads that randomise the subject per article often keep the real file * name in the yEnc header inside the body (`Deep.Strike.S01E16.1080p….part01.rar`; * LOG.md 2026-10-02 (2)). A file with a random name takes that name, and * leaves its weak one-file release for the release its name gives, where * the other files of the upload gather. Returns the name, or undefined. */ private async nameFromYenc(file: any, binary: any, yencName: string): Promise { const current = String(file.real_name ?? file.filename) if (file.real_name || !looksRandom(fileStem(current))) return undefined if (!looksLikeFilename(yencName) || looksRandom(fileStem(yencName)) || yencName === current) return undefined const release = file.release_id ? await db.from('releases').where('id', file.release_id).first() : null const weak = !release || (['stem', 'weak-stem'].includes(release.source) && Number(release.file_count) <= 1) let releaseId = file.release_id if (weak) { const identity = releaseIdentity({ poster: file.poster, subject: '', name: yencName, weak: true }) if (identity) { const ids = await IngestService.upsertReleases(db.connection().getWriteClient(), [ { release: identity, poster: file.poster, groups: file.groups ?? [], postedAt: file.date ? new Date(Number(file.date)) : undefined, weakIdentity: true }, ]) releaseId = ids.get(identity.key) ?? releaseId } } await db.from('files').where('id', file.id).update({ real_name: yencName, release_id: releaseId }) if (binary) await db.from('binaries').where('id', binary.id).update({ real_name: yencName, release_id: releaseId }) return yencName } /** * Names a niconico capture's release after the video, while it still has * the archiver's `