implemented a lot of features

This commit is contained in:
Daan Meijer committed 2026-09-29 00:32:01 +02:00
1 parent 62f1767df3
commit bcb9be5da9
141 files changed
+10705 -385

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import DeferredService, { deferredName, isDeferred } from '#services/DeferredService'
const header = (id: string, name: string, extra: Record<string, string> = {}) => ({
'message-id': `<${id.padEnd(32, '0')}@ngPost>`,
'subject': `"${name}" yEnc`,
'from': 'pKeUimunULJZz@ngPost.com',
':bytes': '740000',
'date': 'Mon, 28 Sep 2026 10:00:00 +0000',
...extra,
})
test.group('DeferredService', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('recognises deferred uploads by message-id and a quoted name', ({ assert }) => {
assert.equal(deferredName('[026/109] "HFIX7fbQAuH4PZsvGMw7Pz.part025.rar" yEnc'), 'HFIX7fbQAuH4PZsvGMw7Pz.part025.rar')
assert.isNull(deferredName('no quotes here'))
assert.isTrue(isDeferred(header('ab', 'x.part1.rar')))
assert.isFalse(isDeferred({ ...header('ab', 'x'), 'message-id': '<part1of5.abc@nyuu>' }))
})
test('articles without a name in the subject are kept as article ranges', async ({ assert }) => {
const hex = (id: string, n: number) => ({ articleNumber: n, header: { ...header(id, 'x'), subject: id.padEnd(32, 'f') } })
const files = await DeferredService.record('alt.binaries.test', [hex('a', 100), hex('b', 180), hex('c', 90_000), hex('d', 150)])
assert.equal(files, 0)
const ranges = await db.from('deferred_ranges').orderBy('first_article')
assert.deepEqual(
ranges.map((r) => [Number(r.first_article), Number(r.last_article), r.article_count]),
[[100, 180, 3], [90_000, 90_000, 1]]
)
assert.lengthOf(await db.from('deferred_files'), 0)
})
test('aggregates articles per file, across batches, counting only the first group', async ({ assert }) => {
await DeferredService.record('alt.binaries.test', [
{ articleNumber: 105, header: header('b', 'a.part1.rar') },
{ articleNumber: 101, header: header('a', 'a.part1.rar') },
{ articleNumber: 102, header: header('c', 'b.par2', { ':bytes': '1000' }) },
])
await DeferredService.record('alt.binaries.test', [{ articleNumber: 110, header: header('d', 'a.part1.rar') }])
// A crosspost seen in another group doesn't count twice.
await DeferredService.record('alt.binaries.misc', [{ articleNumber: 9, header: header('a', 'a.part1.rar') }])
const rows = await db.from('deferred_files').orderBy('name')
assert.lengthOf(rows, 2)
const [a, b] = rows
assert.include(a, { name: 'a.part1.rar', group_name: 'alt.binaries.test', article_count: 3, status: 'pending' })
assert.equal(Number(a.first_article), 101)
assert.equal(Number(a.last_article), 110)
assert.equal(a.first_message_id, 'a'.padEnd(32, '0') + '@ngPost')
assert.equal(Number(a.bytes), 3 * 740000)
assert.include(b, { name: 'b.par2', article_count: 1 })
})
test('only claims files that stopped growing', async ({ assert }) => {
await DeferredService.record('alt.binaries.test', [{ articleNumber: 1, header: header('a', 'new.rar') }])
assert.lengthOf(await DeferredService.claim(10), 0)
await db.from('deferred_files').update({ last_seen_at: new Date(Date.now() - 24 * 60 * 60 * 1000) })
const claimed = await DeferredService.claim(10)
assert.lengthOf(claimed, 1)
assert.equal(claimed[0].status, 'processing')
assert.lengthOf(await DeferredService.claim(10), 0)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import FetchRangeService from '#services/FetchRangeService'
async function groupWithRanges(ranges: [number, number, string][]) {
const now = new Date()
const [group] = await db
.table('groups')
.insert({ name: `test.ranges.${Date.now()}`, active: false, last_indexed_id: 100, last_queued_id: 700, created_at: now, updated_at: now })
.returning(['id', 'name'])
for (const [start, end, status] of ranges) {
await db.table('fetch_ranges').insert({ group_id: group.id, start_id: start, end_id: end, status, created_at: now, updated_at: now })
}
return { id: Number(group.id), name: group.name }
}
test.group('FetchRangeService.advanceWatermark', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('stops just below the first range that is not done', async ({ assert }) => {
const g = await groupWithRanges([
[101, 200, 'done'],
[201, 300, 'failed'],
[301, 400, 'done'],
[401, 700, 'queued'],
])
assert.equal(await FetchRangeService.advanceWatermark(g), 200n)
// done ranges are forgotten, the gap and the queued range stay
const left = await db.from('fetch_ranges').where('group_id', g.id).orderBy('start_id').select('status')
assert.deepEqual(left.map((r) => r.status), ['failed', 'queued'])
})
test('reaches the queue cursor when everything is done', async ({ assert }) => {
const g = await groupWithRanges([
[101, 400, 'done'],
[401, 700, 'done'],
])
assert.equal(await FetchRangeService.advanceWatermark(g), 700n)
})
test('never moves backwards', async ({ assert }) => {
const g = await groupWithRanges([[50, 60, 'failed']])
assert.equal(await FetchRangeService.advanceWatermark(g), 100n)
})
})
+148
View File
@@ -0,0 +1,148 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import IngestService, { type IngestSegment } from '#services/IngestService'
import QueueService from '#services/QueueService'
import { fileKey } from '#lib/subject/index'
function segment(overrides: Partial<IngestSegment>): IngestSegment {
const poster = 'ingest-test@example.invalid'
const subject = '"test-file.bin" yEnc'
return {
fileKey: fileKey(poster, subject, 3),
poster,
subject,
name: 'test-file.bin',
totalParts: 3,
number: 1,
messageId: 'part1@test',
bytes: 100,
groups: ['alt.binaries.test'],
...overrides,
}
}
async function binaryFor(key: string) {
return db.from('binaries').where('file_key', key).first()
}
// None of these cases complete a binary, so nothing is queued in Redis.
test.group('IngestService', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
// IngestService opens the BullMQ queues; close them so the test process can exit.
group.teardown(() => QueueService.closeAll())
test('existing binaries are only rewritten when something changes', async ({ assert }) => {
const first = segment({ number: 1, postedAt: new Date('2026-09-28T12:00:00Z') })
await IngestService.ingest([first])
const before = await binaryFor(first.fileKey)
// A later part within TOUCH_MS: nothing to change, so the row is left alone.
await IngestService.ingest([segment({ number: 2, messageId: 'part2@test', postedAt: new Date('2026-09-28T12:05:00Z') })])
const same = await binaryFor(first.fileKey)
assert.equal(+same.last_seen_at, +before.last_seen_at)
assert.equal(await db.from('parts').where('binary_id', before.id).count('* as n').then((r) => Number(r[0].n)), 2)
// An earlier post date and a stale last_seen_at are merged.
await db.from('binaries').where('id', before.id).update({ last_seen_at: new Date(Date.now() - 60 * 60 * 1000) })
await IngestService.ingest([segment({ number: 1, postedAt: new Date('2026-09-28T11:00:00Z') })])
const after = await binaryFor(first.fileKey)
assert.equal(after.posted_at.toISOString(), '2026-09-28T11:00:00.000Z')
assert.isAbove(+after.last_seen_at, Date.now() - 60_000)
})
test('ingesting the same segments twice is a no-op', async ({ assert }) => {
const segments = [segment({ number: 1 }), segment({ number: 2, messageId: 'part2@test' })]
await IngestService.ingest(segments)
await IngestService.ingest(segments)
const binary = await binaryFor(segments[0].fileKey)
const [{ count }] = await db.from('parts').where('binary_id', binary.id).count('* as count')
assert.equal(Number(count), 2)
assert.isNull(binary.completed_at)
assert.isNull(binary.conflicted_at)
})
test('a weak identity with two articles for one part number is flagged and never completes', async ({
assert,
}) => {
const weak = { weakIdentity: true }
await IngestService.ingest([segment({ ...weak, number: 1, messageId: 'volume-a-1@test' })])
// Same name, poster and size, but another file: the second volume of a set.
await IngestService.ingest([
segment({ ...weak, number: 1, messageId: 'volume-b-1@test' }),
segment({ ...weak, number: 2, messageId: 'volume-b-2@test' }),
segment({ ...weak, number: 3, messageId: 'volume-b-3@test' }),
])
const binary = await binaryFor(segment({}).fileKey)
assert.isNotNull(binary.conflicted_at)
assert.isNull(binary.completed_at, 'all part numbers are present, but it must not complete')
})
test('a conflict inside a single batch is detected too', async ({ assert }) => {
await IngestService.ingest([
segment({ weakIdentity: true, number: 2, messageId: 'a@test' }),
segment({ weakIdentity: true, number: 2, messageId: 'b@test' }),
])
assert.isNotNull((await binaryFor(segment({}).fileKey)).conflicted_at)
})
test('on the subject path a second article for a part is a repost, not a conflict', async ({
assert,
}) => {
await IngestService.ingest([segment({ number: 1, messageId: 'original@test' })])
await IngestService.ingest([segment({ number: 1, messageId: 'repost@test' })])
const binary = await binaryFor(segment({}).fileKey)
assert.isNull(binary.conflicted_at)
const part = await db.from('parts').where({ binary_id: binary.id, number: 1 }).first()
assert.equal(part.message_id, 'original@test')
})
test('an obfuscated file joins a known par2 set of exactly its size', async ({ assert }) => {
const now = new Date()
const [release] = await db
.table('releases')
.insert({ release_key: 'k'.repeat(40), name: 'Some.Show.S01E01', source: 'par2', groups: '[]', posted_at: now, first_seen_at: now, last_seen_at: now })
.returning('id')
await db.table('release_files').insert({ release_id: release.id, par2_file_id: 'f'.repeat(32), name: 'Some.Show.S01E01.mkv', size: 5467240532, md5_16k: '0'.repeat(32) })
const weak = { weakIdentity: true, postedAt: now, release: null }
await IngestService.ingest([
segment({ ...weak, fileKey: 'a'.repeat(40), size: 5467240532, totalParts: 7628, messageId: 'match@test' }),
segment({ ...weak, fileKey: 'b'.repeat(40), size: 5467240533, totalParts: 7628, messageId: 'other@test' }),
])
const matched = await binaryFor('a'.repeat(40))
const other = await binaryFor('b'.repeat(40))
assert.equal(Number(matched.release_id), Number(release.id))
assert.isNull(other.release_id)
})
test('an identical repost (same part CRC) is not a conflict', async ({ assert }) => {
const weak = { weakIdentity: true, pcrc32: 'abcd1234', partSize: 700 }
await IngestService.ingest([segment({ ...weak, number: 1, messageId: 'first@test' })])
await IngestService.ingest([segment({ ...weak, number: 1, messageId: 'repost@test' })])
const binary = await binaryFor(segment({}).fileKey)
assert.isNull(binary.conflicted_at)
await IngestService.ingest([segment({ ...weak, pcrc32: 'ffff0000', number: 1, messageId: 'other@test' })])
assert.isNotNull((await binaryFor(segment({}).fileKey)).conflicted_at)
})
test('a release takes its file total from whichever file carries the counter', async ({ assert }) => {
const { releaseIdentity } = await import('#lib/release/index')
const poster = 'archiver@example.invalid'
const file = (subject: string, name: string, key: string) =>
segment({ fileKey: key.padEnd(40, 'k'), poster, subject, name, totalParts: 1, messageId: `${key}@test`,
release: releaseIdentity({ poster, subject, name, weak: false }) })
// The counter-less par2 index comes first in the batch.
await IngestService.ingest([
file('"x.tar.zst.par2" yEnc 3100', 'x.tar.zst.par2', 'index'),
file('[1/2] - "x.tar.zst" yEnc 1000', 'x.tar.zst', 'archive'),
])
const release = await db.from('releases').where('name', 'x').where('poster', poster).first()
assert.equal(release.file_total, 2)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import NameService from '#services/NameService'
async function release(name: string, files: { name: string; size: number; md5_16k: string }[]) {
const now = new Date()
const [row] = await db
.table('releases')
.insert({ release_key: `${name}`.padEnd(40, 'x').slice(0, 40), name, source: 'par2', groups: '[]', posted_at: now, first_seen_at: now, last_seen_at: now })
.returning('id')
for (const [i, f] of files.entries()) {
await db.table('release_files').insert({ release_id: row.id, par2_file_id: String(i).padStart(32, '0'), name: f.name, size: f.size, md5_16k: f.md5_16k })
}
return Number(row.id)
}
async function binary(key: string, fields: Record<string, unknown>) {
const now = new Date()
const [row] = await db
.table('binaries')
.insert({ file_key: key.padEnd(40, '0'), poster: 'x', subject: '"x" yEnc', name: 'Xq8r2KdLm3Zw91PqA7', total_parts: 1, groups: '[]', first_seen_at: now, last_seen_at: now, ...fields })
.returning('id')
return Number(row.id)
}
test.group('NameService', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('an exact match leaves a random-named single-file release for the par2 set', async ({ assert }) => {
const season = await release('SEAL.Team.S03', [
{ name: 'SEAL.Team.S03E18.1080p.WEB-DL-nitrato.mkv', size: 939_684_767, md5_16k: 'd'.repeat(32) },
])
const now = new Date()
const [weak] = await db
.table('releases')
.insert({ release_key: 'w'.repeat(40), name: 'mOzyyxCyYUjAbCdEf12', source: 'stem', groups: '[]', first_seen_at: now, last_seen_at: now })
.returning('id')
const [file] = await db
.table('files')
.insert({ filename: 'mOzyyxCyYUjAbCdEf12', poster: 'x', date: now.getTime(), parts: 1, size: 1, groups: '[]', message_ids: '{}', release_id: weak.id, created_at: now, updated_at: now })
.returning('id')
const episode = await binary('ep18', { size: 939_684_767, md5_16k: 'd'.repeat(32), release_id: weak.id, file_id: file.id })
await NameService.resolve([episode])
const moved = await db.from('binaries').where('id', episode).first()
assert.equal(Number(moved.release_id), season)
assert.equal(moved.real_name, 'SEAL.Team.S03E18.1080p.WEB-DL-nitrato.mkv')
assert.equal(Number((await db.from('files').where('id', file.id).first()).release_id), season)
})
test('a file in a real grouping keeps it', async ({ assert }) => {
await release('Other.Set', [{ name: 'Other.Show.S01E01.mkv', size: 5_000_000, md5_16k: 'e'.repeat(32) }])
const now = new Date()
const [nzb] = await db
.table('releases')
.insert({ release_key: 'n'.repeat(40), name: 'Qz8r2KdLm3Zw91PqA7x', source: 'nzb', groups: '[]', first_seen_at: now, last_seen_at: now })
.returning('id')
const id = await binary('nzbfile', { size: 5_000_000, md5_16k: 'e'.repeat(32), release_id: nzb.id })
await NameService.resolve([id])
assert.equal(Number((await db.from('binaries').where('id', id).first()).release_id), Number(nzb.id))
})
test('names equal-sized volumes exactly by their first-16k fingerprint', async ({ assert }) => {
const releaseId = await release('Some.Movie', [
{ name: 'Some.Movie.part1.rar', size: 50_000_000, md5_16k: 'a'.repeat(32) },
{ name: 'Some.Movie.part2.rar', size: 50_000_000, md5_16k: 'b'.repeat(32) },
])
const one = await binary('one', { size: 50_000_000, md5_16k: 'b'.repeat(32) })
const two = await binary('two', { size: 50_000_000, md5_16k: 'a'.repeat(32) })
const stranger = await binary('three', { size: 50_000_000, md5_16k: 'c'.repeat(32) })
await NameService.resolve([one, two, stranger])
const rows = await db.from('binaries').whereIn('id', [one, two, stranger]).orderBy('id')
assert.deepEqual(rows.map((r) => [r.real_name, r.release_id && Number(r.release_id)]), [
['Some.Movie.part2.rar', releaseId],
['Some.Movie.part1.rar', releaseId],
[null, null],
])
})
test('without a fingerprint, names by size only when the size is unique in the release', async ({ assert }) => {
const releaseId = await release('Other', [
{ name: 'Other.mkv', size: 700_000_000, md5_16k: 'd'.repeat(32) },
{ name: 'Other.part1.rar', size: 10_000_000, md5_16k: 'e'.repeat(32) },
{ name: 'Other.part2.rar', size: 10_000_000, md5_16k: 'f'.repeat(32) },
])
const video = await binary('video', { size: 700_000_000, release_id: releaseId })
const volume = await binary('volume', { size: 10_000_000, release_id: releaseId })
await NameService.resolve([video, volume])
assert.equal((await db.from('binaries').where('id', video).first()).real_name, 'Other.mkv')
assert.isNull((await db.from('binaries').where('id', volume).first()).real_name)
})
})
+90
View File
@@ -0,0 +1,90 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import testUtils from '@adonisjs/core/services/test_utils'
let counter = 0
async function seed(name: string, opts: { fileTotal?: number; files?: number } = {}) {
const now = new Date()
const [release] = await db
.table('releases')
.insert({ release_key: `nz${++counter}`.padEnd(40, 'n'), name, source: 'stem', poster: 'poster@example.invalid', groups: '["alt.binaries.test"]', file_total: opts.fileTotal ?? null, posted_at: now, first_seen_at: now, last_seen_at: now })
.returning('id')
for (let i = 0; i < (opts.files ?? 1); i++) {
await db.table('files').insert({
filename: `${name}.part${i + 1}.rar`, poster: 'poster@example.invalid', date: now.getTime(), parts: 1, size: 1_000_000,
release_id: release.id, groups: '["alt.binaries.test"]', message_ids: JSON.stringify({ '1': { id: `${name}-${i}@test`, size: 1_000_000 } }),
created_at: now, updated_at: now,
})
}
return Number(release.id)
}
const ids = (xml: string) => [...xml.matchAll(/<guid isPermaLink="false">(\d+)<\/guid>/g)].map((m) => Number(m[1]))
test.group('Newznab API', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('caps lists search functions and categories', async ({ client, assert }) => {
const response = await client.get('/api').qs({ t: 'caps' })
response.assertStatus(200)
assert.include(response.text(), '<tv-search available="yes" supportedParams="q,season,ep"/>')
assert.include(response.text(), '<category id="5000" name="TV">')
assert.include(response.text(), '<subcat id="5040" name="HD"/>')
})
test('tvsearch matches show, season and episode, with Newznab attributes', async ({ client, assert }) => {
const e02 = await seed('Nzbtest.Show.S01E02.1080p.WEB-DL-GRP')
const e03 = await seed('Nzbtest.Show.S01E03.1080p.WEB-DL-GRP')
const movie = await seed('Nzbtest.Show.The.Movie.2020.1080p.BluRay.x264-GRP')
const response = await client.get('/api').qs({ t: 'tvsearch', q: 'nzbtest show', season: '1', ep: '2' })
response.assertStatus(200)
const body = response.text()
assert.deepEqual(ids(body), [e02])
assert.include(body, '<newznab:attr name="category" value="5000"/>')
assert.include(body, '<newznab:attr name="category" value="5040"/>')
assert.include(body, '<newznab:attr name="season" value="S01"/>')
assert.include(body, `/api?t=get&amp;id=${e02}`)
assert.include(body, 'type="application/x-nzb"')
const season = await client.get('/api').qs({ t: 'tvsearch', q: 'nzbtest show', season: '1' })
assert.sameMembers(ids(season.text()), [e02, e03])
const movies = await client.get('/api').qs({ t: 'movie', q: 'nzbtest show' })
assert.deepEqual(ids(movies.text()), [movie])
})
test('leaves out obfuscated names and releases known to be incomplete', async ({ client, assert }) => {
const readable = await seed('Nzbtest.Other.2021.720p.WEB-DL-GRP')
const incomplete = await seed('Nzbtest.Partial.2021.720p.WEB-DL-GRP', { fileTotal: 5, files: 2 })
const random = await seed('Xq8r2KdLm3Zw91PqA7nzbtest')
const found = ids((await client.get('/api').qs({ t: 'search', q: 'nzbtest' })).text())
assert.include(found, readable)
assert.notInclude(found, incomplete)
assert.notInclude(found, random)
})
test('get returns the NZB, errors use Newznab codes', async ({ client, assert }) => {
const id = await seed('Nzbtest.Get.S02E01.720p.HDTV-GRP')
const nzb = await client.get('/api').qs({ t: 'get', id: String(id) })
nzb.assertStatus(200)
assert.include(nzb.header('content-type'), 'application/x-nzb')
assert.include(nzb.text(), 'Nzbtest.Get.S02E01.720p.HDTV-GRP-0@test')
assert.include((await client.get('/api').qs({ t: 'get', id: '999999999' })).text(), 'code="300"')
assert.include((await client.get('/api').qs({ t: 'nonsense' })).text(), 'code="202"')
})
test('requires the API key when one is configured', async ({ client, assert }) => {
const previous = env.get('NEWZNAB_API_KEY')
env.set('NEWZNAB_API_KEY', 'test-key' as any)
try {
assert.include((await client.get('/api').qs({ t: 'caps' })).text(), 'code="100"')
assert.include((await client.get('/api').qs({ t: 'caps', apikey: 'test-key' })).text(), '<caps>')
const id = await seed('Nzbtest.Key.S01E01.720p.HDTV-GRP')
const search = await client.get('/api').qs({ t: 'search', q: 'nzbtest key', apikey: 'test-key' })
assert.include(search.text(), `id=${id}&amp;apikey=test-key`)
} finally {
env.set('NEWZNAB_API_KEY', previous as any)
}
})
})
+97
View File
@@ -0,0 +1,97 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import NzbImportService, { nzbFileName } from '#services/NzbImportService'
import SearchService from '#services/SearchService'
const XML = `<?xml version="1.0" encoding="utf-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<head><meta type="name">Real.Name.2026.1080p.WEB-DL-GRP</meta><meta type="password">hunter2</meta></head>
<file poster="p@x" date="1790598923" subject="Wond40 &#x22;Nachbe&#x22; Xq8r2KdLm3Zw91PqA7.part1.rar&#x22; yEnc (1/2)">
<groups><group>alt.binaries.bloaf</group></groups>
<segments><segment bytes="700" number="1">known-1@x</segment><segment bytes="700" number="2">known-2@x</segment></segments>
</file>
<file poster="p@x" date="1790598923" subject="&#x22;Xq8r2KdLm3Zw91PqA7.part2.rar&#x22; yEnc (1/1)">
<groups><group>alt.binaries.cores</group></groups>
<segments><segment bytes="500" number="1">new-1@x</segment></segments>
</file>
</nzb>`
async function file(fields: Record<string, unknown>) {
const now = new Date()
const [row] = await db
.table('files')
.insert({ poster: 'p@x', date: now.getTime(), parts: 1, size: 100, groups: '[]', created_at: now, updated_at: now, ...fields })
.returning('*')
return row
}
test.group('NzbImportService', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('names entries despite odd quoting', ({ assert }) => {
assert.equal(nzbFileName('Wond40 "Nachbe" Nachchbee2025bebeSD.part1.rar" yEnc (0001/1849)'), 'Nachchbee2025bebeSD.part1.rar')
assert.equal(nzbFileName('"x.par2" yEnc (1/1)'), 'x.par2')
})
test('joins the release of known files, adds the rest, keeps name and password', async ({ assert }) => {
const now = new Date()
const [release] = await db
.table('releases')
.insert({ release_key: 'z'.repeat(40), name: 'Xq8r2KdLm3Zw91PqA7', source: 'stem', groups: '[]', first_seen_at: now, last_seen_at: now })
.returning('id')
await file({ filename: 'Xq8r2KdLm3Zw91PqA7.part1.rar', release_id: release.id, message_ids: JSON.stringify({ '1': { id: 'known-1@x', size: 700 } }) })
const posted = await file({ filename: 'Xq8r2KdLm3Zw91PqA7.nzb', message_ids: JSON.stringify({ '1': { id: 'nzb-1@x', size: 2000 } }) })
const result = (await NzbImportService.import(posted, XML))!
assert.equal(result.releaseId, Number(release.id))
assert.include(result, { listed: 2, known: 1, added: 1, password: true })
const updated = await db.from('releases').where('id', release.id).first()
assert.equal(updated.name, 'Real.Name.2026.1080p.WEB-DL-GRP')
assert.equal(updated.password, 'hunter2')
assert.equal(updated.file_total, 2)
const files = await db.from('files').where('release_id', release.id).orderBy('filename').select('filename', 'groups')
assert.deepEqual(files.map((f) => f.filename), ['Xq8r2KdLm3Zw91PqA7.nzb', 'Xq8r2KdLm3Zw91PqA7.part1.rar', 'Xq8r2KdLm3Zw91PqA7.part2.rar'])
// Our own NZB passes the password on.
const nzb = await SearchService.nzb(Number(release.id))
assert.include(nzb!.xml, '<meta type="password">hunter2</meta>')
// Importing again changes nothing.
const again = (await NzbImportService.import(posted, XML))!
assert.equal(again.added, 0)
})
test('NZBs from one archive become separate releases named after their entries', async ({ assert }) => {
const nzbXml = (id: string) => `<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x" date="1790598923" subject="&#x22;${id}.flac&#x22; yEnc (1/1)">
<groups><group>alt.binaries.x</group></groups>
<segments><segment bytes="500" number="1">${id}@x</segment></segments>
</file></nzb>`
const archive = await file({ filename: 'JOCpLxSgyzOPhBWXL5riLy.rar', message_ids: JSON.stringify({ '1': { id: 'rar-1@x', size: 2000 } }) })
const a = (await NzbImportService.import(archive, nzbXml('a'), { entryName: 'The Kinks - Misfits-LP-1978.nzb', moveContainer: false }))!
const b = (await NzbImportService.import(archive, nzbXml('b'), { entryName: 'The Kinks - Sleepwalker-LP-1977.nzb', moveContainer: false }))!
assert.notEqual(a.releaseId, b.releaseId)
assert.equal(a.releaseName, 'The Kinks - Misfits-LP-1978')
assert.equal(b.releaseName, 'The Kinks - Sleepwalker-LP-1977')
const container = await db.from('files').where('id', archive.id).first()
assert.isNull(container.release_id)
})
test('a posted NZB alone in its own release moves to the release it describes', async ({ assert }) => {
const now = new Date()
const insert = async (key: string, name: string) =>
(await db.table('releases').insert({ release_key: key.padEnd(40, 'r'), name, source: 'stem', groups: '[]', first_seen_at: now, last_seen_at: now }).returning('id'))[0].id
const target = await insert('target', 'Backbeat.1994.1080p.BluRay.x264-OFT')
const lonely = await insert('lonely', '.nzb')
await file({ filename: 'x.part1.rar', release_id: target, message_ids: JSON.stringify({ '1': { id: 'known-1@x', size: 700 } }) })
const posted = await file({ filename: '.nzb', release_id: lonely, message_ids: JSON.stringify({ '1': { id: 'nzb-2@x', size: 2000 } }) })
await NzbImportService.import(posted, XML)
assert.equal(Number((await db.from('files').where('id', posted.id).first()).release_id), Number(target))
assert.notExists(await db.from('releases').where('id', lonely).first())
})
})
+56
View File
@@ -0,0 +1,56 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import Par2Service from '#services/Par2Service'
import type { Par2Set } from '#lib/par2/index'
const VOLUME = 10_000_952 // a standard split-volume size that recurs across posts
async function release(key: string, name: string) {
const now = new Date()
const [row] = await db
.table('releases')
.insert({ release_key: key.padEnd(40, '0'), name, source: 'weak-stem', groups: '[]', posted_at: now, first_seen_at: now, last_seen_at: now })
.returning('id')
return Number(row.id)
}
async function binary(key: string, fields: Record<string, unknown>) {
const now = new Date()
const [row] = await db
.table('binaries')
.insert({ file_key: key.padEnd(40, '0'), poster: 'x', subject: '"x" yEnc', name: 'Xq8r2KdLm3Zw91PqA7', total_parts: 14, groups: '[]', posted_at: now, first_seen_at: now, last_seen_at: now, ...fields })
.returning(['id', 'poster', 'groups', 'posted_at'])
return row
}
function set(id: string, files: { name: string; md5_16k: string }[]): Par2Set {
return {
recoverySetId: id.padEnd(32, '0'),
files: files.map((f, i) => ({ fileId: `${id}${i}`.padEnd(32, '0'), md5: '0'.repeat(32), md5_16k: f.md5_16k, length: VOLUME, name: f.name })),
}
}
test.group('Par2Service.attach', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('a set does not join another post just because a size matches', async ({ assert }) => {
const other = await release('other', 'OtherPost')
await binary('other-vol', { size: VOLUME, md5_16k: 'a'.repeat(32), release_id: other })
const par2 = await binary('par2-b', { size: 50_000, total_parts: 1 })
const result = await Par2Service.attach(par2, set('b', [{ name: 'PostB.part1.rar', md5_16k: 'b'.repeat(32) }]))
assert.notEqual(result.releaseId, other)
assert.equal(await db.from('release_files').where('release_id', other).count('* as n').first().then((r) => Number(r.n)), 0)
})
test('an exactly matching file pulls the set into its release and gets its real name', async ({ assert }) => {
const stemRelease = await release('stem', 'Xq8r2KdLm3Zw91PqA7')
const vol = await binary('vol', { size: VOLUME, md5_16k: 'c'.repeat(32), release_id: stemRelease })
const par2 = await binary('par2-c', { size: 50_000, total_parts: 1 })
const result = await Par2Service.attach(par2, set('c', [{ name: 'Real.Name.part1.rar', md5_16k: 'c'.repeat(32) }]))
assert.equal(result.releaseId, stemRelease)
assert.equal((await db.from('binaries').where('id', vol.id).first()).real_name, 'Real.Name.part1.rar')
})
})
+84
View File
@@ -0,0 +1,84 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
import IngestService, { type IngestSegment } from '#services/IngestService'
import SplitService from '#services/SplitService'
import { weakFileKey } from '#lib/subject/index'
import { crc32Hex } from '#lib/yenc/index'
// Three 4-byte parts per volume, with real CRCs so the split volumes verify.
const DATA: Record<string, Buffer> = { A: Buffer.from('AAAAaaaa1111'), B: Buffer.from('BBBBbbbb2222') }
const KEY = weakFileKey('a8f3Kq', 10_000_952, 3)
function article(volume: string, number: number, articleNumber: number): IngestSegment {
return {
fileKey: KEY,
poster: `random-${articleNumber}@example.invalid`,
subject: '"a8f3Kq" yEnc',
name: `random${articleNumber}`,
totalParts: 3,
number,
messageId: `${volume}-${number}@test`,
bytes: 700_000,
articleNumber,
groups: ['alt.binaries.test'],
weakIdentity: true,
size: 10_000_952,
release: null,
pcrc32: crc32Hex(DATA[volume].subarray((number - 1) * 4, number * 4)),
partSize: 4,
fileCrc32: number === 3 ? crc32Hex(DATA[volume]) : undefined,
}
}
test.group('SplitService', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
group.each.setup(() => {
// Keep completed volumes out of the real file queue.
const enqueue = IngestService.enqueue
IngestService.enqueue = async () => {}
return () => {
IngestService.enqueue = enqueue
}
})
test('splits two equal-sized volumes into verified, complete files', async ({ assert }) => {
// Volume A then volume B, arriving interleaved and out of order.
await IngestService.ingest([article('A', 1, 101), article('A', 2, 102), article('B', 1, 104)])
await IngestService.ingest([article('B', 2, 105), article('A', 3, 103), article('B', 3, 106)])
const parent = await db.from('binaries').where('file_key', KEY).first()
assert.isNotNull(parent.conflicted_at)
const result = await SplitService.splitPending()
assert.equal(result.volumes, 2)
assert.equal(result.completed, 2)
const volumes = await db.from('binaries').where('parent_binary_id', parent.id).orderBy('id')
assert.lengthOf(volumes, 2)
for (const [i, volume] of volumes.entries()) {
assert.isNotNull(volume.completed_at)
const parts = await db.from('parts').where('binary_id', volume.id).orderBy('number').select('message_id')
const letter = i === 0 ? 'A' : 'B'
assert.deepEqual(parts.map((p) => p.message_id), [1, 2, 3].map((n) => `${letter}-${n}@test`))
}
// Nothing new since the split: the next run leaves it alone.
assert.equal((await SplitService.splitPending()).parents, 0)
})
test('a mix that fails CRC verification is not turned into a file', async ({ assert }) => {
// B's last part is missing: the only complete candidate would mix volumes.
await IngestService.ingest([article('A', 1, 101), article('B', 1, 102), article('A', 2, 103)])
await IngestService.ingest([article('B', 2, 104), article('A', 3, 105)])
const parent = await db.from('binaries').where('file_key', KEY).first()
const result = await SplitService.splitPending()
const volumes = await db.from('binaries').where('parent_binary_id', parent.id)
for (const volume of volumes) {
const parts = await db.from('parts').where('binary_id', volume.id).select('message_id')
assert.lengthOf(new Set(parts.map((p) => p.message_id[0])), 1)
}
assert.isAtMost(result.completed, 1)
})
})
+106
View File
@@ -0,0 +1,106 @@
import { test } from '@japa/runner'
import db from '@adonisjs/lucid/services/db'
import testUtils from '@adonisjs/core/services/test_utils'
async function seed() {
const now = new Date()
const [release] = await db
.table('releases')
.insert({ release_key: 'w'.repeat(40), name: 'Webtest.Show.S01E02.1080p', source: 'par2', groups: '["alt.binaries.test"]', posted_at: now, first_seen_at: now, last_seen_at: now, contents: JSON.stringify([{ name: 'Webtest.Show.S01E02.1080p/episode.mkv', size: 1000 }]) })
.returning('id')
await db.table('files').insert({
filename: 'Xq8r2KdLm3Zw91PqA7webtest', real_name: 'Webtest.Show.S01E02.1080p.mkv', poster: 'p@example.invalid',
date: now.getTime(), parts: 2, size: 1400, release_id: release.id, groups: '["alt.binaries.test"]',
message_ids: JSON.stringify({ '1': { id: 'one@test', size: 700 }, '2': { id: 'two@test', size: 700 } }),
created_at: now, updated_at: now,
})
return Number(release.id)
}
test.group('Web API', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('searches by words in any order, with dots and spaces interchangeable', async ({ client, assert }) => {
const id = await seed()
for (const q of ['webtest show s01e02', 'Webtest.Show', 'episode.mkv 1080p']) {
const response = await client.get('/api/v1/releases').qs({ q })
response.assertStatus(200)
assert.include(response.body().releases.map((r: any) => r.id), id, q)
}
const none = await client.get('/api/v1/releases').qs({ q: 'webtest nosuchword' })
assert.notInclude(none.body().releases.map((r: any) => r.id), id)
})
test('filters and details', async ({ client, assert }) => {
const id = await seed()
const readable = await client.get('/api/v1/releases').qs({ q: 'webtest', readable: '1', source: 'par2' })
assert.include(readable.body().releases.map((r: any) => r.id), id)
const other = await client.get('/api/v1/releases').qs({ q: 'webtest', source: 'rar' })
assert.notInclude(other.body().releases.map((r: any) => r.id), id)
const detail = await client.get(`/api/v1/releases/${id}`)
detail.assertStatus(200)
assert.equal(detail.body().files[0].name, 'Webtest.Show.S01E02.1080p.mkv')
assert.equal(detail.body().fileCount, 1)
assert.equal(detail.body().size, 1400)
;(await client.get('/api/v1/releases/999999999')).assertStatus(404)
})
test('serves an NZB with the real file name', async ({ client, assert }) => {
const id = await seed()
const response = await client.get(`/api/v1/releases/${id}/nzb`)
response.assertStatus(200)
assert.include(response.header('content-type'), 'application/x-nzb')
assert.include(response.header('content-disposition'), 'Webtest.Show.S01E02.1080p.nzb')
assert.include(response.text(), '&quot;Webtest.Show.S01E02.1080p.mkv&quot; yEnc (1/2)')
assert.include(response.text(), '<segment bytes="700" number="1">one@test</segment>')
})
test('stats', async ({ client }) => {
const response = await client.get('/api/v1/stats')
response.assertStatus(200)
response.assertBodyContains({ sources: [] })
})
test('completeness follows files and par2 lists through triggers', async ({ assert }) => {
const now = new Date()
const complete = async (id: number) => (await db.from('releases').where('id', id).first()).complete
const file = (releaseId: number, name: string) => ({
filename: name, poster: 'p', date: now.getTime(), parts: 1, size: 100_000, release_id: releaseId,
groups: '[]', message_ids: '{}', created_at: now, updated_at: now,
})
// Counter rule: [i/M] announced 2 files.
const [counted] = await db.table('releases').insert({ release_key: 'c'.repeat(40), name: 'Counted', groups: '[]', file_total: 2, first_seen_at: now, last_seen_at: now }).returning('id')
await db.table('files').insert(file(counted.id, 'a.rar'))
assert.isFalse(await complete(counted.id))
await db.table('files').insert(file(counted.id, 'b.rar'))
assert.isTrue(await complete(counted.id))
// par2 rule: the listed file becomes present once a file of its exact size is there.
const [listed] = await db.table('releases').insert({ release_key: 'l'.repeat(40), name: 'Listed', groups: '[]', first_seen_at: now, last_seen_at: now }).returning('id')
await db.table('release_files').insert({ release_id: listed.id, par2_file_id: '1'.repeat(32), name: 'Listed.mkv', size: 5_000_000, md5_16k: '0'.repeat(32) })
assert.isFalse(await complete(listed.id))
const [f] = await db.table('files').insert(file(listed.id, 'Xq8r2KdLm3Zw91PqA7')).returning('id')
const [b] = await db.table('binaries').insert({ file_key: 'z'.repeat(40), poster: 'p', subject: '"x" yEnc', name: 'x', total_parts: 1, groups: '[]', first_seen_at: now, last_seen_at: now, file_id: f.id }).returning('id')
assert.isFalse(await complete(listed.id))
await db.from('binaries').where('id', b.id).update({ size: 5_000_000 })
assert.isTrue(await complete(listed.id))
})
test('par2-only releases are hidden unless asked for', async ({ client, assert }) => {
const now = new Date()
const [release] = await db.table('releases')
.insert({ release_key: 'p'.repeat(40), name: 'Webtest.Par2only.S01E01.1080p.WEB-DL-GRP', groups: '[]', first_seen_at: now, last_seen_at: now })
.returning('id')
await db.table('release_files').insert({ release_id: release.id, par2_file_id: '2'.repeat(32), name: 'Webtest.Par2only.S01E01.mkv', size: 1_000_000_000, md5_16k: '0'.repeat(32) })
await db.table('files').insert({ filename: 'Xq8r2KdLm3Zw91PqA7', content_type: 'par2', poster: 'p', date: now.getTime(), parts: 1, size: 3919,
release_id: release.id, groups: '[]', message_ids: '{}', created_at: now, updated_at: now })
const hidden = await client.get('/api/v1/releases').qs({ q: 'webtest par2only' })
assert.notInclude(hidden.body().releases.map((r: any) => r.id), Number(release.id))
const shown = await client.get('/api/v1/releases').qs({ q: 'webtest par2only', par2_only: '1' })
const row = shown.body().releases.find((r: any) => r.id === Number(release.id))
assert.isTrue(row.par2Only)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { test } from '@japa/runner'
import yencode from 'yencode'
import { sniffContentType } from '#lib/content/index'
import { crc32Hex, decodeSegment, YencError } from '#lib/yenc/index'
test.group('content / sniffContentType', () => {
test('recognises containers and media by magic bytes', ({ assert }) => {
const magic = (hex: string, pad = 16) => Buffer.concat([Buffer.from(hex, 'hex'), Buffer.alloc(pad)])
assert.equal(sniffContentType(Buffer.concat([Buffer.from('nzbe'), Buffer.alloc(40)])), 'nzbe')
assert.equal(sniffContentType(magic('5041523200504b54')), 'par2')
assert.equal(sniffContentType(magic('526172211a070100')), 'rar5')
assert.equal(sniffContentType(magic('526172211a0700')), 'rar4')
assert.equal(sniffContentType(magic('1a45dfa3')), 'matroska')
assert.equal(sniffContentType(magic('0000001866747970')), 'mp4')
assert.equal(sniffContentType(magic('28b52ffd')), 'zstd')
assert.equal(sniffContentType(Buffer.from('<?xml version="1.0"?>\n<!DOCTYPE nzb PUBLIC "x">')), 'nzb')
assert.isNull(sniffContentType(Buffer.from('plain text')))
assert.equal(sniffContentType(magic('ffd8ffe0')), 'jpeg')
assert.equal(sniffContentType(magic('89504e470d0a1a0a')), 'png')
assert.equal(sniffContentType(Buffer.concat([Buffer.from('ID3'), Buffer.alloc(16)])), 'mp3')
assert.equal(sniffContentType(Buffer.concat([Buffer.from('fLaC'), Buffer.alloc(16)])), 'flac')
assert.equal(sniffContentType(Buffer.from('RIFF\x00\x00\x00\x00AVI LIST', 'latin1')), 'avi')
// UDF (Blu-ray) volume recognition sequence at 32 KiB
const udf = Buffer.alloc(0x9000)
udf.write('BEA01', 0x8001, 'latin1')
udf.write('NSR03', 0x8801, 'latin1')
assert.equal(sniffContentType(udf), 'udf')
})
})
test.group('yenc / lenientSize', () => {
test('decodes a part whose declared size leaves out a header, still checking the CRC', ({ assert }) => {
const data = Buffer.concat([Buffer.from('nzbe'), Buffer.alloc(16, 7), Buffer.from('ciphertext-here')])
const declared = data.length - 20
const body = Buffer.from(
`=ybegin part=1 line=128 size=${declared} name=f6dd6b156692a52b.bin\r\n=ypart begin=1 end=${declared}\r\n` +
yencode.encode(data).toString('latin1') +
`\r\n=yend size=${declared} part=1 pcrc32=${crc32Hex(data)}`,
'latin1'
)
assert.throws(() => decodeSegment(body), YencError as any)
assert.isTrue(decodeSegment(body, { lenientSize: true }).data.equals(data))
})
})
+18
View File
@@ -0,0 +1,18 @@
import { test } from '@japa/runner'
import { parseGroupResponse } from '#lib/nntp/group'
test.group('nntp / parseGroupResponse', () => {
test('parses the reply from news.eweka.nl', ({ assert }) => {
// Captured 2026-09-27. count == last - first + 1, so the field order is standard.
assert.deepEqual(parseGroupResponse('211 27275164317 266599 27275430915 alt.binaries.test'), {
count: 27275164317n,
first: 266599n,
last: 27275430915n,
name: 'alt.binaries.test',
})
})
test('rejects non-211 replies', ({ assert }) => {
assert.throws(() => parseGroupResponse('411 No such group'), /Unexpected GROUP response/)
})
})
+55
View File
@@ -0,0 +1,55 @@
import { test } from '@japa/runner'
import { buildNzb } from '#lib/nzb/index'
test.group('nzb / buildNzb', () => {
test('writes files, groups and ordered segments', ({ assert }) => {
const xml = buildNzb(
[
{
name: 'Some.Show.S01E01.mkv',
poster: 'poster@example.invalid',
date: 1_790_000_000_000,
groups: ['alt.binaries.test'],
segments: { '2': { id: 'b@x', size: 700 }, '1': { id: 'a@x', size: 800 } },
},
],
{ title: 'Some.Show.S01E01' }
)
assert.include(xml, '<!DOCTYPE nzb PUBLIC "-//newzBin//DTD NZB 1.1//EN"')
assert.include(xml, '<meta type="name">Some.Show.S01E01</meta>')
assert.include(xml, 'subject="&quot;Some.Show.S01E01.mkv&quot; yEnc (1/2)"')
assert.include(xml, 'date="1790000000"')
assert.include(xml, '<group>alt.binaries.test</group>')
assert.isBelow(xml.indexOf('>a@x<'), xml.indexOf('>b@x<'))
assert.include(xml, '<segment bytes="800" number="1">a@x</segment>')
})
})
test.group('nzb / parseNzb', () => {
test('reads meta, files, groups and segments, decoding entities', async ({ assert }) => {
const { parseNzb } = await import('#lib/nzb/index')
const xml = `<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE nzb PUBLIC "-//newzBin//DTD NZB 0.9//EN" "http://www.newzbin.com/DTD/nzb/nzb-0.9.dtd">
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<head><meta type="name">Some.Show.S01E01</meta><meta type="password">s3cr&amp;t</meta></head>
<file poster="Yenc@power-post.org (Cosmo71)" date="1790598923" subject="Wond40 &#x22;Nachbe&#x22; x.part1.rar&#x22; yEnc (0001/2)">
<groups><group>alt.binaries.bloaf</group></groups>
<segments>
<segment bytes="1000" number="2">b@x.local</segment>
<segment bytes="2000" number="1">&lt;a@x.local&gt;</segment>
</segments>
</file>
<file poster="p" date="1" subject="empty"><groups/><segments></segments></file>
</nzb>`
const nzb = parseNzb(xml)!
assert.deepEqual(nzb.meta, { name: 'Some.Show.S01E01', password: 's3cr&t' })
assert.lengthOf(nzb.files, 1)
assert.equal(nzb.files[0].subject, 'Wond40 "Nachbe" x.part1.rar" yEnc (0001/2)')
assert.deepEqual(nzb.files[0].groups, ['alt.binaries.bloaf'])
assert.deepEqual(nzb.files[0].segments, [
{ number: 1, bytes: 2000, id: 'a@x.local' },
{ number: 2, bytes: 1000, id: 'b@x.local' },
])
assert.isNull(parseNzb('<html>not an nzb</html>'))
})
})
+60
View File
@@ -0,0 +1,60 @@
import { test } from '@japa/runner'
import { createHash } from 'node:crypto'
import { isPar2, parsePar2 } from '#lib/par2/index'
const md5 = (data: Buffer) => createHash('md5').update(data).digest()
/** Builds one PAR2 packet with a valid checksum. */
function packet(setId: Buffer, type: string, body: Buffer) {
const padded = Buffer.concat([body, Buffer.alloc((4 - (body.length % 4)) % 4)])
const length = Buffer.alloc(8)
length.writeBigUInt64LE(BigInt(64 + padded.length))
const typeBuf = Buffer.alloc(16)
typeBuf.write(type, 'latin1')
const hashed = Buffer.concat([setId, typeBuf, padded])
return Buffer.concat([Buffer.from('PAR2\0PKT', 'latin1'), length, md5(hashed), hashed])
}
function fileDesc(setId: Buffer, name: string, size: number) {
const len = Buffer.alloc(8)
len.writeBigUInt64LE(BigInt(size))
const md5_16k = md5(Buffer.from(`${name}-16k`))
const fileId = md5(Buffer.concat([md5_16k, len, Buffer.from(name)]))
const body = Buffer.concat([fileId, md5(Buffer.from(name)), md5_16k, len, Buffer.from(name, 'utf8')])
return packet(setId, 'PAR 2.0\0FileDesc', body)
}
test.group('par2 / parsePar2', () => {
const setId = Buffer.alloc(16, 7)
test('reads file names and sizes from FileDesc packets', ({ assert }) => {
const data = Buffer.concat([
packet(setId, 'PAR 2.0\0Main', Buffer.alloc(12)),
fileDesc(setId, 'Some.Movie.2024.part01.rar', 104857600),
fileDesc(setId, 'Some.Movie.2024.part02.rar', 52428800),
fileDesc(setId, 'Some.Movie.2024.part01.rar', 104857600), // duplicates are common
])
assert.isTrue(isPar2(data))
const set = parsePar2(data)!
assert.equal(set.recoverySetId, setId.toString('hex'))
assert.deepEqual(
set.files.map((f) => [f.name, f.length]),
[
['Some.Movie.2024.part01.rar', 104857600],
['Some.Movie.2024.part02.rar', 52428800],
]
)
})
test('skips corrupted packets and resumes at the next one', ({ assert }) => {
const bad = fileDesc(setId, 'bad.bin', 1)
bad[100] ^= 0xff
const data = Buffer.concat([bad, fileDesc(setId, 'good.bin', 12345)])
assert.deepEqual(parsePar2(data)!.files.map((f) => f.name), ['good.bin'])
})
test('returns null for non-par2 data', ({ assert }) => {
assert.isFalse(isPar2(Buffer.from('Rar!\x1a\x07\x00', 'latin1')))
assert.isNull(parsePar2(Buffer.from('hello world')))
})
})
+230
View File
@@ -0,0 +1,230 @@
import { test } from '@japa/runner'
import { crc32 } from 'node:zlib'
import { decodeRar4Name, parseRarHeaders, rarFormat, storedEntryData, type RarEntry } from '#lib/rar/index'
/** Entries without the extraction fields, for comparing listings. */
const listing = (entries: RarEntry[]) => entries.map(({ stored, dataOffset, ...rest }) => rest)
// --- RAR 4 builders -----------------------------------------------------------
const RAR4 = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00])
function rar4Block(type: number, flags: number, body: Buffer) {
const header = Buffer.alloc(7)
header.writeUInt8(type, 2)
header.writeUInt16LE(flags, 3)
header.writeUInt16LE(7 + body.length, 5)
const block = Buffer.concat([header, body])
block.writeUInt16LE(crc32(block.subarray(2)) & 0xffff, 0)
return block
}
function rar4Main(flags: number) {
return rar4Block(0x73, flags, Buffer.alloc(6))
}
function rar4File(name: Buffer, opts: { flags?: number; packed: number; unpacked: number; method?: number }) {
const body = Buffer.alloc(25)
body.writeUInt8(opts.method ?? 0x33, 18)
body.writeUInt32LE(opts.packed, 0)
body.writeUInt32LE(opts.unpacked, 4)
body.writeUInt16LE(name.length, 19)
// LONG_BLOCK (0x8000) is always set on file headers.
return Buffer.concat([rar4Block(0x74, 0x8000 | (opts.flags ?? 0), Buffer.concat([body, name])), Buffer.alloc(opts.packed)])
}
// --- RAR 5 builders -----------------------------------------------------------
const RAR5 = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00])
function vint(value: number) {
const bytes: number[] = []
do {
let byte = value % 128
value = Math.floor(value / 128)
if (value > 0) byte |= 0x80
bytes.push(byte)
} while (value > 0)
return Buffer.from(bytes)
}
function rar5Block(type: number, headerFlags: number, fields: Buffer, opts: { extra?: Buffer; data?: number } = {}) {
let flags = headerFlags
const parts = [vint(type)]
const sizes: Buffer[] = []
if (opts.extra) {
flags |= 0x0001
sizes.push(vint(opts.extra.length))
}
if (opts.data !== undefined) {
flags |= 0x0002
sizes.push(vint(opts.data))
}
const header = Buffer.concat([...parts, vint(flags), ...sizes, fields, opts.extra ?? Buffer.alloc(0)])
const sized = Buffer.concat([vint(header.length), header])
const crc = Buffer.alloc(4)
crc.writeUInt32LE(crc32(sized) >>> 0)
return Buffer.concat([crc, sized, Buffer.alloc(opts.data ?? 0)])
}
function rar5File(name: string, size: number, opts: { packed: number; headerFlags?: number; encrypted?: boolean; dir?: boolean }) {
const nameBuf = Buffer.from(name, 'utf8')
const fields = Buffer.concat([
vint(opts.dir ? 0x0001 : 0), // file flags
vint(size),
vint(0x20), // attributes
vint(0), // compression info
vint(0), // host OS
vint(nameBuf.length),
nameBuf,
])
// Encryption record: size, type 1, then its data (dummy here).
const extra = opts.encrypted ? Buffer.concat([vint(3), vint(0x01), Buffer.from([0, 0])]) : undefined
return rar5Block(2, opts.headerFlags ?? 0, fields, { extra, data: opts.packed })
}
test.group('rar / RAR 4', () => {
test('lists files of a first volume, including 64-bit sizes and flags', ({ assert }) => {
const data = Buffer.concat([
RAR4,
rar4Main(0x0001 | 0x0100), // volume, first volume
rar4File(Buffer.from('Some.Movie.2024/movie.mkv'), { packed: 10, unpacked: 5_000_000, flags: 0x0002 }),
])
assert.equal(rarFormat(data), 'rar4')
const info = parseRarHeaders(data)!
assert.include(info, { format: 'rar4', volume: true, firstVolume: true, headersEncrypted: false, truncated: true })
assert.deepEqual(listing(info.entries), [
{ name: 'Some.Movie.2024/movie.mkv', size: 5_000_000, packedSize: 10, directory: false, encrypted: false, splitBefore: false, splitAfter: true },
])
})
test('detects password-protected files and encrypted headers', ({ assert }) => {
const files = parseRarHeaders(
Buffer.concat([RAR4, rar4Main(0), rar4File(Buffer.from('secret.bin'), { packed: 4, unpacked: 4, flags: 0x0004 })])
)!
assert.isTrue(files.entries[0].encrypted)
const headers = parseRarHeaders(Buffer.concat([RAR4, rar4Main(0x0080)]))!
assert.isTrue(headers.headersEncrypted)
assert.lengthOf(headers.entries, 0)
})
test('copies out stored entries, not compressed ones', ({ assert }) => {
const data = Buffer.concat([
RAR4,
rar4Main(0),
rar4File(Buffer.from('a.nzb'), { packed: 5, unpacked: 5, method: 0x30 }),
rar4File(Buffer.from('b.nzb'), { packed: 5, unpacked: 9 }),
])
const [stored, packed] = parseRarHeaders(data)!.entries
data.write('<nzb>', stored.dataOffset)
assert.equal(storedEntryData(data, stored)?.toString(), '<nzb>')
assert.isFalse(packed.stored)
assert.isNull(storedEntryData(data, packed))
})
test('stops at a header with a bad CRC', ({ assert }) => {
const good = rar4File(Buffer.from('a.bin'), { packed: 1, unpacked: 1 })
const bad = rar4File(Buffer.from('b.bin'), { packed: 1, unpacked: 1 })
bad[10] ^= 0xff
const info = parseRarHeaders(Buffer.concat([RAR4, rar4Main(0), good, bad]))!
assert.deepEqual(info.entries.map((e) => e.name), ['a.bin'])
})
test('decodes compressed Unicode names', ({ assert }) => {
// "é.txt": ASCII fallback "?.txt", then highByte 0, flags 0b01_00_00_00 (é via high byte,
// then 3 plain bytes), followed by one more flag byte for the last byte.
const ascii = Buffer.from('?.txt', 'latin1')
const encoded = Buffer.from([0x00, 0b01000000, 0xe9, 0x2e, 0x74, 0x78, 0b00000000, 0x74])
assert.equal(decodeRar4Name(Buffer.concat([ascii, Buffer.from([0]), encoded])), 'é.txt')
assert.equal(decodeRar4Name(Buffer.from('plain.txt')), 'plain.txt')
})
})
test.group('rar / RAR 5', () => {
test('lists files and reads volume flags', ({ assert }) => {
const data = Buffer.concat([
RAR5,
rar5Block(1, 0, vint(0x0001)), // main: volume, no volume number → first volume
rar5File('Release/track01.flac', 30_000_000, { packed: 20, headerFlags: 0x0010 }),
])
assert.equal(rarFormat(data), 'rar5')
const info = parseRarHeaders(data)!
assert.include(info, { format: 'rar5', volume: true, firstVolume: true, headersEncrypted: false })
assert.deepEqual(listing(info.entries), [
{ name: 'Release/track01.flac', size: 30_000_000, packedSize: 20, directory: false, encrypted: false, splitBefore: false, splitAfter: true },
])
})
test('a later volume carries a volume number', ({ assert }) => {
const info = parseRarHeaders(Buffer.concat([RAR5, rar5Block(1, 0, Buffer.concat([vint(0x0003), vint(4)]))]))!
assert.isFalse(info.firstVolume)
})
test('detects encrypted files and encrypted headers', ({ assert }) => {
const files = parseRarHeaders(Buffer.concat([RAR5, rar5Block(1, 0, vint(0)), rar5File('x.mkv', 1, { packed: 1, encrypted: true })]))!
assert.isTrue(files.entries[0].encrypted)
const headers = parseRarHeaders(Buffer.concat([RAR5, rar5Block(4, 0, Buffer.alloc(3))]))!
assert.isTrue(headers.headersEncrypted)
})
test('marks the listing complete at the end-of-archive header', ({ assert }) => {
const info = parseRarHeaders(Buffer.concat([RAR5, rar5Block(1, 0, vint(0)), rar5File('a', 1, { packed: 1 }), rar5Block(5, 0, vint(0))]))!
assert.isFalse(info.truncated)
assert.lengthOf(info.entries, 1)
})
test('copies out entries stored without compression', ({ assert }) => {
const data = Buffer.concat([RAR5, rar5Block(1, 0, vint(0)), rar5File('a.nzb', 5, { packed: 5 }), rar5Block(5, 0, vint(0))])
const entry = parseRarHeaders(data)!.entries[0]
assert.isTrue(entry.stored)
data.write('<nzb>', entry.dataOffset)
assert.equal(storedEntryData(data, entry)?.toString(), '<nzb>')
assert.isNull(storedEntryData(data.subarray(0, entry.dataOffset + 2), entry))
assert.isNull(storedEntryData(data, { ...entry, encrypted: true }))
})
test('returns null for other data', ({ assert }) => {
assert.isNull(parseRarHeaders(Buffer.from('PAR2\0PKT', 'latin1')))
})
})
test.group('rar / release naming from contents', () => {
const info = (names: string[]) =>
({ format: 'rar5', volume: false, firstVolume: true, solid: false, headersEncrypted: false, truncated: true,
entries: names.map((name) => ({ name, size: 1, packedSize: 1, directory: false, encrypted: false, splitBefore: false, splitAfter: false })) }) as any
test('uses the shared top-level directory when it looks like a release name', async ({ assert }) => {
const { releaseNameFromContents } = await import('#services/ArchiveService')
assert.equal(
releaseNameFromContents(info(['GLDY_LX_-_Pretty_Girl_Rage_EP-WEB-2026-DIM/00-a.jpg', 'GLDY_LX_-_Pretty_Girl_Rage_EP-WEB-2026-DIM/01-b.flac'])),
'GLDY_LX_-_Pretty_Girl_Rage_EP-WEB-2026-DIM'
)
// No directory: the largest readable entry names the release.
assert.equal(
releaseNameFromContents(info(['Joker.Folie.a.Deux.2024.2160p.HMAX.WEB-DL.MULTi.DDP5.1.Atmos.H.265.nzb'])),
'Joker.Folie.a.Deux.2024.2160p.HMAX.WEB-DL.MULTi.DDP5.1.Atmos.H.265'
)
assert.isUndefined(releaseNameFromContents(info(['movie.mkv'])))
assert.isUndefined(releaseNameFromContents(info(['Xq8r2KdLm3Zw91PqA7.mkv'])))
assert.isUndefined(releaseNameFromContents(info(['a/x', 'b/y'])))
assert.isUndefined(releaseNameFromContents(info(['Xq8r2KdLm3Zw91Pq/x.mkv'])))
})
test('recognises obfuscated names', async ({ assert }) => {
const { looksRandom } = await import('#services/ArchiveService')
assert.isTrue(looksRandom('CGBWgdhlSCAOAW62gLWRRM'))
assert.isTrue(looksRandom('0220e975b1417f96b77dc64410e10ebc'))
assert.isFalse(looksRandom('Plus.and.Minus.S01E06.1080p'))
assert.isFalse(looksRandom('1790546808_nicovideo_jp_watch_sm26679715'))
})
})
test.group('rar / looksRandom capitals', () => {
test('long letter-only tokens with many capitals are random', async ({ assert }) => {
const { looksRandom } = await import('#services/ArchiveService')
assert.isTrue(looksRandom('RmISakytFwXSCEzjVEpaKk'))
assert.isTrue(looksRandom('326678d1d07ef12a.bin'))
assert.isFalse(looksRandom('Some.Show.S01E01'))
assert.isFalse(looksRandom('TheQuickBrownFoxJumps'))
})
})
+169
View File
@@ -0,0 +1,169 @@
import { test } from '@japa/runner'
import { fileStem, releaseIdentity, subjectRemainder } from '#lib/release/index'
import { looksLikeFilename, weakFileKey } from '#lib/subject/index'
test.group('release / fileStem', () => {
test('reduces all files of a set to the same stem', ({ assert }) => {
const names = [
'1790546808_nicovideo_jp_watch_sm26679715.tar.zst',
'1790546808_nicovideo_jp_watch_sm26679715.tar.zst.par2',
'1790546808_nicovideo_jp_watch_sm26679715.tar.zst.vol03+04.par2',
]
assert.deepEqual(new Set(names.map(fileStem)), new Set(['1790546808_nicovideo_jp_watch_sm26679715']))
})
test('handles rar, split and par2 volume naming', ({ assert }) => {
assert.equal(fileStem('lJzuoIKA3T8xjzAD3vqtw0.part036.rar'), 'lJzuoIKA3T8xjzAD3vqtw0')
assert.equal(fileStem('Some.Movie.2024.r07'), 'Some.Movie.2024')
assert.equal(fileStem('Some.Movie.2024.mkv.001'), 'Some.Movie.2024')
assert.equal(fileStem('Some.Movie.2024.2160p.WEB-DL.H.265.mkv'), 'Some.Movie.2024.2160p.WEB-DL.H.265')
assert.equal(fileStem('Some.Movie.2024.H.264.nzb'), 'Some.Movie.2024.H.264')
assert.equal(fileStem('Some.Movie.2024.vol127-128.par2'), 'Some.Movie.2024')
assert.equal(fileStem('Some.Movie.2024.nfo'), 'Some.Movie.2024')
assert.equal(fileStem('00-artist-album-web-2026.m3u'), '00-artist-album-web-2026')
assert.equal(fileStem('f6dd6b156692a52b.bin'), 'f6dd6b156692a52b')
})
test('never reduces a name to nothing', ({ assert }) => {
assert.equal(fileStem('.par2'), '.par2')
})
})
test.group('release / subjectRemainder', () => {
test('is empty when the subject only has counters, filename and size', ({ assert }) => {
assert.equal(subjectRemainder('[1/4] - "x.tar.zst" yEnc 15411409'), '')
assert.equal(subjectRemainder('. - [00/88] - "60SHkTQSvn5RZGExwju2.nzb" yEnc'), '')
})
test('keeps a release name written outside the quotes', ({ assert }) => {
assert.equal(
subjectRemainder('Some.Show.S01E02.1080p [03/40] - "abc.part02.rar" yEnc'),
'Some Show S01E02 1080p'
)
})
})
test.group('release / releaseIdentity', () => {
test('groups a par2 set with its archive by stem, per poster', ({ assert }) => {
const base = { poster: 'p', weak: false }
const a = releaseIdentity({ ...base, subject: '[1/4] - "x.tar.zst" yEnc 1', name: 'x.tar.zst' })!
const b = releaseIdentity({ ...base, subject: '[3/4] - "x.tar.zst.vol01+02.par2" yEnc 2', name: 'x.tar.zst.vol01+02.par2' })!
assert.equal(a.key, b.key)
assert.equal(a.name, 'x')
assert.equal(a.fileTotal, 4)
const other = releaseIdentity({ ...base, poster: 'q', subject: '[1/4] - "x.tar.zst" yEnc 1', name: 'x.tar.zst' })!
assert.notEqual(a.key, other.key)
})
test('a par2 index posted without [i/M] counter joins its release', ({ assert }) => {
const base = { poster: 'nicovideo_archiver', weak: false }
const archive = releaseIdentity({ ...base, subject: '[1/6] - "x.tar.zst" yEnc 105054576', name: 'x.tar.zst' })!
const index = releaseIdentity({ ...base, subject: '"x.tar.zst.par2" yEnc 3100', name: 'x.tar.zst.par2' })!
assert.equal(index.key, archive.key)
assert.equal(archive.fileTotal, 6)
assert.isUndefined(index.fileTotal)
})
test('uses the subject text when it names the release', ({ assert }) => {
const base = { poster: 'p', weak: false }
const a = releaseIdentity({ ...base, subject: 'My.Release [01/40] - "abc.part01.rar" yEnc', name: 'abc.part01.rar' })!
const b = releaseIdentity({ ...base, subject: 'My.Release [40/40] - "abc.par2" yEnc', name: 'abc.par2' })!
assert.equal(a.key, b.key)
assert.equal(a.name, 'My Release')
})
test('obfuscated volumes group by stem regardless of the random poster', ({ assert }) => {
const a = releaseIdentity({ poster: '3Uzx7TTSFpLzz@ngPost.com', subject: '"lJzu.part033.rar" yEnc', name: 'lJzu.part033.rar', weak: true })!
const b = releaseIdentity({ poster: 'bGLd1oaXRdrK1@ngPost.com', subject: '"lJzu.part034.rar" yEnc', name: 'lJzu.part034.rar', weak: true })!
assert.equal(a.key, b.key)
})
test('random extensionless names cannot be grouped by name', ({ assert }) => {
const name = '204b914a977e5f891590ff1099c7a8caf1a0be537bfa5bf7f3a8b1213231'
assert.isNull(releaseIdentity({ poster: 'x', subject: `"${name}" yEnc`, name, weak: true }))
})
})
test.group('subject / weakFileKey', () => {
test('ignores random per-article names, keeps real filenames', ({ assert }) => {
assert.isFalse(looksLikeFilename('204b914a977e5f891590ff1099c7a8caf1a0be537bfa5bf7f3a8b1213231'))
assert.isTrue(looksLikeFilename('lJzuoIKA3T8xjzAD3vqtw0.part033.rar'))
// Same file, different random names per article: same key.
assert.equal(weakFileKey('aaaa1111', 5000, 7), weakFileKey('bbbb2222', 5000, 7))
// Equal-sized volumes with real names: different keys.
assert.notEqual(weakFileKey('x.part01.rar', 5000, 7), weakFileKey('x.part02.rar', 5000, 7))
assert.notEqual(weakFileKey('aaaa1111', 5000, 7), weakFileKey('aaaa1111', 5001, 7))
})
})
test.group('release / rarReleaseIdentity', () => {
test('groups volumes by the shared top-level directory', async ({ assert }) => {
const { rarReleaseIdentity } = await import('#lib/release/index')
const first = rarReleaseIdentity([
{ name: 'LASZR_-_DYING_ALIVE-(PRO094)-WEB-2026-DIM/00-cover.jpg', size: 589382 },
{ name: 'LASZR_-_DYING_ALIVE-(PRO094)-WEB-2026-DIM/01-track.flac', size: 30_000_000 },
])!
const later = rarReleaseIdentity([{ name: 'LASZR_-_DYING_ALIVE-(PRO094)-WEB-2026-DIM/03-track.flac', size: 29_000_000 }])!
assert.equal(first.key, later.key)
assert.equal(first.name, 'LASZR_-_DYING_ALIVE-(PRO094)-WEB-2026-DIM')
assert.equal(first.source, 'rar')
})
test('without a directory, volumes of one file share its first entry', async ({ assert }) => {
const { rarReleaseIdentity } = await import('#lib/release/index')
const a = rarReleaseIdentity([{ name: 'Some.Show.S01E01.1080p.mkv', size: 3_000_000_000 }])!
const b = rarReleaseIdentity([{ name: 'Some.Show.S01E01.1080p.mkv', size: 3_000_000_000 }])!
assert.equal(a.key, b.key)
assert.equal(a.name, 'Some.Show.S01E01.1080p')
assert.isNull(rarReleaseIdentity([]))
})
})
test.group('release / namesRelease', () => {
test('random per-file tags and poster ids are not a release name', async ({ assert }) => {
const { namesRelease, subjectRemainder, releaseIdentity } = await import('#lib/release/index')
const subject = 'elys-de81bcabef5faf6a - [Nyh5Q07Y] "JkpNIFg7En50zU1U" yEnc'
assert.isFalse(namesRelease(subjectRemainder(subject)))
const a = releaseIdentity({ poster: 'elys', subject, name: 'JkpNIFg7En50zU1U', weak: false })!
assert.equal(a.source, 'stem')
assert.isTrue(namesRelease(subjectRemainder('Some.Show.S01E02.1080p [03/40] - "abc.part02.rar" yEnc')))
assert.isTrue(namesRelease('My Release'))
})
})
test.group('release / post id', () => {
test('groups random-named files of one post by poster, post id and file count', async ({ assert }) => {
const { releaseIdentity, isPostToken } = await import('#lib/release/index')
const base = { poster: 'jW344UtC8R2bZEegrVLz@x', weak: false }
const a = releaseIdentity({ ...base, subject: '83110629-n [10/18] - "eiae6fulpolas2adfts6o8643n3e6j6ct8d3219aau0jc33ol9r50tohncgr46evpl0dqoqktfb7k" yEnc', name: 'eiae6fulpolas2adfts6o8643n3e6j6ct8d3219aau0jc33ol9r50tohncgr46evpl0dqoqktfb7k' })!
const b = releaseIdentity({ ...base, subject: '83110629-n [01/18] - "2ef0vm4kb618nv17kn0qsb0j1ofh7pgqtk5a3v5lkqqf1f7g6ed0" yEnc', name: '2ef0vm4kb618nv17kn0qsb0j1ofh7pgqtk5a3v5lkqqf1f7g6ed0' })!
assert.equal(a.key, b.key)
assert.equal(a.source, 'post')
assert.equal(a.fileTotal, 18)
const other = releaseIdentity({ ...base, subject: '83110630-n [01/18] - "x" yEnc', name: 'x' })!
assert.notEqual(a.key, other.key)
assert.isTrue(isPostToken('83110629 n'))
assert.isFalse(isPostToken('PRiVATE'))
assert.isFalse(isPostToken(''))
// Without a counter there is nothing to bound the post: fall back to the stem.
assert.equal(releaseIdentity({ ...base, subject: '83110629-n - "x.bin" yEnc', name: 'x.bin' })!.source, 'stem')
})
})
test.group('release / par2 names', () => {
test('names a season pack after what its episodes share', async ({ assert }) => {
const { par2ReleaseName } = await import('#lib/release/index')
const eps = Array.from({ length: 20 }, (_, i) =>
`SEAL.Team.S03E${String(i + 1).padStart(2, '0')}.1080p.AMZN.WEB-DL.AAC2.0.H.265.DUAL-nitrato.mkv`)
assert.equal(par2ReleaseName(eps), 'SEAL.Team.S03.1080p.AMZN.WEB-DL.AAC2.0.H.265.DUAL-nitrato')
// Movie plus sample and nfo: the movie's (longest) name.
assert.equal(
par2ReleaseName(['Some.Movie.2024.1080p.BluRay.x264-GRP.mkv', 'Some.Movie.2024.1080p.BluRay.x264-GRP.nfo', 'sample.mkv']),
'Some.Movie.2024.1080p.BluRay.x264-GRP'
)
// Numbered parts: the shared name without the part number.
assert.equal(par2ReleaseName(['Album.Name-2024-GRP.part01.rar', 'Album.Name-2024-GRP.part02.rar']), 'Album.Name-2024-GRP')
assert.equal(par2ReleaseName(['only.one.file.2024.mkv']), 'only.one.file.2024')
})
})
+162
View File
@@ -0,0 +1,162 @@
import { test } from '@japa/runner'
import { splitInstances, splitVerified, verifyVolume, type SplitArticle } from '#lib/split/index'
import { crc32Hex } from '#lib/yenc/index'
/** Deterministic pseudo-random numbers. */
function rng(seed: number) {
let x = seed
return () => {
x = (x * 1103515245 + 12345) & 0x7fffffff
return x / 0x7fffffff
}
}
interface PostOptions {
volumes: number
parts: number
/** Parallel upload connections; they finish slightly out of order. */
connections?: number
/** First article number of the post. */
start?: number
/** Unrelated articles between two articles of this post (other posters). */
noise?: number
/** Fraction of articles that never arrive. */
drop?: number
label?: string
seed?: number
}
/** Simulates a posting tool uploading equal-sized volumes. */
function post(opts: PostOptions): SplitArticle[] {
const random = rng(opts.seed ?? 1)
const connections = opts.connections ?? 1
const label = opts.label ?? 'p'
const queue: { volume: number; number: number }[] = []
for (let v = 0; v < opts.volumes; v++) for (let n = 1; n <= opts.parts; n++) queue.push({ volume: v, number: n })
const articles: SplitArticle[] = []
let articleNumber = opts.start ?? 1000
for (let i = 0; i < queue.length; i += connections) {
// One round: each connection posts one article; they land in random order.
const round = queue.slice(i, i + connections).sort(() => random() - 0.5)
for (const { volume, number } of round) {
articleNumber += 1 + Math.floor(random() * (opts.noise ?? 0))
if (random() < (opts.drop ?? 0)) continue
articles.push({ number, messageId: `${label}${volume}.${number}`, bytes: 700_000, articleNumber })
}
}
return articles
}
/** Source volume of an article, from its simulated message-id. */
const source = (a: SplitArticle) => a.messageId.split('.')[0]
/** Every result volume must contain articles of one source volume only. */
function assertPure(assert: any, volumes: SplitArticle[][]) {
for (const volume of volumes) {
assert.lengthOf(new Set(volume.map(source)), 1, `mixed volume: ${volume.map((a) => a.messageId).join(' ')}`)
}
}
test.group('split / splitInstances', () => {
test('separates sequentially posted volumes', ({ assert }) => {
const volumes = splitInstances(post({ volumes: 3, parts: 14 }), { totalParts: 14 })
assert.lengthOf(volumes, 3)
assertPure(assert, volumes)
assert.isTrue(volumes.every((v) => v.length === 14))
})
test('separates volumes posted over interleaved connections, out of order', ({ assert }) => {
for (const seed of [1, 2, 3, 4, 5]) {
const volumes = splitInstances(post({ volumes: 6, parts: 40, connections: 8, noise: 30, seed }), { totalParts: 40 })
assertPure(assert, volumes)
assert.lengthOf(volumes, 6, `seed ${seed}`)
}
})
test('a volume that completes is never mixed, even with heavy loss', ({ assert }) => {
for (const seed of [11, 12, 13, 14, 15, 16, 17, 18]) {
const articles = post({ volumes: 5, parts: 30, connections: 6, noise: 20, drop: 0.2, seed })
const volumes = splitInstances(articles, { totalParts: 30 })
assertPure(assert, volumes.filter((v) => v.length === 30))
}
})
test('a missing article only affects its own volume', ({ assert }) => {
const articles = post({ volumes: 4, parts: 20, connections: 4, drop: 0.05, seed: 7 })
const volumes = splitInstances(articles, { totalParts: 20 })
assertPure(assert, volumes)
assert.lengthOf(volumes, 4)
})
test('posts of the same size at different times do not mix', ({ assert }) => {
// An earlier post that lost articles (e.g. before the backfill window), then two more.
const articles = [
...post({ volumes: 3, parts: 14, connections: 4, drop: 0.3, label: 'a', start: 1_000, seed: 3 }),
...post({ volumes: 3, parts: 14, connections: 4, label: 'b', start: 200_000, seed: 4 }),
...post({ volumes: 2, parts: 14, connections: 2, label: 'c', start: 900_000, seed: 5 }),
]
const volumes = splitInstances(articles, { totalParts: 14 })
// The damaged post may produce mixed volumes, but those stay incomplete
// (they never become files); every volume that completes is pure.
const complete = volumes.filter((v) => v.length === 14)
assertPure(assert, complete)
assert.includeMembers(complete.map((v) => source(v[0])), ['b0', 'b1', 'b2', 'c0', 'c1'])
// The later, intact posts are not affected at all.
assertPure(assert, volumes.filter((v) => !source(v[0]).startsWith('a')))
})
test('ignores duplicate message-ids and does not depend on input order', ({ assert }) => {
const articles = post({ volumes: 2, parts: 5, connections: 3, seed: 9 })
const shuffled = [...articles].reverse().concat(articles[0])
assert.deepEqual(splitInstances(shuffled, { totalParts: 5 }), splitInstances(articles, { totalParts: 5 }))
})
})
/** Like post(), with real data per volume so part and file CRCs are genuine. */
function postWithCrcs(opts: PostOptions): SplitArticle[] {
const random = rng((opts.seed ?? 1) + 1000)
const size = 64
const data = new Map<string, Buffer>()
const articles = post(opts)
for (const a of articles) {
const volume = source(a)
if (!data.has(volume)) {
data.set(volume, Buffer.from(Array.from({ length: opts.parts * size }, () => Math.floor(random() * 256))))
}
const file = data.get(volume)!
a.pcrc32 = crc32Hex(file.subarray((a.number - 1) * size, a.number * size))
a.partSize = size
a.fileCrc32 = a.number === opts.parts ? crc32Hex(file) : null
}
return articles
}
test.group('split / verified', () => {
test('verifyVolume accepts a correct volume and rejects a mixed one', ({ assert }) => {
const articles = postWithCrcs({ volumes: 2, parts: 6 })
const volumeA = articles.filter((a) => source(a) === 'p0')
assert.isTrue(verifyVolume(volumeA, 6))
const mixed = volumeA.map((a) => (a.number === 3 ? articles.find((b) => source(b) === 'p1' && b.number === 3)! : a))
assert.isFalse(verifyVolume(mixed, 6))
assert.isFalse(verifyVolume(volumeA.slice(0, 5), 6))
})
test('only verified volumes come out, and they are never mixed', ({ assert }) => {
for (const seed of [21, 22, 23, 24, 25, 26]) {
const articles = [
...postWithCrcs({ volumes: 4, parts: 30, connections: 16, noise: 20, drop: 0.05, label: 'a', seed }),
...postWithCrcs({ volumes: 3, parts: 30, connections: 16, noise: 20, label: 'b', start: 300_000, seed: seed + 50 }),
]
for (const volume of splitVerified(articles, { totalParts: 30 })) {
assert.isTrue(verifyVolume(volume, 30))
assertPure(assert, [volume])
}
}
})
test('recovers every volume of a cleanly posted set', ({ assert }) => {
const volumes = splitVerified(postWithCrcs({ volumes: 5, parts: 20, connections: 4, noise: 10, seed: 31 }), { totalParts: 20 })
assert.lengthOf(volumes, 5)
})
})
+70
View File
@@ -0,0 +1,70 @@
import { test } from '@japa/runner'
import { deflateRawSync } from 'node:zlib'
import { decodeSpotBinary, parseSpotArticleHeaders, parseSpotHeader, parseSpotXml, verifySpotHeader } from '#lib/spotnet/index'
// A real spot from free.pt (self-signed, key 7), 2026-09-28.
const SPOT = {"from": "Bart <0RnzK11s9B4jqZdfc0UHj1qpSrgBw7nKaYBPckvJuk8sKchVXvdofY6qcKkZE-s9x.Fbc6Dm09v32bUgXpDT88QADBHELZ7wP4sMIMHKpQYxBIlK-pZveUFu0aMYUx-pUf6Z@17a09b03c07c10d17z00.21651233204.20.1790619886.1.NL.Uyf3JHAAlVvgAg9HKIu3veFDGC4DX9-sgTnchacVAkC-p3USalOe-sacdamKdgXirL6>", "subject": "The.Incredible.Shrinking.Woman.1981.1080p.BluRay.REMUX.AVC.FLAC.2.0-MuGi | Bart", "id": "<jWCbaAa4f1A7LC6agmOEx@spot.net>", "date": "Mon, 28 Sep 2026 18:24:45 GMT"}
test.group('spotnet / headers', () => {
test('parses a spot header', ({ assert }) => {
const spot = parseSpotHeader(SPOT.from, SPOT.subject, SPOT.id, SPOT.date)!
assert.include(spot, {
poster: 'Bart',
title: 'The.Incredible.Shrinking.Woman.1981.1080p.BluRay.REMUX.AVC.FLAC.2.0-MuGi',
tag: 'Bart',
category: 0,
keyId: 7,
size: 21651233204,
messageId: 'jWCbaAa4f1A7LC6agmOEx@spot.net',
})
assert.deepEqual(spot.subcats, { a: [9], b: [3], c: [7, 10], d: [17], z: [0] })
})
test('verifies its signatures, and rejects another message id', ({ assert }) => {
const spot = parseSpotHeader(SPOT.from, SPOT.subject, SPOT.id, SPOT.date)!
assert.isTrue(verifySpotHeader(spot))
assert.isFalse(verifySpotHeader({ ...spot, messageId: 'other@spot.net' }))
assert.isNull(parseSpotHeader('someone <x@y>', 'subject', '<a@b>'))
})
})
test.group('spotnet / xml and nzb', () => {
test('reads the NZB segments and category from the X-XML lines', ({ assert }) => {
const raw = [
'From: Bart <x@y>',
'X-XML: <Spotnet><Posting><Key>7</Key><Title>A &amp; B</Title><Category>01<Sub>01a09</Sub>',
'X-XML: <Sub>01z00</Sub></Category><NZB><Segment>abc@spot.net</Segment><Segment>def@spot.net</Segment></NZB>',
'X-XML: <Size>123</Size></Posting></Spotnet>',
].join('\r\n')
const { headers, xml } = parseSpotArticleHeaders(raw)
assert.equal(headers.from, 'Bart <x@y>')
const spot = parseSpotXml(xml)!
assert.deepEqual(spot.nzbSegments, ['abc@spot.net', 'def@spot.net'])
assert.include(spot, { title: 'A & B', category: 0, size: 123 })
assert.deepEqual(spot.subcats, ['01a09', '01z00'])
})
test('decodes an escaped, deflated NZB split over articles', ({ assert }) => {
const nzb = '<?xml version="1.0"?>\n<nzb>\r\n=\0</nzb>'.repeat(20)
const escaped = deflateRawSync(Buffer.from(nzb, 'latin1'))
.toString('latin1')
.replace(/[=\n\r\0]/g, (c) => ({ '=': '=D', '\n': '=C', '\r': '=B', '\0': '=A' })[c]!)
// Posted as lines, dot-stuffed, over two articles.
const lines = escaped.match(/.{1,40}/gs)!.map((l) => (l.startsWith('.') ? '.' + l : l))
const half = Math.ceil(lines.length / 2)
const bodies = [lines.slice(0, half), lines.slice(half)].map((ls) => Buffer.from(ls.join('\r\n'), 'latin1'))
assert.equal(decodeSpotBinary(bodies).toString('latin1'), nzb)
})
})
test.group('spotnet / moderation', () => {
test('recognises delete and dispose commands from key 2 only', async ({ assert }) => {
const { moderationTarget, spotterId } = await import('#lib/spotnet/index')
assert.equal(moderationTarget({ keyId: 2, title: 'DISPOSE 7IzfA0aCFTkOne6agvY06@spot.net - The Voice DE S15E07 (1080p)' }), '7IzfA0aCFTkOne6agvY06@spot.net')
assert.equal(moderationTarget({ keyId: 2, title: 'delete <abc@spot.net>' }), 'abc@spot.net')
assert.isNull(moderationTarget({ keyId: 7, title: 'DISPOSE abc@spot.net' }))
assert.isNull(moderationTarget({ keyId: 2, title: 'The Disposal 2024' }))
// Spotweb: base64 of the little-endian CRC32 of the key, without / + =.
assert.match(spotterId('0RnzK11s9B4jqZdfc0UHj1qpSrgBw7nKaYBPckvJuk8sKchVXvdofY6qcKkZE/9x'), /^[A-Za-z0-9]{6}$/)
})
})
+108
View File
@@ -0,0 +1,108 @@
import { test } from '@japa/runner'
import { fileKey, parseSubject } from '#lib/subject/index'
import { parseXrefGroups } from '#lib/nntp/xref'
import { inferTotalParts } from '#lib/yenc/index'
test.group('subject / parseSubject', () => {
test('parses file counter, filename and segment counter', ({ assert }) => {
assert.deepEqual(parseSubject('. - [09/51] - "obrMkrDeLmbhaouwYO34.part08.rar" yEnc (56/274)'), {
name: 'obrMkrDeLmbhaouwYO34.part08.rar',
segment: 56,
totalSegments: 274,
fileIndex: 9,
fileTotal: 51,
base: '. - [09/51] - "obrMkrDeLmbhaouwYO34.part08.rar" yEnc',
})
})
test('handles a file size after the segment counter', ({ assert }) => {
const subject = '[8/9] - "x.tar.zst.vol063+064.par2" yEnc (37/72) 51315352'
const parsed = parseSubject(subject)!
assert.equal(parsed.segment, 37)
assert.equal(parsed.totalSegments, 72)
assert.equal(parsed.base, '[8/9] - "x.tar.zst.vol063+064.par2" yEnc 51315352')
})
test('ignores counters inside the filename', ({ assert }) => {
const parsed = parseSubject('"Show (1/2) Special.mkv" yEnc (3/10)')!
assert.equal(parsed.name, 'Show (1/2) Special.mkv')
assert.equal(parsed.segment, 3)
assert.equal(parsed.totalSegments, 10)
})
test('the base is equal for all segments of one file', ({ assert }) => {
const a = parseSubject('[1/9] - "a.tar.zst" yEnc (253/1000) 716554110')!
const b = parseSubject('[1/9] - "a.tar.zst" yEnc (254/1000) 716554110')!
assert.equal(a.base, b.base)
})
test('returns null for obfuscated or invalid subjects', ({ assert }) => {
assert.isNull(parseSubject('GZFDJc0hB3Y0FwwAznkk'))
assert.isNull(parseSubject('"name.rar" yEnc'))
assert.isNull(parseSubject('"name.rar" yEnc (0/5)'))
assert.isNull(parseSubject('"name.rar" yEnc (6/5)'))
assert.isNull(parseSubject('"" yEnc (1/5)'))
})
})
test.group('subject / fileKey', () => {
test('differs per poster and per segment count, is stable otherwise', ({ assert }) => {
const key = fileKey('poster@a', '"x.rar" yEnc', 10)
assert.lengthOf(key, 40)
assert.equal(key, fileKey('poster@a', '"x.rar" yEnc', 10))
assert.notEqual(key, fileKey('poster@b', '"x.rar" yEnc', 10))
assert.notEqual(key, fileKey('poster@a', '"x.rar" yEnc', 11))
})
})
test.group('nntp / parseXrefGroups', () => {
test('extracts crossposted groups', ({ assert }) => {
assert.deepEqual(
parseXrefGroups('news.eweka.nl alt.binaries.nl:35801424104 alt.binaries.test:27275430913'),
['alt.binaries.nl', 'alt.binaries.test']
)
assert.deepEqual(parseXrefGroups(undefined), [])
})
})
test.group('yenc / inferTotalParts', () => {
const size = 104857600
const partSize = 384000
test('uses total= when present', ({ assert }) => {
assert.equal(inferTotalParts({ begin: { part: 2, total: 7, line: 128, size, name: 'a' } }), 7)
})
test('derives the total from the part size for middle and last parts', ({ assert }) => {
const expected = Math.ceil(size / partSize) // 274, as in the subject "(56/274)"
assert.equal(expected, 274)
assert.equal(
inferTotalParts({
begin: { part: 56, line: 128, size, name: 'a' },
part: { begin: 21120001, end: 21504000 },
}),
expected
)
assert.equal(
inferTotalParts({
begin: { part: 274, line: 128, size, name: 'a' },
part: { begin: 273 * partSize + 1, end: size },
}),
274
)
})
test('single-part posts have one part', ({ assert }) => {
assert.equal(inferTotalParts({ begin: { line: 128, size: 10, name: 'a' } }), 1)
})
})
test.group('indexer / skipBodyMessageIds', () => {
test('matches the multi-group uploader, not other posts', async ({ assert }) => {
const { default: indexerConfig } = await import('#config/indexer')
const skip = (id: string) => indexerConfig.skipBodyMessageIds.some((re) => re.test(id))
assert.isTrue(skip('<b800526$4db823e$6544066@d810c77fc0.d15fa>'))
assert.isFalse(skip('<Part56of274.4B8CB0C9360F4548AB800FEDCB39CE07@1790532933.local>'))
assert.isFalse(skip('<a6a525b170f44d68a756f1ba6fbd4c60@ngPost>'))
})
})
+192
View File
@@ -0,0 +1,192 @@
import { test } from '@japa/runner'
import yencode from 'yencode'
import {
YencCrcError,
YencError,
YencFile,
crc32Hex,
decodeSegment,
parseYencMeta,
} from '#lib/yenc/index'
/** Builds an article body as a yEnc poster would send it. */
function article(
data: Buffer,
opts: { name: string; fileSize?: number; part?: number; total?: number; begin?: number; fileCrc?: string }
) {
const fileSize = opts.fileSize ?? data.length
const encoded = yencode.encode(data).toString('latin1')
const lines: string[] = []
if (opts.part) {
lines.push(`=ybegin part=${opts.part} total=${opts.total} line=128 size=${fileSize} name=${opts.name}`)
const begin = opts.begin ?? 1
lines.push(`=ypart begin=${begin} end=${begin + data.length - 1}`)
lines.push(encoded)
const fileCrc = opts.fileCrc ? ` crc32=${opts.fileCrc}` : ''
lines.push(`=yend size=${data.length} part=${opts.part} pcrc32=${crc32Hex(data)}${fileCrc}`)
} else {
lines.push(`=ybegin line=128 size=${fileSize} name=${opts.name}`)
lines.push(encoded)
lines.push(`=yend size=${data.length} crc32=${crc32Hex(data)}`)
}
return Buffer.from(lines.join('\r\n'), 'latin1')
}
function randomBytes(length: number, seed = 1) {
const buf = Buffer.alloc(length)
let x = seed
for (let i = 0; i < length; i++) {
x = (x * 1103515245 + 12345) & 0x7fffffff
buf[i] = x & 0xff
}
return buf
}
test.group('yenc / parseYencMeta', () => {
test('parses a multi-part header as seen on alt.binaries.test', ({ assert }) => {
const body = Buffer.from(
'=ybegin part=56 line=128 size=104857600 name=obrMkrDeLmbhaouwYO34.part08.rar\r\n' +
'=ypart begin=21120001 end=21504000\r\n' +
'xyz\r\n' +
'=yend size=384000 part=56 pcrc32=B6339AFC',
'latin1'
)
const meta = parseYencMeta(body)
assert.deepEqual(meta.begin, {
part: 56,
total: undefined,
line: 128,
size: 104857600,
name: 'obrMkrDeLmbhaouwYO34.part08.rar',
})
assert.deepEqual(meta.part, { begin: 21120001, end: 21504000 })
assert.deepEqual(meta.end, { size: 384000, part: 56, pcrc32: 'b6339afc', crc32: undefined })
assert.equal(body.subarray(meta.dataStart, meta.dataEnd).toString(), 'xyz')
})
test('keeps spaces and "=" in the filename', ({ assert }) => {
const body = article(Buffer.from('hi'), { name: 'My File = v2 (final).nfo' })
assert.equal(parseYencMeta(body).begin.name, 'My File = v2 (final).nfo')
})
test('ignores leading blank lines before =ybegin', ({ assert }) => {
const body = Buffer.concat([Buffer.from('\r\n\r\n'), article(Buffer.from('hi'), { name: 'a.txt' })])
assert.equal(parseYencMeta(body).begin.name, 'a.txt')
})
test('rejects bodies without yEnc markers', ({ assert }) => {
assert.throws(() => parseYencMeta(Buffer.from('plain text\r\n')), /=ybegin/)
assert.throws(() => parseYencMeta(Buffer.from('=ybegin line=128 size=2 name=a\r\nxx')), /=yend/)
})
})
test.group('yenc / decodeSegment', () => {
test('round-trips a single-part post and checks crc32', ({ assert }) => {
const data = randomBytes(5000)
const segment = decodeSegment(article(data, { name: 'single.bin' }))
assert.isTrue(segment.data.equals(data))
assert.equal(segment.offset, 0)
})
test('throws YencCrcError on a corrupted part', ({ assert }) => {
const body = article(randomBytes(1000), { name: 'x.bin', part: 1, total: 1 })
const index = body.indexOf('\r\n=ypart') + 60
body[index] = body[index] === 0x41 ? 0x42 : 0x41
assert.throws(() => decodeSegment(body), YencCrcError as any)
})
test('undoes NNTP dot-stuffing', ({ assert }) => {
// Byte 4 encodes to "." (4 + 42 = 46). An unescaped leading "." is dot-stuffed to "..".
const data = Buffer.from([4, 5, 6])
const body = Buffer.from(
`=ybegin line=128 size=3 name=dot.bin\r\n../0\r\n=yend size=3 crc32=${crc32Hex(data)}`,
'latin1'
)
assert.isTrue(decodeSegment(body).data.equals(data))
assert.throws(() => decodeSegment(body, { stripDots: false }), YencError as any)
})
})
test.group('yenc / YencFile', () => {
test('assembles parts added out of order and verifies the file crc', ({ assert }) => {
const file = randomBytes(10_000, 7)
const chunk = 3000
const total = Math.ceil(file.length / chunk)
const bodies = Array.from({ length: total }, (_, i) =>
article(file.subarray(i * chunk, (i + 1) * chunk), {
name: 'multi.rar',
fileSize: file.length,
part: i + 1,
total,
begin: i * chunk + 1,
fileCrc: i === total - 1 ? crc32Hex(file) : undefined,
})
)
const segments = bodies.map((body) => decodeSegment(body)).reverse()
const yencFile = YencFile.fromSegment(segments[0])
assert.equal(yencFile.totalParts, 4)
for (const segment of segments.slice(0, -1)) yencFile.add(segment)
assert.isFalse(yencFile.isComplete)
assert.deepEqual(yencFile.missingParts(), [1])
assert.throws(() => yencFile.getBuffer(), /missing parts: 1/)
yencFile.add(segments.at(-1)!)
yencFile.add(segments[0]) // duplicate is ignored
assert.isTrue(yencFile.isComplete)
assert.isTrue(yencFile.getBuffer().equals(file))
})
test('rejects segments of a file with another size', ({ assert }) => {
const a = decodeSegment(article(Buffer.from('aa'), { name: 'a.bin' }))
const b = decodeSegment(article(Buffer.from('bbb'), { name: 'b.bin' }))
assert.throws(() => YencFile.fromSegment(a).add(b), /does not belong to "a.bin"/)
})
test('accepts segments with per-article random names', ({ assert }) => {
const file = randomBytes(4000, 3)
const [x, y] = [0, 1].map((i) =>
decodeSegment(
article(file.subarray(i * 2000, (i + 1) * 2000), {
name: i === 0 ? 'Xq8r2Kd' : 'p0Lm3Zw',
fileSize: 4000,
part: i + 1,
total: 2,
begin: i * 2000 + 1,
})
)
)
const assembled = YencFile.fromSegment(x)
assembled.add(x)
assembled.add(y)
assert.isTrue(assembled.getBuffer().equals(file))
})
})
test.group('content / firstChunkMd5', () => {
test('hashes the first 16 KiB of the decoded first part', async ({ assert }) => {
const { firstChunkMd5 } = await import('#services/ContentService')
const { createHash } = await import('node:crypto')
const file = randomBytes(40_000, 5)
const body = article(file.subarray(0, 20_000), { name: 'x.bin', fileSize: 40_000, part: 1, total: 2 })
assert.equal(firstChunkMd5(body, 40_000), createHash('md5').update(file.subarray(0, 16384)).digest('hex'))
// A first part shorter than 16 KiB of a larger file cannot give the fingerprint.
const short = article(file.subarray(0, 1000), { name: 'x.bin', fileSize: 40_000, part: 1, total: 40 })
assert.isUndefined(firstChunkMd5(short, 40_000))
})
})
test.group('yenc / YencFile without total=', () => {
test('infers the part count when =ybegin has no total', ({ assert }) => {
const file = randomBytes(5000, 11)
const strip = (body: Buffer) => Buffer.from(body.toString('latin1').replace(/ total=\d+/, ''), 'latin1')
const parts = [0, 1].map((i) =>
decodeSegment(strip(article(file.subarray(i * 3000, (i + 1) * 3000), { name: 'n.nzb', fileSize: 5000, part: i + 1, total: 2, begin: i * 3000 + 1 })))
)
const assembled = YencFile.fromSegment(parts[0])
assert.equal(assembled.totalParts, 2)
parts.forEach((p) => assembled.add(p))
assert.isTrue(assembled.getBuffer().equals(file))
})
})