Files
usenet-indexer/lib/sevenzip/index.ts
T
Daan MeijerandClaude Opus 5.5 be8a3d4580 Spotnet import, header backfill, staging and counter grouping, faster search
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>
2026-10-03 19:59:23 +02:00

98 lines
3.7 KiB
TypeScript

import { crc32 } from 'node:zlib'
/**
* 7-Zip archives, independent of AdonisJS. A 7z archive starts with a
* 32-byte signature header that points to the archive's header (the file
* list) at its very end, often LZMA-compressed ("encoded") with its packed
* data just before it. So the list needs the first 32 bytes and the tail:
* for a split archive (.7z.001, .002, …), the start of the first volume and
* the end of the last. Listing itself is left to libarchive (bsdtar).
*/
const SIGNATURE = Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c])
const SIGNATURE_HEADER_SIZE = 32
/** Header property ids (7zFormat.txt). */
const K_ENCODED_HEADER = 0x17
const K_PACK_INFO = 0x06
export interface SevenZipStart {
/** Where the archive header starts, from the start of the archive. */
headerStart: number
headerSize: number
headerCrc: number
/** The archive's total size (the header is its last part). */
archiveSize: number
}
/** Reads the signature header; null when `data` isn't a 7z start or its CRC fails. */
export function parseSevenZipStart(data: Buffer): SevenZipStart | null {
if (data.length < SIGNATURE_HEADER_SIZE || !data.subarray(0, 6).equals(SIGNATURE)) return null
if ((crc32(data.subarray(12, 32)) >>> 0) !== data.readUInt32LE(8)) return null
const offset = Number(data.readBigUInt64LE(12))
const size = Number(data.readBigUInt64LE(20))
const headerStart = SIGNATURE_HEADER_SIZE + offset
return { headerStart, headerSize: size, headerCrc: data.readUInt32LE(28), archiveSize: headerStart + size }
}
/** Whether `header` (the bytes at headerStart) is the archive header its signature header describes. */
export function checkHeader(start: SevenZipStart, header: Buffer): boolean {
return header.length === start.headerSize && (crc32(header) >>> 0) === start.headerCrc
}
/**
* Where the bytes needed for listing start: the header itself, or for an
* encoded header the packed (compressed) header stream it points to.
*/
export function listingStart(start: SevenZipStart, header: Buffer): number {
if (header[0] !== K_ENCODED_HEADER || header[1] !== K_PACK_INFO) return start.headerStart
const [packPos] = readNumber(header, 2)
return SIGNATURE_HEADER_SIZE + packPos
}
/**
* 7z's variable-length number: the leading 1-bits of the first byte count
* the little-endian bytes that follow; its remaining bits are the high part.
*/
export function readNumber(data: Buffer, pos: number): [value: number, next: number] {
const first = data[pos]
let mask = 0x80
let value = 0
for (let i = 0; i < 8; i++) {
if ((first & mask) === 0) {
value += (first & (mask - 1)) * 2 ** (8 * i)
return [value, pos + 1 + i]
}
value += data[pos + 1 + i] * 2 ** (8 * i)
mask >>= 1
}
return [value, pos + 9]
}
export interface SevenZipEntry {
name: string
size: number
directory: boolean
}
/**
* Entries from `bsdtar -tv` output, e.g.
* `-rw-rw-r-- 0 0 0 3000000 Sep 30 19:13 Some.Release/movie.mkv`.
*/
export function parseBsdtarListing(stdout: string): SevenZipEntry[] {
const entries: SevenZipEntry[] = []
for (const line of stdout.split('\n')) {
const match = /^([-dlbcps])\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{2}|\d{4})\s(.+)$/.exec(line)
if (!match) continue
const directory = match[1] === 'd'
entries.push({ name: directory ? match[3].replace(/\/$/, '') : match[3], size: Number(match[2]), directory })
}
return entries
}
/** Volume number of a split 7z name (`x.7z.001` → 1), 0 for an unsplit `.7z`, null otherwise. */
export function sevenZipVolume(name: string): number | null {
const split = /\.7z\.(\d{1,4})$/i.exec(name)
if (split) return Number(split[1])
return /\.7z$/i.test(name) ? 0 : null
}