implemented a lot of features
This commit is contained in:
1 parent
62f1767df3
commit
bcb9be5da9
141 files changed
+10705
-385
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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&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}&apikey=test-key`)
|
||||
} finally {
|
||||
env.set('NEWZNAB_API_KEY', previous as any)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 "Nachbe" Xq8r2KdLm3Zw91PqA7.part1.rar" 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=""Xq8r2KdLm3Zw91PqA7.part2.rar" 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=""${id}.flac" 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())
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(), '"Webtest.Show.S01E02.1080p.mkv" 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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user