Files
usenet-indexer/app/controllers/newznab_controller.ts
T
Daan MeijerandClaude Opus 5.5 92480174b9
Test and deploy / test (push) Successful in 48s
Test and deploy / deploy (push) Successful in 2m27s
Search and the web interface use the prober's findings
- Search matches a word in the name or the inner name (trigram index on
  inner_name, partial, built concurrently); "Readable names" includes
  releases with an inner name.
- unlocked=1 (chip "No password") leaves out passworded releases.
- Results carry passworded and innerName: a Password badge, the inner
  name under the release name.
- The release view: Inside and Inspected facts, a passworded notice, and
  Contents (per probed article its yEnc name, kind, type, encryption and
  the names inside) from release_inspections.
- Newznab: the password attribute from the prober; an obfuscated release
  with an inner name is included, titled by it (without its extension).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:33:23 +02:00

117 lines
4.6 KiB
TypeScript

import type { HttpContext } from '@adonisjs/core/http'
import env from '#start/env'
import NzbService from '#services/nzb_service'
import SearchService from '#services/search_service'
import { capsXml, errorXml, expandCategories, resultsXml, type NewznabServer } from '#lib/newznab/index'
const LIMITS = { max: 100, default: 50 }
/** Search functions and the category each defaults to when `cat` is absent. */
const SEARCH_FUNCTIONS: Record<string, number | undefined> = {
'search': undefined,
'tvsearch': 5000,
'tv-search': 5000,
'movie': 2000,
'movie-search': 2000,
'music': 3000,
'audio': 3000,
'book': 7000,
}
/** The configured key, if any. */
function configuredApiKey(): string | undefined {
const value: any = env.get('NEWZNAB_API_KEY')
const key = typeof value === 'string' ? value : value?.release()
return key || undefined
}
function int(value: unknown): number | undefined {
return typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : undefined
}
/**
* Newznab API for Prowlarr, Sonarr, Radarr and the like (from voyager 1):
* GET /api?t=caps|search|tvsearch|movie|music|book|get&apikey=…
* Releases with readable names (or an inner name from the prober, which then is the title) that aren't known to miss files.
*/
export default class NewznabController {
async handle({ request, response }: HttpContext) {
const qs = request.qs()
const t = String(qs.t ?? '')
const server = this.server(request)
const xml = (body: string, type = 'application/xml') => {
response.header('Content-Type', `${type}; charset=utf-8`)
return response.send(body)
}
const apiKey = configuredApiKey()
if (apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials'))
if (t === 'caps') return xml(capsXml(server, LIMITS))
if (t === 'get') {
const id = int(qs.id)
if (id === undefined) return xml(errorXml(200, 'Missing parameter (id)'))
const nzb = await NzbService.build(id)
if (!nzb) return xml(errorXml(300, 'No such item'))
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
response.header('Content-Disposition', `attachment; filename="${filename}"`)
return xml(nzb.xml, 'application/x-nzb')
}
if (!(t in SEARCH_FUNCTIONS)) return xml(errorXml(202, `No such function (${t || 'none'})`))
const requested = String(qs.cat ?? '')
.split(',')
.map((c) => int(c.trim()))
.filter((c): c is number => c !== undefined)
const fallback = SEARCH_FUNCTIONS[t]
const categories = expandCategories(requested.length ? requested : fallback ? [fallback] : [])
const limit = Math.min(int(qs.limit) ?? LIMITS.default, LIMITS.max)
const offset = int(qs.offset) ?? 0
// ID-only lookups (tvdbid, imdbid, …) aren't supported: an empty result rather than everything.
const q = typeof qs.q === 'string' ? qs.q.trim() : ''
const idOnly = !q && ['tvdbid', 'tvmazeid', 'rid', 'imdbid', 'tmdbid'].some((p) => qs[p])
const result = idOnly
? null
: await SearchService.search({
q,
categories,
readable: true,
notIncomplete: true,
maxAgeDays: int(qs.maxage),
season: int(qs.season),
episode: int(qs.season) !== undefined ? int(qs.ep) : undefined,
sort: 'newest',
perPage: limit,
offset,
})
const items = (result?.releases ?? []).map((r) => {
const tv = /(?:^|[._ -])S(\d{1,2})E(\d{1,3})/i.exec(r.name)
return {
id: r.id,
// An obfuscated release by what the prober found inside, without its extension (what clients parse).
title: r.readable || !r.innerName ? r.name : r.innerName.replace(/\.[a-z0-9]{2,4}$/i, ''),
category: r.category ?? 8010,
size: r.size,
files: r.fileCount,
postedAt: r.postedAt ? new Date(r.postedAt) : null,
poster: r.poster,
groups: r.groups ?? [],
// From the prober (phase 2): encrypted archive entries or headers.
password: r.passworded === true,
season: tv ? `S${tv[1].padStart(2, '0')}` : undefined,
episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined,
}
})
return xml(resultsXml(server, items, offset, result?.total ?? 0), 'application/rss+xml')
}
private server(request: HttpContext['request']): NewznabServer {
const proto = request.header('x-forwarded-proto') ?? 'http'
const host = request.header('x-forwarded-host') ?? request.header('host') ?? 'localhost'
return { title: 'Voyager 2', baseUrl: `${proto}://${host}`, apiKey: configuredApiKey() }
}
}