probe:run (compose service prober, PROBE_CONNECTIONS, default 6) claims waiting candidates of settled releases (lone, then par2, then archive), fetches each article with BODY over connections of its own (no compression) and inspects it: the yEnc name and size, the content type from the first bytes, a par2 set's files, a RAR volume's entries and whether they're encrypted. Results per release and kind in release_inspections (CHUNK_TABLESPACE, the HDD); on the release passworded, inner_name (the largest content name) and inspected_at. 430 = missing, not yEnc = error, both final; connection trouble puts the candidate back. Fetch and parse in one process: an article takes ~0.1 s per connection, parsing a few ms (no queue in between). Parsers from voyager 1 (lib/yenc, lib/rar, lib/par2, lib/content sniff) with their tests; XoverClient.body; yencode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
/**
|
|
* Recognises a file type from its first bytes, independent of AdonisJS.
|
|
* `nzbe` is an encrypted-NZB container seen on Usenet (LOG.md 2026-09-28 (11)):
|
|
* "nzbe", 16 random bytes (salt/IV), then ciphertext.
|
|
* `yencbin` is the encrypted `.bin` container of `yEncBin@Poster.com`
|
|
* (LOG.md 2026-09-30 (3)): a fixed header, then data with 8 bits/byte of entropy.
|
|
*/
|
|
export type ContentType =
|
|
/** Not sniffed: the server no longer has the file's first article (inspection records it, no retries). */
|
|
| 'missing'
|
|
| 'nzbe'
|
|
| 'par2'
|
|
| 'rar4'
|
|
| 'rar5'
|
|
| '7z'
|
|
| 'zip'
|
|
| 'zstd'
|
|
| 'gzip'
|
|
| 'xz'
|
|
| 'matroska'
|
|
| 'mp4'
|
|
| 'rclone'
|
|
| 'yencbin'
|
|
| 'iso'
|
|
| 'udf'
|
|
| 'pdf'
|
|
| 'nzb'
|
|
| 'jpeg'
|
|
| 'png'
|
|
| 'mp3'
|
|
| 'flac'
|
|
| 'avi'
|
|
| 'wav'
|
|
| 'ogg'
|
|
| 'bzip2'
|
|
|
|
/** Magic bytes at an offset; null matches any byte. */
|
|
const SIGNATURES: [ContentType, number, (number | null)[]][] = [
|
|
// Two header variants seen (54f399d117c49269 6e4dc4ef5b60c11d…, 54f399e917c49269 6a4e9fb05b60c31d…):
|
|
// the bytes they share.
|
|
['yencbin', 0, [0x54, 0xf3, 0x99, null, 0x17, 0xc4, 0x92, 0x69, null, null, null, null, 0x5b, 0x60, null, 0x1d, 0x7f, 0x83, 0xe9, 0x2d]],
|
|
['nzbe', 0, [0x6e, 0x7a, 0x62, 0x65]],
|
|
['par2', 0, [0x50, 0x41, 0x52, 0x32, 0x00, 0x50, 0x4b, 0x54]],
|
|
['rar5', 0, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]],
|
|
['rar4', 0, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00]],
|
|
['7z', 0, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]],
|
|
['zip', 0, [0x50, 0x4b, 0x03, 0x04]],
|
|
['zstd', 0, [0x28, 0xb5, 0x2f, 0xfd]],
|
|
['gzip', 0, [0x1f, 0x8b]],
|
|
['xz', 0, [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]],
|
|
['matroska', 0, [0x1a, 0x45, 0xdf, 0xa3]],
|
|
['mp4', 4, [0x66, 0x74, 0x79, 0x70]], // "ftyp" box
|
|
['rclone', 0, [0x52, 0x43, 0x4c, 0x4f, 0x4e, 0x45]], // rclone crypt
|
|
['pdf', 0, [0x25, 0x50, 0x44, 0x46, 0x2d]],
|
|
['jpeg', 0, [0xff, 0xd8, 0xff]],
|
|
['png', 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
|
|
['mp3', 0, [0x49, 0x44, 0x33]], // "ID3" tag
|
|
['flac', 0, [0x66, 0x4c, 0x61, 0x43]], // "fLaC"
|
|
['ogg', 0, [0x4f, 0x67, 0x67, 0x53]], // "OggS"
|
|
['bzip2', 0, [0x42, 0x5a, 0x68]], // "BZh"
|
|
]
|
|
|
|
export function sniffContentType(data: Buffer): ContentType | null {
|
|
for (const [type, offset, bytes] of SIGNATURES) {
|
|
if (data.length >= offset + bytes.length && bytes.every((b, i) => b === null || data[offset + i] === b)) return type
|
|
}
|
|
// Disc images, visible when the first part is big enough (sector 16 at 32 KiB):
|
|
// ISO 9660 has "CD001"; UDF (DVD/Blu-ray) has BEA01 followed by NSR02/NSR03.
|
|
if (data.length >= 0x8006 && data.subarray(0x8001, 0x8006).toString('latin1') === 'CD001') return 'iso'
|
|
if (data.length >= 0x8806 && data.subarray(0x8001, 0x8006).toString('latin1') === 'BEA01' &&
|
|
/^NSR0[23]$/.test(data.subarray(0x8801, 0x8806).toString('latin1'))) return 'udf'
|
|
// RIFF container: AVI or WAV by form type.
|
|
if (data.length >= 12 && data.subarray(0, 4).toString('latin1') === 'RIFF') {
|
|
const form = data.subarray(8, 12).toString('latin1')
|
|
if (form === 'AVI ') return 'avi'
|
|
if (form === 'WAVE') return 'wav'
|
|
}
|
|
const head = data.subarray(0, 512).toString('latin1')
|
|
if (/^\s*(<\?xml[^>]*>\s*)?(<!DOCTYPE nzb|<nzb[\s>])/.test(head)) return 'nzb'
|
|
return null
|
|
}
|