Put known passwords in the NZB and show them
release_passwords (from spots: their NZB meta, title or text) was only read by the judge. The NZB now carries the most trusted one as <meta type="password">, which SABnzbd and NZBGet use to unpack; a spot's own NZB gets it added when its meta has none. The release details list all known passwords with their source, the list marks "Password known", and the web interface shows them instead of "not in the index". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d446bb57df
commit
f75097d7ac
5 files changed
+83
-6
No files matched your search
@@ -3,13 +3,24 @@ import db from '@adonisjs/lucid/services/db'
|
||||
import { decodeChunk, type ChunkFile } from '#lib/chunks/index'
|
||||
import { buildNzb } from '#lib/nzb/index'
|
||||
|
||||
/** Where a known password came from, the most trusted first: the spot's NZB meta, its title, its text. */
|
||||
const PASSWORD_SOURCES = ['nzb', 'spot-title', 'spot-text']
|
||||
|
||||
/**
|
||||
* A release's files and NZB (REBUILD.md, part 9), from its chunks in the
|
||||
* chunk store: every chunk decoded, its files in name order. A file stored
|
||||
* twice (the same name and first article, e.g. a range read again) is taken
|
||||
* once.
|
||||
* once. A known password (release_passwords) goes into the NZB's meta, where
|
||||
* SABnzbd and NZBGet take it for unpacking.
|
||||
*/
|
||||
class NzbService {
|
||||
/** The release's known passwords, the most trusted first. */
|
||||
async passwords(releaseId: number): Promise<{ password: string; source: string }[]> {
|
||||
const rows = await db.from('release_passwords').where('release_id', releaseId).orderBy('created_at').select('password', 'source')
|
||||
const rank = (source: string) => (PASSWORD_SOURCES.indexOf(source) + PASSWORD_SOURCES.length + 1) % (PASSWORD_SOURCES.length + 1)
|
||||
return rows.sort((a, b) => rank(a.source) - rank(b.source)).map((r) => ({ password: r.password, source: r.source }))
|
||||
}
|
||||
|
||||
async files(releaseId: number): Promise<ChunkFile[]> {
|
||||
const chunks = await db.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload')
|
||||
const files = new Map<string, ChunkFile>()
|
||||
@@ -22,10 +33,11 @@ class NzbService {
|
||||
async build(releaseId: number): Promise<{ name: string; xml: string } | null> {
|
||||
const release = await db.from('releases').where('id', releaseId).select('name', 'source').first()
|
||||
if (!release) return null
|
||||
const password = (await this.passwords(releaseId))[0]?.password
|
||||
// A release a spot made of its own (no files matched the header pass's): the spot's NZB as read.
|
||||
if (release.source === 'spot') {
|
||||
const spot = await db.from('spots').where('release_id', releaseId).whereNotNull('nzb').select('nzb').first()
|
||||
if (spot) return { name: release.name, xml: gunzipSync(spot.nzb).toString('utf8') }
|
||||
if (spot) return { name: release.name, xml: withPassword(gunzipSync(spot.nzb).toString('utf8'), password) }
|
||||
}
|
||||
const xml = buildNzb(
|
||||
(await this.files(releaseId)).map((f) => ({
|
||||
@@ -35,10 +47,18 @@ class NzbService {
|
||||
groups: f.groups,
|
||||
segments: f.segments.map(([number, id, size]) => ({ number, id, size })),
|
||||
})),
|
||||
{ title: release.name }
|
||||
{ title: release.name, password }
|
||||
)
|
||||
return { name: release.name, xml }
|
||||
}
|
||||
}
|
||||
|
||||
/** A read NZB with the password in its meta, when it has none (the spot's title or text named it, not its NZB). */
|
||||
export function withPassword(xml: string, password: string | undefined): string {
|
||||
if (!password || /<meta\s+type=["']password["']/i.test(xml)) return xml
|
||||
const meta = `<meta type="password">${password.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</meta>`
|
||||
if (/<head\b[^>]*>/i.test(xml)) return xml.replace(/<head\b[^>]*>/i, (head) => `${head}${meta}`)
|
||||
return xml.replace(/<nzb\b[^>]*>/i, (nzb) => `${nzb}<head>${meta}</head>`)
|
||||
}
|
||||
|
||||
export default new NzbService()
|
||||
@@ -57,6 +57,8 @@ export interface ReleaseSummary {
|
||||
settled: boolean
|
||||
/** The prober found encrypted archive entries or headers; null: not probed (yet). */
|
||||
passworded: boolean | null
|
||||
/** A password is known (release_passwords): it's in the NZB. */
|
||||
passwordKnown: boolean
|
||||
/** What the prober found inside (an archive entry, a par2 set's file, a yEnc name), when it says more than the name. */
|
||||
innerName: string | null
|
||||
}
|
||||
@@ -163,6 +165,7 @@ class SearchService {
|
||||
const [{ rows }, { rows: counted }] = await Promise.all([
|
||||
db.rawQuery(
|
||||
`SELECT r.id, r.name, r.source, r.poster, r.groups, r.posted_at, r.file_total, r.files, r.bytes, r.readable, r.complete, r.category, r.settled_at, r.passworded, r.inner_name,
|
||||
EXISTS (SELECT 1 FROM release_passwords p WHERE p.release_id = r.id) AS password_known,
|
||||
release_final(r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete) AS final
|
||||
FROM ${source.from} WHERE ${source.where} ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||
[...source.bindings, ...bindings, perPage, offset]
|
||||
@@ -192,6 +195,7 @@ class SearchService {
|
||||
category: r.category,
|
||||
settled: r.settled_at !== null,
|
||||
passworded: r.passworded,
|
||||
passwordKnown: r.password_known,
|
||||
innerName: r.inner_name,
|
||||
})),
|
||||
}
|
||||
@@ -201,10 +205,11 @@ class SearchService {
|
||||
async release(id: number) {
|
||||
const r = await db.from('releases').where('id', id).first()
|
||||
if (!r) return null
|
||||
const [files, { rows: inspections }, { rows: finality }] = await Promise.all([
|
||||
const [files, { rows: inspections }, { rows: finality }, passwords] = await Promise.all([
|
||||
NzbService.files(id),
|
||||
db.rawQuery(`SELECT kind, result, created_at FROM release_inspections WHERE release_id = ? ORDER BY candidate_id`, [id]),
|
||||
db.rawQuery(`SELECT release_final(?, ?, ?, ?, ?, ?) AS final`, [r.group_id, r.first_article, r.last_article, r.posted_at, r.last_posted_at, r.complete]),
|
||||
NzbService.passwords(id),
|
||||
])
|
||||
return {
|
||||
id: Number(r.id),
|
||||
@@ -226,6 +231,8 @@ class SearchService {
|
||||
lastSeenAt: r.last_seen_at,
|
||||
settledAt: r.settled_at,
|
||||
passworded: r.passworded,
|
||||
/** Known passwords (spots, their NZBs), the most trusted first; the first is in the NZB. */
|
||||
passwords,
|
||||
innerName: r.inner_name,
|
||||
inspectedAt: r.inspected_at,
|
||||
/** What the prober read from the release's articles (lib/probe Inspection), by probe kind. */
|
||||
|
||||
+14
-2
@@ -17,6 +17,12 @@ const SOURCE_LABELS = {
|
||||
subject: 'Subject',
|
||||
post: 'Post id',
|
||||
}
|
||||
/** Where a known password came from (release_passwords.source). */
|
||||
const PASSWORD_SOURCES = {
|
||||
nzb: "from the spot's NZB",
|
||||
'spot-title': "from the spot's title",
|
||||
'spot-text': "from the spot's text",
|
||||
}
|
||||
/** Newznab categories (lib/newznab/categories.ts). */
|
||||
const CATEGORIES = [
|
||||
[2000, 'Movies', [[2030, 'SD'], [2040, 'HD'], [2045, 'UHD']]],
|
||||
@@ -220,7 +226,8 @@ function releaseRow(release) {
|
||||
if (release.complete) badges.append(badge(files, 'good', 'Complete'))
|
||||
else if (release.fileTotal) badges.append(badge(`${release.fileCount}/${release.fileTotal}`, 'warn', 'Files present / announced'))
|
||||
else badges.append(badge(files))
|
||||
if (release.passworded) badges.append(badge('Password', 'bad', 'Its archive is encrypted: a password is needed'))
|
||||
if (release.passworded && release.passwordKnown) badges.append(badge('Password known', 'warn', 'Its archive is encrypted; the password is in the NZB'))
|
||||
else if (release.passworded) badges.append(badge('Password', 'bad', 'Its archive is encrypted: a password is needed'))
|
||||
if (!release.settled) badges.append(badge('Collecting', 'accent', 'Recently seen: more files may still arrive'))
|
||||
if (release.category && release.category !== 8010) badges.append(badge(categoryLabel(release.category), '', 'Category (from the name)'))
|
||||
if (release.source && release.source !== 'stem' && release.source !== 'subject') {
|
||||
@@ -347,7 +354,12 @@ function renderDetail(release) {
|
||||
if (!release.settledAt) {
|
||||
notices.push(el('div', { class: 'notice notice-warn', text: 'Recently seen: more files of this release may still arrive. The NZB has what is indexed so far.' }))
|
||||
}
|
||||
if (release.passworded) {
|
||||
const passwords = release.passwords ?? []
|
||||
if (passwords.length) {
|
||||
notices.push(el('div', { class: 'notice notice-warn' },
|
||||
el('span', { text: `${passwords.length === 1 ? 'Password' : 'Passwords'} (the first is in the NZB): ` }),
|
||||
...passwords.map((p, i) => el('span', {}, i ? ', ' : '', el('code', { text: p.password }), ` (${PASSWORD_SOURCES[p.source] ?? p.source})`))))
|
||||
} else if (release.passworded) {
|
||||
notices.push(el('div', { class: 'notice notice-bad', text: 'Passworded: its archive has encrypted entries or headers. The password is not in the index.' }))
|
||||
}
|
||||
if (!release.readable && !release.innerName) {
|
||||
|
||||
@@ -113,4 +113,27 @@ test.group('Releases API', (group) => {
|
||||
assert.include(feed, '<title>Hidden.Gem.2023</title>')
|
||||
assert.match(feed, /Other Movie 2024[^]*?name="password" value="1"/)
|
||||
})
|
||||
|
||||
test('a known password: in the NZB (the most trusted source first), the details and the list', async ({ client, assert }) => {
|
||||
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||
const [n] = await db.table('nodes').insert({ name: 'api-node' }).returning('id')
|
||||
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: n.id }).returning('id')
|
||||
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
|
||||
indexer.add(file('Locked.Movie.2024.1080p.BluRay.x264.part1.rar', 'Locked.Movie [1/1]', 2, 100), 1001)
|
||||
indexer.finish()
|
||||
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
|
||||
const movie = await db.from('releases').firstOrFail()
|
||||
await db.from('releases').where('id', movie.id).update({ passworded: true })
|
||||
await db.table('release_passwords').multiInsert([
|
||||
{ release_id: movie.id, password: 'from-text', source: 'spot-text' },
|
||||
{ release_id: movie.id, password: 'from-nzb', source: 'nzb' },
|
||||
])
|
||||
|
||||
const nzb = parseNzb((await client.get(`/api/v1/releases/${movie.id}/nzb`)).text())!
|
||||
assert.equal(nzb.meta.password, 'from-nzb')
|
||||
const detail = (await client.get(`/api/v1/releases/${movie.id}`)).body() as any
|
||||
assert.deepEqual(detail.passwords, [{ password: 'from-nzb', source: 'nzb' }, { password: 'from-text', source: 'spot-text' }])
|
||||
const list = (await client.get('/api/v1/releases?q=locked movie')).body() as { releases: { passwordKnown: boolean }[] }
|
||||
assert.isTrue(list.releases[0].passwordKnown)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { test } from '@japa/runner'
|
||||
import { withPassword } from '#services/nzb_service'
|
||||
import { parseNzb } from '#lib/nzb/index'
|
||||
|
||||
const NZB = (head: string) =>
|
||||
`<?xml version="1.0" encoding="UTF-8"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">${head}<file poster="p" date="1" subject=""a.rar" yEnc (1/1)"><groups><group>a.b.x</group></groups><segments><segment bytes="1" number="1">a@x</segment></segments></file></nzb>`
|
||||
|
||||
test.group('nzb / a read NZB with the password', () => {
|
||||
test('added into its head, or a head made; one it has already is kept', ({ assert }) => {
|
||||
assert.equal(parseNzb(withPassword(NZB('<head><meta type="name">x</meta></head>'), 'p<&>w'))!.meta.password, 'p<&>w')
|
||||
assert.equal(parseNzb(withPassword(NZB(''), 'pw'))!.meta.password, 'pw')
|
||||
assert.equal(parseNzb(withPassword(NZB('<head><meta type="password">own</meta></head>'), 'pw'))!.meta.password, 'own')
|
||||
assert.equal(withPassword(NZB(''), undefined), NZB(''))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user