- /api (Newznab: caps, search, tvsearch, movie, music, book, get) for Prowlarr, Sonarr and Radarr: voyager 1's controller and XML on the new catalog; readable releases not known to miss files; NEWZNAB_API_KEY. - /api/v1/releases (q, cat, readable, complete, source, poster, size, sort, paging), /api/v1/releases/:id with its files (decoded from the chunks; NzbService.files, shared with the NZB), /api/v1/stats (estimates, plus history and tail progress), /health. Open, as voyager 1's catalog; the web interface uses it. - release_category(name): voyager 1's categorizer without the archive contents, a generated column with a (category, posted_at) index. - @adonisjs/static for the web interface (next). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
4.3 KiB
TypeScript
116 lines
4.3 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 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() }
|
|
}
|
|
}
|