implemented a lot of features
This commit is contained in:
1 parent
62f1767df3
commit
bcb9be5da9
141 files changed
+10705
-385
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import env from '#start/env'
|
||||
import SearchService from '#services/SearchService'
|
||||
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 (a Secret from .env, or a plain string set at runtime). */
|
||||
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 (CONCEPT.md D23) for Prowlarr, Sonarr, Radarr and the like:
|
||||
* GET /api?t=caps|search|tvsearch|movie|music|book|get&apikey=…
|
||||
*/
|
||||
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 SearchService.nzb(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, …) are not supported: caps says so, but
|
||||
// answer with 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
|
||||
? { total: 0, releases: [] }
|
||||
: await SearchService.search({
|
||||
q,
|
||||
categories,
|
||||
readable: true,
|
||||
notIncomplete: true,
|
||||
hideContainers: 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: any) => {
|
||||
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 ?? [],
|
||||
password: Boolean(r.encrypted),
|
||||
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), '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: 'Usenet Indexer',
|
||||
baseUrl: `${proto}://${host}`,
|
||||
apiKey: configuredApiKey(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import SearchService, { type SortOrder } from '#services/SearchService'
|
||||
|
||||
const flag = (value: unknown) => value === '1' || value === 'true' || value === true
|
||||
|
||||
export default class ReleasesController {
|
||||
/** GET /api/v1/releases?q=&readable=1&complete=1&hide_encrypted=1&source=&poster=&sort=&page=&per_page= */
|
||||
async index({ request }: HttpContext) {
|
||||
const qs = request.qs()
|
||||
return SearchService.search({
|
||||
q: typeof qs.q === 'string' ? qs.q : undefined,
|
||||
readable: flag(qs.readable),
|
||||
complete: flag(qs.complete),
|
||||
hideEncrypted: flag(qs.hide_encrypted),
|
||||
includePar2Only: flag(qs.par2_only),
|
||||
source: typeof qs.source === 'string' && qs.source ? qs.source : undefined,
|
||||
poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : undefined,
|
||||
sort: qs.sort as SortOrder | undefined,
|
||||
page: Number(qs.page) || 1,
|
||||
perPage: Number(qs.per_page) || 50,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /api/v1/releases/:id */
|
||||
async show({ params, response }: HttpContext) {
|
||||
const release = await SearchService.release(Number(params.id))
|
||||
return release ?? response.notFound({ error: 'Release not found' })
|
||||
}
|
||||
|
||||
/** GET /api/v1/releases/:id/nzb */
|
||||
async nzb({ params, response }: HttpContext) {
|
||||
const nzb = await SearchService.nzb(Number(params.id))
|
||||
if (!nzb) return response.notFound({ error: 'Release not found' })
|
||||
const filename = `${nzb.name.replace(/[^\w.\-()[\] ]+/g, '_').slice(0, 180) || 'release'}.nzb`
|
||||
response.header('Content-Type', 'application/x-nzb')
|
||||
response.header('Content-Disposition', `attachment; filename="${filename}"`)
|
||||
return response.send(nzb.xml)
|
||||
}
|
||||
|
||||
/** GET /api/v1/stats */
|
||||
async stats() {
|
||||
return SearchService.stats()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user