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 AccessService, { AccessDenied } from '#services/access_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 (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(ctx: HttpContext) { const xml = (body: string, type = 'application/xml') => { ctx.response.header('Content-Type', `${type}; charset=utf-8`) return ctx.response.send(body) } try { return await this.answer(ctx, xml) } catch (error) { if (error instanceof AccessDenied) return xml(errorXml(error.newznabCode, error.message)) throw error } } private async answer(ctx: HttpContext, xml: (body: string, type?: string) => void) { const { request, response } = ctx const qs = request.qs() const t = String(qs.t ?? '') // With accounts the key is a user's (AccessService); else the one configured, if any. const grant = await AccessService.authorize(ctx, qs.apikey) const apiKey = grant ? grant.user.apiKey : configuredApiKey() if (!grant && apiKey && qs.apikey !== apiKey) return xml(errorXml(100, 'Incorrect user credentials')) const server = this.server(request, apiKey) if (t === 'caps') return xml(capsXml(server, LIMITS, { available: AccessService.accounts && AccessService.signup !== 'closed', open: AccessService.accounts && AccessService.signup === 'open' })) if (t === 'get') { const id = int(qs.id) if (id === undefined) return xml(errorXml(200, 'Missing parameter (id)')) // Unknown ids and releases past the tier's retention aren't counted. if (!(await AccessService.checkRelease(grant, id))) return xml(errorXml(300, 'No such item')) await AccessService.charge(grant, 'grab') 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'})`)) await AccessService.charge(grant, 'api') 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: AccessService.maxAgeDays(grant, 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, } }) // What's left of the day, for clients that show it (newznab:apilimits). const limits = grant && { apiCurrent: grant.usage.apiHits, apiMax: grant.limits.apiHits, grabCurrent: grant.usage.grabs, grabMax: grant.limits.grabs, } return xml(resultsXml(server, items, offset, result?.total ?? 0, limits || undefined), 'application/rss+xml') } /** Download links carry the caller's key. */ private server(request: HttpContext['request'], apiKey: string | undefined): 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 } } }