import { crc32Hex, type DecodedSegment } from './decode.js' import { YencCrcError, YencError } from './errors.js' import { inferTotalParts } from './meta.js' /** * Assembles the decoded segments of one file into a single buffer. Segments * may be added in any order. */ export class YencFile { readonly name: string readonly size: number readonly totalParts: number private readonly buffer: Buffer private readonly received = new Set() private bytesWritten = 0 private fileCrc?: string constructor(name: string, size: number, totalParts = 1) { this.name = name this.size = size this.totalParts = totalParts this.buffer = Buffer.alloc(size) } /** Creates a file sized from the metadata of any of its segments. */ static fromSegment(segment: DecodedSegment): YencFile { const { begin } = segment.meta // Many posters leave total= out; it follows from the part size. return new YencFile(begin.name, begin.size, inferTotalParts(segment.meta)) } add(segment: DecodedSegment): void { const { meta, data, offset } = segment // Only the size is compared: obfuscating posters can give every segment // of one file a different random name. if (meta.begin.size !== this.size) { throw new YencError( `Segment of "${meta.begin.name}" (${meta.begin.size} bytes) does not belong to "${this.name}" (${this.size} bytes)` ) } if (offset + data.length > this.size) { throw new YencError(`Segment at offset ${offset} overruns file size ${this.size}`) } const partNumber = meta.begin.part ?? 1 if (this.received.has(partNumber)) return data.copy(this.buffer, offset) this.received.add(partNumber) this.bytesWritten += data.length if (meta.end.crc32) this.fileCrc = meta.end.crc32 } get partsReceived(): number { return this.received.size } get isComplete(): boolean { return this.received.size === this.totalParts && this.bytesWritten === this.size } /** Part numbers (1-based) that have not been added yet. */ missingParts(): number[] { const missing: number[] = [] for (let part = 1; part <= this.totalParts; part++) { if (!this.received.has(part)) missing.push(part) } return missing } /** * Returns the assembled file. Throws if parts are missing or, when the poster * included a whole-file crc32, if it does not match. */ getBuffer(): Buffer { if (!this.isComplete) { throw new YencError(`File "${this.name}" is incomplete, missing parts: ${this.missingParts().join(', ')}`) } if (this.fileCrc) { const actual = crc32Hex(this.buffer) if (actual !== this.fileCrc) throw new YencCrcError(this.fileCrc, actual) } return this.buffer } }