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 = { '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 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, title: r.name, category: r.category ?? 8010, size: r.size, files: r.fileCount, postedAt: r.postedAt ? new Date(r.postedAt) : null, poster: r.poster, groups: r.groups ?? [], // Passwords come with phase 2 (archive inspection). password: false, 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() } } }