Rebuild: the catalog API and Newznab, from voyager 1
- /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>
This commit is contained in:
1 parent
57dfa3d749
commit
8e52bff213
18 files changed
+801
-79
No files matched your search
@@ -0,0 +1,40 @@
|
||||
/** Newznab categories this indexer assigns (release_category() in SQL, migration …1200). */
|
||||
export const CATEGORIES: { id: number; name: string; subcats: { id: number; name: string }[] }[] = [
|
||||
{ id: 2000, name: 'Movies', subcats: [{ id: 2030, name: 'SD' }, { id: 2040, name: 'HD' }, { id: 2045, name: 'UHD' }] },
|
||||
{ id: 3000, name: 'Audio', subcats: [{ id: 3010, name: 'MP3' }, { id: 3040, name: 'Lossless' }] },
|
||||
{ id: 4000, name: 'PC', subcats: [] },
|
||||
{ id: 5000, name: 'TV', subcats: [{ id: 5030, name: 'SD' }, { id: 5040, name: 'HD' }, { id: 5045, name: 'UHD' }] },
|
||||
{ id: 7000, name: 'Books', subcats: [{ id: 7020, name: 'Ebook' }] },
|
||||
{ id: 8000, name: 'Other', subcats: [{ id: 8010, name: 'Misc' }] },
|
||||
]
|
||||
|
||||
/** Parent category of a (sub)category, e.g. 5040 → 5000. */
|
||||
export function parentCategory(id: number): number {
|
||||
return Math.floor(id / 1000) * 1000
|
||||
}
|
||||
|
||||
/** "TV > HD" style label. */
|
||||
export function categoryLabel(id: number): string {
|
||||
const parent = CATEGORIES.find((c) => c.id === parentCategory(id))
|
||||
if (!parent) return 'Other'
|
||||
const sub = parent.subcats.find((s) => s.id === id)
|
||||
return sub ? `${parent.name} > ${sub.name}` : parent.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands requested categories (`cat=5000,2040`) to the concrete ones to
|
||||
* match: a parent includes all of its subcategories.
|
||||
*/
|
||||
export function expandCategories(requested: number[]): number[] {
|
||||
const result = new Set<number>()
|
||||
for (const id of requested) {
|
||||
const parent = CATEGORIES.find((c) => c.id === id)
|
||||
if (parent) {
|
||||
result.add(parent.id)
|
||||
for (const sub of parent.subcats) result.add(sub.id)
|
||||
} else {
|
||||
result.add(id)
|
||||
}
|
||||
}
|
||||
return [...result]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Newznab API formatting, independent of AdonisJS. From voyager 1 (its CONCEPT.md D23).
|
||||
*/
|
||||
export { CATEGORIES, categoryLabel, expandCategories, parentCategory } from './categories.js'
|
||||
export { capsXml, errorXml, resultsXml, type NewznabItem, type NewznabServer } from './xml.js'
|
||||
@@ -0,0 +1,107 @@
|
||||
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, 200/201 parameters, 202 function, 300 item. */
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
export function capsXml(server: NewznabServer, limits: { max: number; default: number }): 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: 'no', open: '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): 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) })
|
||||
|
||||
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 })
|
||||
}
|
||||
Reference in new issue
Block a user