ACCESS=accounts (config/access.ts; open stays the default and behaves as before): Newznab and the catalog's releases routes name a user (apikey or the web interface's token), whose tier sets searches and NZBs per UTC day (Newznab 500/501, the catalog 429) and how far back they see (102/403). Counted in one upsert per use, so the last one can't be taken twice; caps, stats and refused requests aren't counted. Signup takes an invite (SIGNUP), which may give a tier; ace access:invite, access:tier (extends a running tier), access:suspend. The web interface signs in and shows the day's usage and the Newznab key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
5.3 KiB
TypeScript
128 lines
5.3 KiB
TypeScript
import { create } from 'xmlbuilder2'
|
|
import { CATEGORIES, categoryLabel, parentCategory } from './categories.js'
|
|
|
|
const NEWZNAB_NS = 'http://www.newznab.com/DTD/2010/feeds/attributes/'
|
|
|
|
export interface NewznabItem {
|
|
id: number
|
|
title: string
|
|
category: number
|
|
size: number
|
|
files: number
|
|
postedAt: Date | null
|
|
poster: string | null
|
|
groups: string[]
|
|
password: boolean
|
|
/** Longer text than the title, e.g. a Spotnet spot's description (UBB markup). */
|
|
description?: string
|
|
season?: string
|
|
episode?: string
|
|
}
|
|
|
|
export interface NewznabServer {
|
|
title: string
|
|
/** Absolute base URL, e.g. http://homelab:3333 */
|
|
baseUrl: string
|
|
/** Appended to download links so a client can fetch without extra config. */
|
|
apiKey?: string
|
|
}
|
|
|
|
/** Newznab error codes: 100 credentials, 101 suspended, 102 privileges, 200/201 parameters, 202 function, 300 item, 500/501 request/download limit. */
|
|
/** Spotnet UBB markup (or the HTML of old spots) as plain text: [br] as line breaks, other tags dropped. */
|
|
export function ubbToText(ubb: string): string {
|
|
return ubb
|
|
.replace(/<br\s*\/?>|\[br\]/gi, '\n')
|
|
.replace(/<\/?[a-z][^>]*>/gi, '')
|
|
.replace(/\[url=([^\]]*)\]([\s\S]*?)\[\/url\]/gi, (_, url: string, label: string) => (label.trim() ? `${label} (${url.trim()})` : url.trim()))
|
|
.replace(/\[\/?[a-z]+(?:=[^\]]*)?\]/gi, '')
|
|
.trim()
|
|
}
|
|
|
|
export function errorXml(code: number, description: string): string {
|
|
return create({ version: '1.0', encoding: 'UTF-8' })
|
|
.ele('error', { code: String(code), description })
|
|
.end({ prettyPrint: true })
|
|
}
|
|
|
|
/** A caller's day so far and its limits (null: none), as nZEDb-style indexers report them. */
|
|
export interface ApiLimits {
|
|
apiCurrent: number
|
|
apiMax: number | null
|
|
grabCurrent: number
|
|
grabMax: number | null
|
|
}
|
|
|
|
export function capsXml(
|
|
server: NewznabServer,
|
|
limits: { max: number; default: number },
|
|
registration = { available: false, open: false }
|
|
): string {
|
|
const caps = create({ version: '1.0', encoding: 'UTF-8' }).ele('caps')
|
|
caps.ele('server', { version: '1.0', title: server.title, strapline: 'Usenet indexer', url: `${server.baseUrl}/` })
|
|
caps.ele('limits', { max: String(limits.max), default: String(limits.default) })
|
|
caps.ele('registration', { available: registration.available ? 'yes' : 'no', open: registration.open ? 'yes' : 'no' })
|
|
const searching = caps.ele('searching')
|
|
searching.ele('search', { available: 'yes', supportedParams: 'q' })
|
|
searching.ele('tv-search', { available: 'yes', supportedParams: 'q,season,ep' })
|
|
searching.ele('movie-search', { available: 'yes', supportedParams: 'q' })
|
|
searching.ele('audio-search', { available: 'yes', supportedParams: 'q' })
|
|
searching.ele('book-search', { available: 'yes', supportedParams: 'q' })
|
|
const categories = caps.ele('categories')
|
|
for (const category of CATEGORIES) {
|
|
const element = categories.ele('category', { id: String(category.id), name: category.name })
|
|
for (const sub of category.subcats) element.ele('subcat', { id: String(sub.id), name: sub.name })
|
|
}
|
|
return caps.end({ prettyPrint: true })
|
|
}
|
|
|
|
export function resultsXml(server: NewznabServer, items: NewznabItem[], offset: number, total: number, limits?: ApiLimits): string {
|
|
const rss = create({ version: '1.0', encoding: 'UTF-8' }).ele('rss', {
|
|
'version': '2.0',
|
|
'xmlns:atom': 'http://www.w3.org/2005/Atom',
|
|
'xmlns:newznab': NEWZNAB_NS,
|
|
})
|
|
const channel = rss.ele('channel')
|
|
channel.ele('title').txt(server.title)
|
|
channel.ele('description').txt(`${server.title} API results`)
|
|
channel.ele('link').txt(`${server.baseUrl}/`)
|
|
channel.ele('newznab:response', { offset: String(offset), total: String(total) })
|
|
if (limits) {
|
|
channel.ele('newznab:apilimits', {
|
|
apicurrent: String(limits.apiCurrent),
|
|
...(limits.apiMax !== null && { apimax: String(limits.apiMax) }),
|
|
grabcurrent: String(limits.grabCurrent),
|
|
...(limits.grabMax !== null && { grabmax: String(limits.grabMax) }),
|
|
})
|
|
}
|
|
|
|
const key = server.apiKey ? `&apikey=${encodeURIComponent(server.apiKey)}` : ''
|
|
for (const item of items) {
|
|
const getUrl = `${server.baseUrl}/api?t=get&id=${item.id}${key}`
|
|
const pubDate = (item.postedAt ?? new Date()).toUTCString()
|
|
const element = channel.ele('item')
|
|
element.ele('title').txt(item.title)
|
|
element.ele('guid', { isPermaLink: 'false' }).txt(String(item.id))
|
|
element.ele('link').txt(getUrl)
|
|
element.ele('comments').txt(`${server.baseUrl}/?release=${item.id}`)
|
|
element.ele('pubDate').txt(pubDate)
|
|
element.ele('category').txt(categoryLabel(item.category))
|
|
element.ele('description').txt(item.description ? ubbToText(item.description) : item.title)
|
|
element.ele('enclosure', { url: getUrl, length: String(item.size), type: 'application/x-nzb' })
|
|
|
|
const attr = (name: string, value: string | number) =>
|
|
element.ele('newznab:attr', { name, value: String(value) })
|
|
attr('category', parentCategory(item.category))
|
|
if (item.category !== parentCategory(item.category)) attr('category', item.category)
|
|
attr('size', item.size)
|
|
attr('files', item.files)
|
|
attr('grabs', 0)
|
|
attr('password', item.password ? 1 : 0)
|
|
if (item.poster) attr('poster', item.poster)
|
|
if (item.groups.length) attr('group', item.groups[0])
|
|
attr('usenetdate', pubDate)
|
|
if (item.season) attr('season', item.season)
|
|
if (item.episode) attr('episode', item.episode)
|
|
}
|
|
return rss.end({ prettyPrint: true })
|
|
}
|