179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
import { combineCrc32 } from '../yenc/decode.js'
|
|
|
|
export interface SplitArticle {
|
|
number: number
|
|
messageId: string
|
|
bytes: number
|
|
/** Article number in the group; posting order. */
|
|
articleNumber: number | null
|
|
/** yEnc part CRC and decoded part length, for verification. */
|
|
pcrc32?: string | null
|
|
partSize?: number | null
|
|
/** Whole-file CRC32, usually only in the last part. */
|
|
fileCrc32?: string | null
|
|
}
|
|
|
|
export interface SplitOptions {
|
|
/** Total parts per volume; a volume that has them all takes no more articles. */
|
|
totalParts: number
|
|
/**
|
|
* Maximum distance in article numbers between consecutive articles of one
|
|
* volume. Separates different posts that happen to share a size.
|
|
*/
|
|
window?: number
|
|
/**
|
|
* How far (in posting positions) an article may be from where its volume
|
|
* predicts it; covers out-of-order finishing across connections.
|
|
*/
|
|
tolerance?: number
|
|
}
|
|
|
|
interface Volume {
|
|
articles: SplitArticle[]
|
|
/** Posting rank of each part number present. */
|
|
ranks: Map<number, number>
|
|
lastArticle: number
|
|
opened: number
|
|
}
|
|
|
|
/** ~1 minute of posting in a busy group. */
|
|
const DEFAULT_WINDOW = 20_000
|
|
|
|
/**
|
|
* Separates the articles of several equal-sized files that share one
|
|
* identity (obfuscated volumes with random names, possibly from different
|
|
* posts) into one list per file. See CONCEPT.md (D20).
|
|
*
|
|
* Posting tools upload a queue (volume 1 parts 1..n, volume 2 parts 1..n,
|
|
* ...) over several connections, so within a volume part k+1 lands about one
|
|
* position after part k in posting order, give or take the connection count.
|
|
* Each article, in posting order, goes to the eligible volume (lacks this
|
|
* part, not complete, active within `window` article numbers) whose
|
|
* predicted position for it is closest: taken from that volume's article with
|
|
* the nearest part number, which keeps the prediction local and unaffected
|
|
* by missing articles elsewhere. A deviation above the tolerance starts a new
|
|
* volume. The result is recomputed from all articles, so it improves as late
|
|
* articles arrive.
|
|
*/
|
|
export function splitInstances(articles: SplitArticle[], options: SplitOptions): SplitArticle[][] {
|
|
const window = options.window ?? DEFAULT_WINDOW
|
|
const tolerance = options.tolerance ?? Math.max(16, Math.ceil(options.totalParts * 0.1))
|
|
const seen = new Set<string>()
|
|
const ordered = articles
|
|
.filter((a) => (seen.has(a.messageId) ? false : (seen.add(a.messageId), true)))
|
|
.sort(
|
|
(a, b) =>
|
|
(a.articleNumber ?? Number.MAX_SAFE_INTEGER) - (b.articleNumber ?? Number.MAX_SAFE_INTEGER) ||
|
|
a.messageId.localeCompare(b.messageId)
|
|
)
|
|
|
|
const volumes: Volume[] = []
|
|
ordered.forEach((article, rank) => {
|
|
const position = article.articleNumber ?? Number.MAX_SAFE_INTEGER
|
|
let best: Volume | undefined
|
|
let bestScore: [number, number] | undefined
|
|
for (const volume of volumes) {
|
|
if (volume.ranks.has(article.number) || volume.ranks.size >= options.totalParts) continue
|
|
if (position - volume.lastArticle > window) continue
|
|
const deviation = Math.abs(rank - predictedRank(volume, article.number))
|
|
if (deviation > tolerance) continue
|
|
const score: [number, number] = [deviation, volume.opened]
|
|
if (!bestScore || score[0] < bestScore[0] || (score[0] === bestScore[0] && score[1] < bestScore[1])) {
|
|
best = volume
|
|
bestScore = score
|
|
}
|
|
}
|
|
if (!best) {
|
|
best = { articles: [], ranks: new Map(), lastArticle: position, opened: volumes.length }
|
|
volumes.push(best)
|
|
}
|
|
best.articles.push(article)
|
|
best.ranks.set(article.number, rank)
|
|
best.lastArticle = Math.max(best.lastArticle, position)
|
|
})
|
|
|
|
return volumes.map((v) => v.articles.sort((a, b) => a.number - b.number))
|
|
}
|
|
|
|
/** Where part `number` should be in posting order, judged from the volume's nearest part. */
|
|
function predictedRank(volume: Volume, number: number): number {
|
|
let anchorPart = 0
|
|
let anchorRank = 0
|
|
let nearest = Number.MAX_SAFE_INTEGER
|
|
for (const [part, rank] of volume.ranks) {
|
|
const distance = Math.abs(part - number)
|
|
if (distance < nearest) {
|
|
nearest = distance
|
|
anchorPart = part
|
|
anchorRank = rank
|
|
}
|
|
}
|
|
return anchorRank + (number - anchorPart)
|
|
}
|
|
|
|
/**
|
|
* Simpler split: for every part number, the i-th article in posting order
|
|
* belongs to volume i. Right for a single, complete post; used as a second
|
|
* proposal for verification.
|
|
*/
|
|
export function splitByRank(articles: SplitArticle[]): SplitArticle[][] {
|
|
const byNumber = new Map<number, SplitArticle[]>()
|
|
const seen = new Set<string>()
|
|
for (const article of articles) {
|
|
if (seen.has(article.messageId)) continue
|
|
seen.add(article.messageId)
|
|
byNumber.set(article.number, [...(byNumber.get(article.number) ?? []), article])
|
|
}
|
|
const volumes: SplitArticle[][] = []
|
|
for (const group of byNumber.values()) {
|
|
group.sort(
|
|
(a, b) =>
|
|
(a.articleNumber ?? Number.MAX_SAFE_INTEGER) - (b.articleNumber ?? Number.MAX_SAFE_INTEGER) ||
|
|
a.messageId.localeCompare(b.messageId)
|
|
)
|
|
group.forEach((article, i) => (volumes[i] ??= []).push(article))
|
|
}
|
|
return volumes.map((v) => v.sort((a, b) => a.number - b.number))
|
|
}
|
|
|
|
/**
|
|
* True when a volume has every part and its combined part CRCs equal the
|
|
* whole-file CRC32 its last part announces: then it is exactly one file.
|
|
*/
|
|
export function verifyVolume(volume: SplitArticle[], totalParts: number): boolean {
|
|
if (volume.length !== totalParts) return false
|
|
const sorted = [...volume].sort((a, b) => a.number - b.number)
|
|
if (sorted.some((a, i) => a.number !== i + 1 || !a.pcrc32 || !a.partSize)) return false
|
|
const expected = sorted[sorted.length - 1].fileCrc32
|
|
if (!expected) return false
|
|
const combined = combineCrc32(sorted.map((a) => ({ crc: a.pcrc32!, length: a.partSize! })))
|
|
return combined === expected.toLowerCase().padStart(8, '0')
|
|
}
|
|
|
|
/**
|
|
* Splits and keeps only volumes that verify (verifyVolume). Proposals from
|
|
* both methods are tried; articles of verified volumes are taken out and the
|
|
* rest is tried again, until nothing new verifies. Unverified articles are
|
|
* left out: guessing wrong would produce a corrupt file.
|
|
*/
|
|
export function splitVerified(articles: SplitArticle[], options: SplitOptions): SplitArticle[][] {
|
|
const verified: SplitArticle[][] = []
|
|
let remaining = articles
|
|
for (let round = 0; round < 4 && remaining.length >= options.totalParts; round++) {
|
|
const found: SplitArticle[][] = []
|
|
const used = new Set<string>()
|
|
for (const propose of [(a: SplitArticle[]) => splitInstances(a, options), splitByRank]) {
|
|
for (const volume of propose(remaining.filter((a) => !used.has(a.messageId)))) {
|
|
if (verifyVolume(volume, options.totalParts)) {
|
|
found.push(volume)
|
|
for (const article of volume) used.add(article.messageId)
|
|
}
|
|
}
|
|
}
|
|
if (found.length === 0) break
|
|
verified.push(...found)
|
|
remaining = remaining.filter((a) => !used.has(a.messageId))
|
|
}
|
|
return verified
|
|
}
|