Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97edf02c5 | ||
|
|
8e52bff213 | ||
|
|
57dfa3d749 | ||
|
|
fd0f2bde75 |
No files matched your search
@@ -6,6 +6,7 @@
|
|||||||
export const controllers = {
|
export const controllers = {
|
||||||
AccessTokens: () => import('#controllers/access_tokens_controller'),
|
AccessTokens: () => import('#controllers/access_tokens_controller'),
|
||||||
NewAccount: () => import('#controllers/new_account_controller'),
|
NewAccount: () => import('#controllers/new_account_controller'),
|
||||||
|
Newznab: () => import('#controllers/newznab_controller'),
|
||||||
Node: () => import('#controllers/node_controller'),
|
Node: () => import('#controllers/node_controller'),
|
||||||
Profile: () => import('#controllers/profile_controller'),
|
Profile: () => import('#controllers/profile_controller'),
|
||||||
Releases: () => import('#controllers/releases_controller'),
|
Releases: () => import('#controllers/releases_controller'),
|
||||||
|
|||||||
@@ -24,3 +24,6 @@ yarn-error.log
|
|||||||
|
|
||||||
# Platform specific
|
# Platform specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Generated by the dev tooling
|
||||||
|
.adonisjs/server/routes.json
|
||||||
+5
-1
@@ -53,6 +53,7 @@ export default defineConfig({
|
|||||||
() => import('@adonisjs/cors/cors_provider'),
|
() => import('@adonisjs/cors/cors_provider'),
|
||||||
() => import('@adonisjs/auth/auth_provider'),
|
() => import('@adonisjs/auth/auth_provider'),
|
||||||
() => import('#providers/api_provider'),
|
() => import('#providers/api_provider'),
|
||||||
|
() => import('@adonisjs/static/static_provider')
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -103,7 +104,10 @@ export default defineConfig({
|
|||||||
| the production build.
|
| the production build.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
metaFiles: [],
|
metaFiles: [{
|
||||||
|
pattern: 'public/**',
|
||||||
|
reloadServer: false,
|
||||||
|
}],
|
||||||
|
|
||||||
hooks: {
|
hooks: {
|
||||||
init: [
|
init: [
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,60 @@
|
|||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
|
||||||
import NzbService from '#services/nzb_service'
|
import NzbService from '#services/nzb_service'
|
||||||
|
import SearchService, { type SortOrder } from '#services/search_service'
|
||||||
|
import { expandCategories } from '#lib/newznab/index'
|
||||||
|
|
||||||
/** The catalog (REBUILD.md, part 9): a name search and a release's NZB. */
|
const flag = (value: unknown) => value === '1' || value === 'true' || value === true
|
||||||
|
|
||||||
|
/** A size in MB from the query string, as bytes. */
|
||||||
|
function megabytes(value: unknown): number | undefined {
|
||||||
|
const mb = Number(value)
|
||||||
|
return value !== undefined && value !== '' && Number.isFinite(mb) && mb >= 0 ? Math.round(mb * 1024 * 1024) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The catalog for the web interface (REBUILD.md, part 9). */
|
||||||
export default class ReleasesController {
|
export default class ReleasesController {
|
||||||
/** Releases whose name has every word of `q` (dots, dashes, underscores and spaces alike), newest first. */
|
/** GET /api/v1/releases?q=&cat=5000,2040&readable=1&complete=1&min_size=&max_size=(MB)&source=&poster=&sort=&page=&per_page= */
|
||||||
async index({ request }: HttpContext) {
|
async index({ request }: HttpContext) {
|
||||||
const q = String(request.input('q', '')).trim()
|
const qs = request.qs()
|
||||||
const limit = Math.min(Number(request.input('limit', 50)) || 50, 200)
|
const categories = String(qs.cat ?? '')
|
||||||
const query = db
|
.split(',')
|
||||||
.from('releases')
|
.map((c) => Number(c.trim()))
|
||||||
.select('id', 'name', 'poster', 'groups', 'posted_at', 'files', 'file_total', 'bytes', 'complete', 'readable')
|
.filter((c) => Number.isInteger(c) && c > 0)
|
||||||
.orderBy('posted_at', 'desc')
|
return SearchService.search({
|
||||||
.limit(limit)
|
q: typeof qs.q === 'string' ? qs.q : undefined,
|
||||||
for (const word of q.split(/[\s._-]+/).filter(Boolean)) query.whereILike('name', `%${word.replace(/[\\%_]/g, '\\$&')}%`)
|
categories: categories.length ? expandCategories(categories) : undefined,
|
||||||
return { releases: await query }
|
readable: flag(qs.readable),
|
||||||
|
complete: flag(qs.complete),
|
||||||
|
source: typeof qs.source === 'string' && qs.source ? qs.source : undefined,
|
||||||
|
poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : undefined,
|
||||||
|
minSize: megabytes(qs.min_size),
|
||||||
|
maxSize: megabytes(qs.max_size),
|
||||||
|
sort: qs.sort as SortOrder | undefined,
|
||||||
|
page: Number(qs.page) || 1,
|
||||||
|
perPage: Number(qs.per_page) || 50,
|
||||||
|
// The page shows "1,000+"; counting further made filter clicks slow.
|
||||||
|
countLimit: 1000,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/releases/:id, with its files */
|
||||||
|
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) {
|
async nzb({ params, response }: HttpContext) {
|
||||||
const nzb = await NzbService.build(Number(params.id))
|
const nzb = await NzbService.build(Number(params.id))
|
||||||
if (!nzb) return response.notFound({ error: 'No such release' })
|
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-Type', 'application/x-nzb')
|
||||||
response.header('Content-Disposition', `attachment; filename="${nzb.name.replace(/["\\/]/g, '_')}.nzb"`)
|
response.header('Content-Disposition', `attachment; filename="${filename}"`)
|
||||||
return nzb.xml
|
return response.send(nzb.xml)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/stats */
|
||||||
|
async stats() {
|
||||||
|
return SearchService.stats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+14
-11
@@ -1,25 +1,28 @@
|
|||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
import { decodeChunk } from '#lib/chunks/index'
|
import { decodeChunk, type ChunkFile } from '#lib/chunks/index'
|
||||||
import { buildNzb } from '#lib/nzb/index'
|
import { buildNzb } from '#lib/nzb/index'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A release's NZB (REBUILD.md, part 9), from its chunks in the chunk store:
|
* A release's files and NZB (REBUILD.md, part 9), from its chunks in the
|
||||||
* every chunk decoded, its files in name order. A file stored twice (the same
|
* chunk store: every chunk decoded, its files in name order. A file stored
|
||||||
* name and first article, e.g. a range read again) is taken once.
|
* twice (the same name and first article, e.g. a range read again) is taken
|
||||||
|
* once.
|
||||||
*/
|
*/
|
||||||
class NzbService {
|
class NzbService {
|
||||||
async build(releaseId: number): Promise<{ name: string; xml: string } | null> {
|
async files(releaseId: number): Promise<ChunkFile[]> {
|
||||||
const release = await db.from('releases').where('id', releaseId).select('name').first()
|
|
||||||
if (!release) return null
|
|
||||||
const chunks = await db.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload')
|
const chunks = await db.from('release_chunks').where('release_id', releaseId).orderBy('id').select('payload')
|
||||||
const files = new Map<string, ReturnType<typeof decodeChunk>[number]>()
|
const files = new Map<string, ChunkFile>()
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
for (const file of decodeChunk(chunk.payload)) files.set(`${file.name}\n${file.segments[0]?.[1] ?? ''}`, file)
|
for (const file of decodeChunk(chunk.payload)) files.set(`${file.name}\n${file.segments[0]?.[1] ?? ''}`, file)
|
||||||
}
|
}
|
||||||
|
return [...files.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
async build(releaseId: number): Promise<{ name: string; xml: string } | null> {
|
||||||
|
const release = await db.from('releases').where('id', releaseId).select('name').first()
|
||||||
|
if (!release) return null
|
||||||
const xml = buildNzb(
|
const xml = buildNzb(
|
||||||
[...files.values()]
|
(await this.files(releaseId)).map((f) => ({
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((f) => ({
|
|
||||||
name: f.name,
|
name: f.name,
|
||||||
poster: f.poster,
|
poster: f.poster,
|
||||||
date: f.postedAt ?? 0,
|
date: f.postedAt ?? 0,
|
||||||
|
|||||||
+74
-1
@@ -54,8 +54,9 @@ class Planner {
|
|||||||
return rows.length
|
return rows.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** History for groups not planned yet (new ones); what comes after is the tail's (planTail). */
|
||||||
async planAll(): Promise<{ groups: number; ranges: number }> {
|
async planAll(): Promise<{ groups: number; ranges: number }> {
|
||||||
const groups = await db.from('groups').where('active', true).orderBy('priority').select('id', 'name')
|
const groups = await db.from('groups').where('active', true).whereNull('planned_at').orderBy('priority').select('id', 'name')
|
||||||
let ranges = 0
|
let ranges = 0
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
// One group failing (e.g. gone from the server) doesn't stop the others.
|
// One group failing (e.g. gone from the server) doesn't stop the others.
|
||||||
@@ -67,6 +68,78 @@ class Planner {
|
|||||||
}
|
}
|
||||||
return { groups: groups.length, ranges }
|
return { groups: groups.length, ranges }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live tail: ranges for the articles after each planned group's last
|
||||||
|
* range, at the tail's priority (before any history). The newest article
|
||||||
|
* numbers come from LIST ACTIVE, one command per name prefix (GROUP per
|
||||||
|
* group took minutes for 1000 groups).
|
||||||
|
*/
|
||||||
|
async planTail(now = new Date()): Promise<{ groups: number; ranges: number }> {
|
||||||
|
const groups = await db.from('groups').where('active', true).whereNotNull('planned_at').select('id', 'name', 'planned_at')
|
||||||
|
if (groups.length === 0) return { groups: 0, ranges: 0 }
|
||||||
|
const { rows: ends } = await db.rawQuery(`SELECT DISTINCT ON (group_id) group_id, end_id FROM ranges ORDER BY group_id, start_id DESC`)
|
||||||
|
const plannedTo = new Map<number, number>(ends.map((r: any) => [Number(r.group_id), Number(r.end_id)]))
|
||||||
|
const highs = new Map<string, number>()
|
||||||
|
await NntpPool.use(async (client) => {
|
||||||
|
for (const pattern of new Set(groups.map((g) => wildmatOf(g.name)))) {
|
||||||
|
for (const g of await client.listActive(pattern)) highs.set(g.name, g.high)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const rows = tailRanges(
|
||||||
|
groups.map((g) => ({ id: Number(g.id), plannedTo: plannedTo.get(Number(g.id)) ?? null, plannedAt: new Date(g.planned_at), high: highs.get(g.name) ?? null })),
|
||||||
|
now,
|
||||||
|
{ rangeArticles: coordinatorConfig.rangeArticles, ...coordinatorConfig.tail }
|
||||||
|
)
|
||||||
|
if (rows.length === 0) return { groups: 0, ranges: 0 }
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx.table('ranges').multiInsert(rows)
|
||||||
|
for (const id of new Set(rows.map((r) => r.group_id))) {
|
||||||
|
const high = Math.max(...rows.filter((r) => r.group_id === id).map((r) => r.end_id))
|
||||||
|
await trx.from('groups').where('id', id).update({ high, planned_at: now })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { groups: new Set(rows.map((r) => r.group_id)).size, ranges: rows.length }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A LIST ACTIVE pattern for a group: its first two name parts (`alt.binaries.*`), or the name itself. */
|
||||||
|
export function wildmatOf(name: string): string {
|
||||||
|
const parts = name.split('.')
|
||||||
|
return parts.length > 2 ? `${parts[0]}.${parts[1]}.*` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TailGroup {
|
||||||
|
id: number
|
||||||
|
/** End of its last range (null: none, then it has no tail yet). */
|
||||||
|
plannedTo: number | null
|
||||||
|
/** When its last range was planned. */
|
||||||
|
plannedAt: Date
|
||||||
|
/** The server's newest article (null: not listed). */
|
||||||
|
high: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tail's new ranges: per group, the articles after its last range, in
|
||||||
|
* ranges of at most `rangeArticles`, once there are `minArticles` of them or
|
||||||
|
* the group's last planning is `maxDelayMinutes` ago.
|
||||||
|
*/
|
||||||
|
export function tailRanges(
|
||||||
|
groups: TailGroup[],
|
||||||
|
now: Date,
|
||||||
|
options: { rangeArticles: number; minArticles: number; maxDelayMinutes: number; priority: number }
|
||||||
|
) {
|
||||||
|
const rows: { group_id: number; start_id: number; end_id: number; next_id: number; priority: number }[] = []
|
||||||
|
for (const g of groups) {
|
||||||
|
if (g.plannedTo === null || g.high === null || g.high <= g.plannedTo) continue
|
||||||
|
const waiting = g.high - g.plannedTo
|
||||||
|
const waited = (now.getTime() - g.plannedAt.getTime()) / 60_000
|
||||||
|
if (waiting < options.minArticles && waited < options.maxDelayMinutes) continue
|
||||||
|
for (let start = g.plannedTo + 1; start <= g.high; start += options.rangeArticles) {
|
||||||
|
rows.push({ group_id: g.id, start_id: start, end_id: Math.min(start + options.rangeArticles - 1, g.high), next_id: start, priority: options.priority })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new Planner()
|
export default new Planner()
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import db from '@adonisjs/lucid/services/db'
|
||||||
|
import NzbService from '#services/nzb_service'
|
||||||
|
|
||||||
|
export type SortOrder = 'newest' | 'oldest' | 'largest' | 'files' | 'name'
|
||||||
|
|
||||||
|
/** Voyager 1's search parameters, on the catalog's columns (REBUILD.md, part 9). */
|
||||||
|
export interface SearchParams {
|
||||||
|
q?: string
|
||||||
|
/** Only releases with a file whose name is readable (not an obfuscated token). */
|
||||||
|
readable?: boolean
|
||||||
|
/** Only releases known to have all their files. */
|
||||||
|
complete?: boolean
|
||||||
|
/** Leave out releases known to miss files (Newznab). */
|
||||||
|
notIncomplete?: boolean
|
||||||
|
/** How the release was recognised: stem, subject or post. */
|
||||||
|
source?: string
|
||||||
|
poster?: string
|
||||||
|
/** Exact category ids (already expanded from parents). */
|
||||||
|
categories?: number[]
|
||||||
|
maxAgeDays?: number
|
||||||
|
/** TV: season and episode, matched as S01E02 or 1x02 in the name. */
|
||||||
|
season?: number
|
||||||
|
episode?: number
|
||||||
|
/** Size bounds in bytes. */
|
||||||
|
minSize?: number
|
||||||
|
maxSize?: number
|
||||||
|
sort?: SortOrder
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
/** Overrides `page` (Newznab pages by offset). */
|
||||||
|
offset?: number
|
||||||
|
/** Count matches up to this many past the page; `total` is capped there. */
|
||||||
|
countLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A release in search results. */
|
||||||
|
export interface ReleaseSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
source: string
|
||||||
|
poster: string | null
|
||||||
|
groups: string[]
|
||||||
|
postedAt: Date | null
|
||||||
|
fileTotal: number | null
|
||||||
|
fileCount: number
|
||||||
|
size: number
|
||||||
|
readable: boolean
|
||||||
|
complete: boolean | null
|
||||||
|
category: number
|
||||||
|
settled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTS: Record<SortOrder, string> = {
|
||||||
|
newest: 'r.posted_at DESC NULLS LAST, r.id DESC',
|
||||||
|
oldest: 'r.posted_at ASC NULLS LAST, r.id ASC',
|
||||||
|
largest: 'r.bytes DESC, r.id DESC',
|
||||||
|
files: 'r.files DESC, r.id DESC',
|
||||||
|
name: 'r.name ASC, r.id ASC',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A broad search needn't count every match (each counted row is a heap read). */
|
||||||
|
const COUNT_LIMIT = 10_000
|
||||||
|
const STATS_TTL_MS = 60 * 1000
|
||||||
|
|
||||||
|
/** ILIKE pattern of one word (the words are split on dots, dashes, underscores and spaces). */
|
||||||
|
function contains(word: string): string {
|
||||||
|
return `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searching and browsing the catalog, for the web interface and Newznab. */
|
||||||
|
class SearchService {
|
||||||
|
async search(params: SearchParams) {
|
||||||
|
const where: string[] = ['r.files > 0']
|
||||||
|
const bindings: unknown[] = []
|
||||||
|
// Every word in the name, through its trigram index; "The.Block.AU" and "the block au" alike.
|
||||||
|
for (const word of (params.q ?? '').split(/[\s._-]+/).filter(Boolean).slice(0, 8)) {
|
||||||
|
where.push('r.name ILIKE ?')
|
||||||
|
bindings.push(contains(word))
|
||||||
|
}
|
||||||
|
if (params.readable) where.push('r.readable')
|
||||||
|
if (params.complete) where.push('r.complete')
|
||||||
|
if (params.notIncomplete) where.push('r.complete IS NOT FALSE')
|
||||||
|
if (params.source) {
|
||||||
|
where.push('r.source = ?')
|
||||||
|
bindings.push(params.source)
|
||||||
|
}
|
||||||
|
if (params.poster) {
|
||||||
|
where.push('r.poster ILIKE ?')
|
||||||
|
bindings.push(contains(params.poster))
|
||||||
|
}
|
||||||
|
if (params.categories?.length) {
|
||||||
|
where.push('r.category = ANY(?)')
|
||||||
|
bindings.push(params.categories)
|
||||||
|
}
|
||||||
|
if (params.maxAgeDays) {
|
||||||
|
where.push('r.posted_at >= now() - make_interval(days => ?)')
|
||||||
|
bindings.push(params.maxAgeDays)
|
||||||
|
}
|
||||||
|
if (params.minSize !== undefined) {
|
||||||
|
where.push('r.bytes >= ?')
|
||||||
|
bindings.push(params.minSize)
|
||||||
|
}
|
||||||
|
if (params.maxSize !== undefined) {
|
||||||
|
where.push('r.bytes <= ?')
|
||||||
|
bindings.push(params.maxSize)
|
||||||
|
}
|
||||||
|
if (params.season !== undefined) {
|
||||||
|
const s = params.season
|
||||||
|
const e = params.episode
|
||||||
|
where.push('r.name ~* ?')
|
||||||
|
bindings.push(e !== undefined ? `(^|[._ -])(S0*${s}E0*${e}|0*${s}x0*${e})([^0-9]|$)` : `(^|[._ -])(S0*${s}([._ -]|E\\d|$)|0*${s}x\\d)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const perPage = Math.min(Math.max(params.perPage ?? 50, 1), 200)
|
||||||
|
const page = Math.max(params.page ?? 1, 1)
|
||||||
|
const offset = params.offset ?? (page - 1) * perPage
|
||||||
|
const order = SORTS[params.sort ?? 'newest'] ?? SORTS.newest
|
||||||
|
const whereSql = where.join(' AND ')
|
||||||
|
const countLimit = offset + (params.countLimit ?? COUNT_LIMIT)
|
||||||
|
const [{ rows }, { rows: counted }] = await Promise.all([
|
||||||
|
db.rawQuery(
|
||||||
|
`SELECT r.id, r.name, r.source, r.poster, r.groups, r.posted_at, r.file_total, r.files, r.bytes, r.readable, r.complete, r.category, r.settled_at
|
||||||
|
FROM releases r WHERE ${whereSql} ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||||
|
[...bindings, perPage, offset]
|
||||||
|
),
|
||||||
|
db.rawQuery(`SELECT count(*) AS total FROM (SELECT 1 FROM releases r WHERE ${whereSql} LIMIT ${countLimit + 1}) x`, bindings),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
offset,
|
||||||
|
total: Math.min(Number(counted[0].total), countLimit),
|
||||||
|
/** More than `total` match (the count stops at `countLimit`). */
|
||||||
|
totalCapped: Number(counted[0].total) > countLimit,
|
||||||
|
releases: (rows as any[]).map((r): ReleaseSummary => ({
|
||||||
|
id: Number(r.id),
|
||||||
|
name: r.name,
|
||||||
|
source: r.source,
|
||||||
|
poster: r.poster,
|
||||||
|
groups: r.groups,
|
||||||
|
postedAt: r.posted_at,
|
||||||
|
fileTotal: r.file_total,
|
||||||
|
fileCount: Number(r.files),
|
||||||
|
size: Number(r.bytes),
|
||||||
|
readable: r.readable,
|
||||||
|
complete: r.complete,
|
||||||
|
category: r.category,
|
||||||
|
settled: r.settled_at !== null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A release with its files, read from its chunks. */
|
||||||
|
async release(id: number) {
|
||||||
|
const r = await db.from('releases').where('id', id).first()
|
||||||
|
if (!r) return null
|
||||||
|
const files = await NzbService.files(id)
|
||||||
|
return {
|
||||||
|
id: Number(r.id),
|
||||||
|
name: r.name,
|
||||||
|
source: r.source,
|
||||||
|
poster: r.poster,
|
||||||
|
groups: r.groups,
|
||||||
|
postedAt: r.posted_at,
|
||||||
|
fileTotal: r.file_total,
|
||||||
|
fileCount: Number(r.files),
|
||||||
|
size: Number(r.bytes),
|
||||||
|
segments: Number(r.segments),
|
||||||
|
readable: r.readable,
|
||||||
|
complete: r.complete,
|
||||||
|
hasPar2: r.has_par2,
|
||||||
|
category: r.category,
|
||||||
|
firstSeenAt: r.first_seen_at,
|
||||||
|
lastSeenAt: r.last_seen_at,
|
||||||
|
settledAt: r.settled_at,
|
||||||
|
files: files.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
subject: f.subject,
|
||||||
|
poster: f.poster,
|
||||||
|
postedAt: f.postedAt === null ? null : new Date(f.postedAt),
|
||||||
|
size: f.segments.reduce((n, [, , bytes]) => n + bytes, 0),
|
||||||
|
parts: f.segments.length,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Totals for the web interface, computed at most once a minute (callers in between share them). */
|
||||||
|
async stats() {
|
||||||
|
if (!this.statsCache || Date.now() - this.statsCache.at > STATS_TTL_MS) {
|
||||||
|
this.statsRefresh ??= this.computeStats()
|
||||||
|
.then((value) => {
|
||||||
|
this.statsCache = { at: Date.now(), value }
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!this.statsCache) throw error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.statsRefresh = undefined
|
||||||
|
})
|
||||||
|
if (!this.statsCache) await this.statsRefresh
|
||||||
|
}
|
||||||
|
return this.statsCache!.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private statsCache?: { at: number; value: Awaited<ReturnType<SearchService['computeStats']>> }
|
||||||
|
private statsRefresh?: Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates (a catalog of 100M+ rows can't be counted on every visit):
|
||||||
|
* the row count from Postgres's statistics, below a million rows exact;
|
||||||
|
* readable share, files and size from a sample; ranges counted exactly
|
||||||
|
* (a few hundred thousand rows).
|
||||||
|
*/
|
||||||
|
private async computeStats() {
|
||||||
|
const { rows } = await db.rawQuery(`SELECT reltuples::bigint AS n FROM pg_class WHERE relname = 'releases'`)
|
||||||
|
const estimate = Number(rows[0]?.n ?? 0)
|
||||||
|
const sample = estimate < 1_000_000 ? '' : 'TABLESAMPLE SYSTEM (0.5)'
|
||||||
|
const { rows: s } = await db.rawQuery(
|
||||||
|
`SELECT count(*) AS sampled, count(*) FILTER (WHERE readable) AS readable, coalesce(sum(files), 0) AS files, coalesce(sum(bytes), 0) AS bytes
|
||||||
|
FROM releases ${sample} WHERE files > 0`
|
||||||
|
)
|
||||||
|
const sampled = Number(s[0].sampled)
|
||||||
|
const releases = sample ? Math.max(estimate, 0) : sampled
|
||||||
|
const scale = (n: unknown) => (sampled > 0 ? Math.round((Number(n) / sampled) * releases) : 0)
|
||||||
|
const { rows: ranges } = await db.rawQuery(
|
||||||
|
`SELECT count(*) FILTER (WHERE status = 'done') AS done, count(*) AS total,
|
||||||
|
count(*) FILTER (WHERE status = 'done' AND priority >= 0) AS history_done, count(*) FILTER (WHERE priority >= 0) AS history,
|
||||||
|
max(done_at) AS last_done_at,
|
||||||
|
max(done_at) FILTER (WHERE priority < 0) AS tail_done_at
|
||||||
|
FROM ranges`
|
||||||
|
)
|
||||||
|
const g = ranges[0]
|
||||||
|
return {
|
||||||
|
approximate: sample !== '',
|
||||||
|
releases,
|
||||||
|
readableReleases: scale(s[0].readable),
|
||||||
|
files: scale(s[0].files),
|
||||||
|
size: scale(s[0].bytes),
|
||||||
|
ranges: { done: Number(g.done), total: Number(g.total), historyDone: Number(g.history_done), history: Number(g.history) },
|
||||||
|
lastIndexedAt: g.last_done_at,
|
||||||
|
tailIndexedAt: g.tail_done_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new SearchService()
|
||||||
@@ -6,6 +6,7 @@ import Planner from '#services/planner'
|
|||||||
import FragmentMerger from '#services/fragment_merger'
|
import FragmentMerger from '#services/fragment_merger'
|
||||||
import Settler from '#services/settler'
|
import Settler from '#services/settler'
|
||||||
import NntpPool from '#services/nntp_pool'
|
import NntpPool from '#services/nntp_pool'
|
||||||
|
import coordinatorConfig from '#config/coordinator'
|
||||||
|
|
||||||
const TICK_MS = 2000
|
const TICK_MS = 2000
|
||||||
const PLAN_EVERY_MS = 60 * 60 * 1000
|
const PLAN_EVERY_MS = 60 * 60 * 1000
|
||||||
@@ -55,7 +56,9 @@ export default class CoordinatorRun extends BaseCommand {
|
|||||||
await sleep(TICK_MS)
|
await sleep(TICK_MS)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
// History for new groups hourly; the tail every few minutes.
|
||||||
let plannedAt = 0
|
let plannedAt = 0
|
||||||
|
let tailedAt = 0
|
||||||
let mergedAt = 0
|
let mergedAt = 0
|
||||||
while (!stopping) {
|
while (!stopping) {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +69,11 @@ export default class CoordinatorRun extends BaseCommand {
|
|||||||
const settled = await Settler.run()
|
const settled = await Settler.run()
|
||||||
if (settled > 0) this.logger.info(`Settled ${settled} releases.`)
|
if (settled > 0) this.logger.info(`Settled ${settled} releases.`)
|
||||||
}
|
}
|
||||||
|
if (main && Date.now() - tailedAt >= coordinatorConfig.tail.everySeconds * 1000) {
|
||||||
|
tailedAt = Date.now()
|
||||||
|
const { groups, ranges } = await Planner.planTail()
|
||||||
|
if (ranges > 0) this.logger.info(`Tail: ${ranges} ranges for ${groups} groups.`)
|
||||||
|
}
|
||||||
if (main && Date.now() - mergedAt >= MERGE_EVERY_MS) {
|
if (main && Date.now() - mergedAt >= MERGE_EVERY_MS) {
|
||||||
mergedAt = Date.now()
|
mergedAt = Date.now()
|
||||||
// Rounds until a whole pass over the file keys decides nothing more, or MERGE_BUDGET_MS.
|
// Rounds until a whole pass over the file keys decides nothing more, or MERGE_BUDGET_MS.
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { BaseCommand } from '@adonisjs/core/ace'
|
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||||
import Planner from '#services/planner'
|
import Planner from '#services/planner'
|
||||||
import NntpPool from '#services/nntp_pool'
|
import NntpPool from '#services/nntp_pool'
|
||||||
|
|
||||||
/** Cuts every active group's history (and new articles since) into ranges. */
|
/** Cuts new groups' history into ranges, or (--tail) plans one round of the live tail. */
|
||||||
export default class RangesPlan extends BaseCommand {
|
export default class RangesPlan extends BaseCommand {
|
||||||
static commandName = 'ranges:plan'
|
static commandName = 'ranges:plan'
|
||||||
static description = "Plans ranges for the indexed groups' articles"
|
static description = "Plans ranges for new groups' history, or one round of the live tail"
|
||||||
static options: CommandOptions = { startApp: true }
|
static options: CommandOptions = { startApp: true }
|
||||||
|
|
||||||
|
@flags.boolean({ description: 'Plan the live tail once (what coordinator:run does every few minutes)' })
|
||||||
|
declare tail: boolean
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
try {
|
try {
|
||||||
const { groups, ranges } = await Planner.planAll()
|
const { groups, ranges } = this.tail ? await Planner.planTail() : await Planner.planAll()
|
||||||
this.logger.info(`${ranges} ranges planned over ${groups} groups.`)
|
this.logger.info(`${ranges} ${this.tail ? 'tail ' : ''}ranges planned over ${groups} groups.`)
|
||||||
} finally {
|
} finally {
|
||||||
await NntpPool.close()
|
await NntpPool.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
|
||||||
|
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import RuleSets from '#services/rule_sets'
|
||||||
|
import { signatureKey } from '#lib/rules/index'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the dropped upload signatures (REBUILD.md, rules and recovery) from a
|
||||||
|
* file of "tool<TAB>group" lines, e.g. voyager 1's learned ones:
|
||||||
|
*
|
||||||
|
* psql … -At -F $'\t' -c "SELECT tool, group_name FROM upload_signatures WHERE dropped"
|
||||||
|
*
|
||||||
|
* Publishes a new rule set version (ranges record the version they ran with).
|
||||||
|
*/
|
||||||
|
export default class RulesSignatures extends BaseCommand {
|
||||||
|
static commandName = 'rules:signatures'
|
||||||
|
static description = 'Publishes a rule set with the dropped upload signatures from a file'
|
||||||
|
static options: CommandOptions = { startApp: true }
|
||||||
|
|
||||||
|
@args.string({ description: 'File of "tool<TAB>group" lines (more columns and # lines are ignored), e.g. database/data/voyager1_signatures.tsv' })
|
||||||
|
declare file: string
|
||||||
|
|
||||||
|
@flags.string({ description: 'Note stored with the rule set version' })
|
||||||
|
declare note?: string
|
||||||
|
|
||||||
|
@flags.boolean({ description: 'Add to the current signatures instead of replacing them' })
|
||||||
|
declare add: boolean
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const lines = (await readFile(this.file, 'utf8')).split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
||||||
|
const signatures = new Set<string>()
|
||||||
|
for (const line of lines) {
|
||||||
|
const [tool, group] = line.split('\t')
|
||||||
|
if (!tool || !group) throw new Error(`Not "tool<TAB>group": ${line}`)
|
||||||
|
signatures.add(signatureKey(tool, group))
|
||||||
|
}
|
||||||
|
const { version: _v, priorities: _p, ...current } = await RuleSets.current()
|
||||||
|
const before = new Set(current.droppedSignatures)
|
||||||
|
const next = this.add ? new Set([...before, ...signatures]) : signatures
|
||||||
|
if (next.size === before.size && [...next].every((s) => before.has(s))) {
|
||||||
|
this.logger.info(`Unchanged: ${before.size} dropped signatures already.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const version = await RuleSets.publish(
|
||||||
|
{ ...current, droppedSignatures: [...next].sort() },
|
||||||
|
this.note ?? `${next.size} dropped signatures from ${this.file}`
|
||||||
|
)
|
||||||
|
const added = [...next].filter((s) => !before.has(s)).length
|
||||||
|
const removed = [...before].filter((s) => !next.has(s)).length
|
||||||
|
this.logger.info(`Rule set ${version}: ${next.size} dropped signatures (${added} added, ${removed} removed).`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,20 @@ export default {
|
|||||||
* 2026-10-09).
|
* 2026-10-09).
|
||||||
*/
|
*/
|
||||||
chunkArticles: env.get('CHUNK_ARTICLES', 100_000),
|
chunkArticles: env.get('CHUNK_ARTICLES', 100_000),
|
||||||
|
/**
|
||||||
|
* The live tail (REBUILD.md, coordinator): new articles become ranges
|
||||||
|
* every `everySeconds` (one LIST ACTIVE per name prefix), leased and
|
||||||
|
* fetched before any history. A group gets a tail range once it has
|
||||||
|
* `minArticles` new (a chunk) or its oldest unplanned article waited
|
||||||
|
* `maxDelayMinutes`, so a quiet group doesn't make a range every round.
|
||||||
|
*/
|
||||||
|
tail: {
|
||||||
|
everySeconds: env.get('TAIL_EVERY_SECONDS', 120),
|
||||||
|
minArticles: env.get('TAIL_MIN_ARTICLES', 100_000),
|
||||||
|
maxDelayMinutes: env.get('TAIL_MAX_DELAY_MINUTES', 15),
|
||||||
|
/** Before the history's 0 (oldest) … 1000 (newest). */
|
||||||
|
priority: -1,
|
||||||
|
},
|
||||||
/** A leased range without a heartbeat for this long goes back to the queue. */
|
/** A leased range without a heartbeat for this long goes back to the queue. */
|
||||||
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
|
leaseTtlSeconds: env.get('LEASE_TTL_SECONDS', 15 * 60),
|
||||||
/** NNTP connections the coordinator fetches headers with. */
|
/** NNTP connections the coordinator fetches headers with. */
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from '@adonisjs/static'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options to tweak the static files middleware.
|
||||||
|
* The complete set of options are documented on the
|
||||||
|
* official documentation website.
|
||||||
|
*
|
||||||
|
* https://docs.adonisjs.com/guides/static-assets
|
||||||
|
*/
|
||||||
|
const staticServerConfig = defineConfig({
|
||||||
|
enabled: true,
|
||||||
|
etag: true,
|
||||||
|
lastModified: true,
|
||||||
|
dotFiles: 'ignore',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default staticServerConfig
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Voyager 1 upload_signatures WHERE dropped, 2026-10-09: tool, group, probed, named
|
||||||
|
other alt.binaries.ath 10988 24
|
||||||
|
ngPost alt.binaries.ath 3843 0
|
||||||
|
hex32 alt.binaries.test 1890 2
|
||||||
|
ngPost alt.binaries.a51 1516 0
|
||||||
|
nyuu alt.binaries.a51 700 0
|
||||||
|
nyuu alt.binaries.mom 656 0
|
||||||
|
hex32 alt.binaries.boneless 453 1
|
||||||
|
nyuu alt.binaries.misc 356 0
|
||||||
|
nyuu alt.binaries.games.encrypted 353 0
|
||||||
|
other alt.binaries.etc 317 1
|
||||||
|
other alt.binaries.misc 310 0
|
||||||
|
nyuu alt.binaries.warez 292 0
|
||||||
|
other alt.binaries.usenet2day 255 0
|
||||||
|
other alt.binaries.ijsklontje 238 0
|
||||||
|
ngPost alt.binaries.bloaf 237 0
|
||||||
|
nyuu alt.binaries.nl 196 0
|
||||||
|
other alt.binaries.archive.encrypted 189 0
|
||||||
|
nyuu alt.binaries.frogs 172 0
|
||||||
|
other alt.binaries.encrypted 160 0
|
||||||
|
yuun alt.binaries.nl 152 0
|
||||||
|
ngPost alt.binaries.nl 149 0
|
||||||
|
other alt.binaries.bloaf 134 0
|
||||||
|
nyuu alt.binaries.erotica 119 0
|
||||||
|
other alt.binaries.warez 119 0
|
||||||
|
other alt.binaries.ftn 107 0
|
||||||
|
nyuu alt.binaries.ijsklontje 106 0
|
||||||
|
random alt.binaries.newznzb.delta 103 0
|
||||||
|
ngPost alt.binaries.mom 102 0
|
||||||
|
random alt.binaries.newznzb.charlie 101 0
|
||||||
|
@@ -0,0 +1,39 @@
|
|||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A release's Newznab category from its name (REBUILD.md, part 9): voyager
|
||||||
|
* 1's release_category() without the archive contents (phase 2 has them).
|
||||||
|
* TV (5030/5040/5045), movies (2030/2040/2045), audio (3010/3040),
|
||||||
|
* books (7020), otherwise 8010.
|
||||||
|
*/
|
||||||
|
export default class extends BaseSchema {
|
||||||
|
async up() {
|
||||||
|
this.schema.raw(String.raw`
|
||||||
|
CREATE OR REPLACE FUNCTION release_category(name text) RETURNS integer IMMUTABLE LANGUAGE sql AS $$
|
||||||
|
SELECT CASE
|
||||||
|
-- TV: S01E02, 1x02, or a season pack (S01).
|
||||||
|
WHEN name ~* '(^|[._ -])(S\d{1,2}E\d{1,3}|\d{1,2}x\d{2})([._ -]|$)' OR name ~* '[._ -]S\d{1,2}([._ -]|$)' THEN
|
||||||
|
CASE WHEN name ~* '2160p|(^|[._ -])(uhd|4k)([._ -]|$)' THEN 5045
|
||||||
|
WHEN name ~* '720p|1080p' THEN 5040 ELSE 5030 END
|
||||||
|
-- Movies: a year plus a source or quality marker.
|
||||||
|
WHEN name ~* '(^|[._ (-])(19|20)\d{2}([._ )-]|$)'
|
||||||
|
AND name ~* '(480p|576p|720p|1080p|2160p|blu-?ray|web-?dl|webrip|bdrip|brrip|dvdrip|hdtv|x26[45]|h\.?26[45]|hevc|remux)' THEN
|
||||||
|
CASE WHEN name ~* '2160p|(^|[._ -])(uhd|4k)([._ -]|$)' THEN 2045
|
||||||
|
WHEN name ~* '720p|1080p|blu-?ray' THEN 2040 ELSE 2030 END
|
||||||
|
-- Audio: lossless, mp3, or scene music names (-WEB-2026-GROUP).
|
||||||
|
WHEN name ~* '(^|[._ -])flac([._ -]|$)' THEN 3040
|
||||||
|
WHEN name ~* '(^|[._ -])mp3([._ -]|$)' OR name ~* '-(WEB|CD|CDM|CDS|EP|VINYL|VLS|SAT|FM|DAB)-(19|20)\d{2}-[A-Za-z0-9]+$' THEN 3010
|
||||||
|
WHEN name ~* '(^|[._ -])(epub|ebook|mobi)([._ -]|$)' THEN 7020
|
||||||
|
ELSE 8010
|
||||||
|
END
|
||||||
|
$$`)
|
||||||
|
this.schema.raw(`ALTER TABLE releases ADD COLUMN IF NOT EXISTS category integer GENERATED ALWAYS AS (release_category(name)) STORED`)
|
||||||
|
// Newznab and the web interface list a category newest first.
|
||||||
|
this.schema.raw(`CREATE INDEX IF NOT EXISTS releases_category_posted_idx ON releases (category, posted_at DESC)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
this.schema.raw(`ALTER TABLE releases DROP COLUMN IF EXISTS category`)
|
||||||
|
this.schema.raw(`DROP FUNCTION IF EXISTS release_category(text)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -105,7 +105,9 @@ export class XoverClient {
|
|||||||
return this.timed(async () => {
|
return this.timed(async () => {
|
||||||
const reply = await this.command(`LIST ACTIVE ${pattern}`)
|
const reply = await this.command(`LIST ACTIVE ${pattern}`)
|
||||||
await this.expect(reply, ['215'])
|
await this.expect(reply, ['215'])
|
||||||
return (await this.plainBody())
|
// With XFEATURE COMPRESS GZIP on, Eweka compresses this list too ("215 NewsGroups Follow [COMPRESS=GZIP]").
|
||||||
|
const body = reply.includes('COMPRESS=GZIP') ? (await this.compressedRaw(true)).text : await this.plainBody()
|
||||||
|
return body
|
||||||
.split('\r\n')
|
.split('\r\n')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
|
|||||||
Generated
+104
-30
@@ -15,6 +15,7 @@
|
|||||||
"@adonisjs/lucid": "^22.4.2",
|
"@adonisjs/lucid": "^22.4.2",
|
||||||
"@adonisjs/session": "^8.1.0",
|
"@adonisjs/session": "^8.1.0",
|
||||||
"@adonisjs/shield": "^9.0.0",
|
"@adonisjs/shield": "^9.0.0",
|
||||||
|
"@adonisjs/static": "^2.0.1",
|
||||||
"@japa/api-client": "^3.2.1",
|
"@japa/api-client": "^3.2.1",
|
||||||
"@tuyau/core": "^1.2.2",
|
"@tuyau/core": "^1.2.2",
|
||||||
"@vinejs/vine": "^4.4.0",
|
"@vinejs/vine": "^4.4.0",
|
||||||
@@ -700,6 +701,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@adonisjs/static": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@adonisjs/static/-/static-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-pXSyQgha1yfyQDXPp0ywv7hJqk4JgCCkzWLkku2PW3Y6i/9GUTyzzNTCpz29loZzSF0b/EpiHXQE/sqBhW+p7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"serve-static": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@adonisjs/assembler": "^8.0.0-next.7 || ^8.0.0",
|
||||||
|
"@adonisjs/core": "^7.0.0-next.0 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@adonisjs/assembler": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@adonisjs/tsconfig": {
|
"node_modules/@adonisjs/tsconfig": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@adonisjs/tsconfig/-/tsconfig-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@adonisjs/tsconfig/-/tsconfig-2.0.0.tgz",
|
||||||
@@ -790,9 +812,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -809,9 +828,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -828,9 +844,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -847,9 +860,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1882,9 +1892,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1902,9 +1909,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1922,9 +1926,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1942,9 +1943,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1962,9 +1960,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1982,9 +1977,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3543,6 +3535,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -5548,6 +5546,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -6078,6 +6085,19 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
@@ -6375,6 +6395,60 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"fresh": "^2.0.0",
|
||||||
|
"http-errors": "^2.0.1",
|
||||||
|
"mime-types": "^3.0.2",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"on-finished": "^2.4.1",
|
||||||
|
"range-parser": "^1.2.1",
|
||||||
|
"statuses": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/fresh": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "^2.0.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
|
"parseurl": "^1.3.3",
|
||||||
|
"send": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/set-cookie-parser": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"@adonisjs/lucid": "^22.4.2",
|
"@adonisjs/lucid": "^22.4.2",
|
||||||
"@adonisjs/session": "^8.1.0",
|
"@adonisjs/session": "^8.1.0",
|
||||||
"@adonisjs/shield": "^9.0.0",
|
"@adonisjs/shield": "^9.0.0",
|
||||||
|
"@adonisjs/static": "^2.0.1",
|
||||||
"@japa/api-client": "^3.2.1",
|
"@japa/api-client": "^3.2.1",
|
||||||
"@tuyau/core": "^1.2.2",
|
"@tuyau/core": "^1.2.2",
|
||||||
"@vinejs/vine": "^4.4.0",
|
"@vinejs/vine": "^4.4.0",
|
||||||
|
|||||||
+844
@@ -0,0 +1,844 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #f0f2f5;
|
||||||
|
--border: #e1e4ea;
|
||||||
|
--text: #1b1f27;
|
||||||
|
--muted: #5f6878;
|
||||||
|
--faint: #8a93a3;
|
||||||
|
--accent: #2f6fed;
|
||||||
|
--accent-soft: #e7eefe;
|
||||||
|
--good: #1f8a4c;
|
||||||
|
--good-soft: #e3f4ea;
|
||||||
|
--warn: #9a6400;
|
||||||
|
--warn-soft: #fbf0d9;
|
||||||
|
--bad: #b3261e;
|
||||||
|
--bad-soft: #fbe6e4;
|
||||||
|
--shadow: 0 10px 30px rgba(20, 28, 45, 0.12);
|
||||||
|
--radius: 10px;
|
||||||
|
--mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||||
|
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0f1216;
|
||||||
|
--surface: #161a20;
|
||||||
|
--surface-2: #1d222a;
|
||||||
|
--border: #2a303a;
|
||||||
|
--text: #e6e9ef;
|
||||||
|
--muted: #a0a8b6;
|
||||||
|
--faint: #737c8c;
|
||||||
|
--accent: #6e9bff;
|
||||||
|
--accent-soft: #1c2740;
|
||||||
|
--good: #5fcf8e;
|
||||||
|
--good-soft: #15291e;
|
||||||
|
--warn: #e8b457;
|
||||||
|
--warn-soft: #2d2413;
|
||||||
|
--bad: #f28b82;
|
||||||
|
--bad-soft: #321a19;
|
||||||
|
--shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Elements with their own display value must still honour the hidden attribute. */
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.45 var(--sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Top bar ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--surface), var(--surface)) 5px 6px / 12px 2px no-repeat,
|
||||||
|
linear-gradient(var(--surface), var(--surface)) 5px 10px / 12px 2px no-repeat,
|
||||||
|
linear-gradient(var(--surface), var(--surface)) 5px 14px / 8px 2px no-repeat,
|
||||||
|
var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 22px;
|
||||||
|
margin: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats dt {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Search ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 22px 24px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
font: 12px var(--mono);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select select {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 34px 0 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface)
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a93a3' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
|
||||||
|
no-repeat right 10px center / 16px;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-small select {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A chip holding inputs: size bounds and file extensions. */
|
||||||
|
.field-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 6px 0 13px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-chip:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-chip input {
|
||||||
|
width: 64px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-chip input.ext {
|
||||||
|
width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-chip .unit {
|
||||||
|
padding-right: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip[aria-pressed='true'],
|
||||||
|
.chip-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-active button {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 0 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Results ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
margin: 20px 2px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* While a new search runs, the previous results stay in place but greyed out. */
|
||||||
|
.release-list.stale {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results[aria-busy='true'] .summary::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
height: 2px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||||
|
background-size: 40% 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
animation: searching 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searching {
|
||||||
|
from {
|
||||||
|
background-position: -40% 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 140% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.results[aria-busy='true'] .summary::after {
|
||||||
|
animation: none;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-list:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release:hover,
|
||||||
|
.release.selected {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 12px 0 12px 16px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-main:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-name {
|
||||||
|
font-weight: 560;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-name.obfuscated {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 450;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-meta {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--faint);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-right: 10px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-size {
|
||||||
|
width: 76px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 560;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-good {
|
||||||
|
background: var(--good-soft);
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warn {
|
||||||
|
background: var(--warn-soft);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-bad {
|
||||||
|
background: var(--bad-soft);
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-accent {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-weight: 560;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
border-color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
filter: brightness(1.07);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Detail drawer ----------------------------------------------------- */
|
||||||
|
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 14, 20, 0.35);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(640px, 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 11;
|
||||||
|
animation: slide-in 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(24px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 20px 14px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 24px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px 18px;
|
||||||
|
margin: 0 0 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dt {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.facts dd {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-weight: 560;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 11px 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-bad {
|
||||||
|
background: var(--bad-soft);
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-good {
|
||||||
|
background: var(--good-soft);
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice .password {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
user-select: all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-image {
|
||||||
|
flex: none;
|
||||||
|
width: 140px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-body .facts {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-description {
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-description a,
|
||||||
|
.spot-links a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.spot {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-warn {
|
||||||
|
background: var(--warn-soft);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 24px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title small {
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table td {
|
||||||
|
padding: 7px 8px 7px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table tr:first-child td {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table .name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table .posted {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table .num {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table .state {
|
||||||
|
width: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--good);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
height: 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.2s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Small screens ----------------------------------------------------- */
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar,
|
||||||
|
.layout {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-main {
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-side {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 10px 10px 16px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-size {
|
||||||
|
width: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+455
@@ -0,0 +1,455 @@
|
|||||||
|
// Voyager 2's web interface (from voyager 1's). No framework; all Usenet-derived
|
||||||
|
// text is rendered with textContent, never as HTML.
|
||||||
|
|
||||||
|
const $ = (selector) => document.querySelector(selector)
|
||||||
|
const FILTERS = ['readable', 'complete']
|
||||||
|
/** Text inputs that narrow a search: size bounds in MB. */
|
||||||
|
const FIELDS = ['min_size', 'max_size']
|
||||||
|
const SOURCE_LABELS = {
|
||||||
|
stem: 'Filename',
|
||||||
|
subject: 'Subject',
|
||||||
|
post: 'Post id',
|
||||||
|
}
|
||||||
|
/** Newznab categories (lib/newznab/categories.ts). */
|
||||||
|
const CATEGORIES = [
|
||||||
|
[2000, 'Movies', [[2030, 'SD'], [2040, 'HD'], [2045, 'UHD']]],
|
||||||
|
[3000, 'Audio', [[3010, 'MP3'], [3040, 'Lossless']]],
|
||||||
|
[5000, 'TV', [[5030, 'SD'], [5040, 'HD'], [5045, 'UHD']]],
|
||||||
|
[7000, 'Books', [[7020, 'Ebook']]],
|
||||||
|
[8000, 'Other', [[8010, 'Misc']]],
|
||||||
|
]
|
||||||
|
function categoryLabel(id) {
|
||||||
|
for (const [parent, name, subs] of CATEGORIES) {
|
||||||
|
if (Math.floor(id / 1000) * 1000 !== parent) continue
|
||||||
|
const sub = subs.find(([s]) => s === id)
|
||||||
|
return sub ? `${name} > ${sub[1]}` : name
|
||||||
|
}
|
||||||
|
return 'Other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
q: '',
|
||||||
|
sort: 'newest',
|
||||||
|
source: '',
|
||||||
|
cat: '',
|
||||||
|
poster: '',
|
||||||
|
min_size: '',
|
||||||
|
max_size: '',
|
||||||
|
readable: false,
|
||||||
|
complete: false,
|
||||||
|
page: 1,
|
||||||
|
release: null,
|
||||||
|
}
|
||||||
|
let requestId = 0
|
||||||
|
let searchAbort = null
|
||||||
|
|
||||||
|
// --- Formatting ------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return '–'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let value = bytes
|
||||||
|
let unit = 0
|
||||||
|
while (value >= 1000 && unit < units.length - 1) {
|
||||||
|
value /= 1000
|
||||||
|
unit++
|
||||||
|
}
|
||||||
|
return `${value >= 100 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const number = new Intl.NumberFormat()
|
||||||
|
|
||||||
|
function formatAge(date) {
|
||||||
|
if (!date) return ''
|
||||||
|
const seconds = (Date.now() - new Date(date).getTime()) / 1000
|
||||||
|
if (seconds < 90) return 'just now'
|
||||||
|
const steps = [
|
||||||
|
[60, 'minute'],
|
||||||
|
[3600, 'hour'],
|
||||||
|
[86400, 'day'],
|
||||||
|
[2592000, 'month'],
|
||||||
|
[31536000, 'year'],
|
||||||
|
]
|
||||||
|
let [size, name] = steps[0]
|
||||||
|
for (const step of steps) if (seconds >= step[0]) [size, name] = step
|
||||||
|
const n = Math.floor(seconds / size)
|
||||||
|
return `${n} ${name}${n === 1 ? '' : 's'} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, props = {}, ...children) {
|
||||||
|
const node = document.createElement(tag)
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (value === undefined || value === null || value === false) continue
|
||||||
|
if (key === 'class') node.className = value
|
||||||
|
else if (key === 'text') node.textContent = value
|
||||||
|
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value)
|
||||||
|
else node.setAttribute(key, value === true ? '' : value)
|
||||||
|
}
|
||||||
|
for (const child of children.flat()) {
|
||||||
|
if (child === null || child === undefined || child === false) continue
|
||||||
|
node.append(child instanceof Node ? child : document.createTextNode(String(child)))
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(text, tone = '', title) {
|
||||||
|
return el('span', { class: `badge${tone ? ` badge-${tone}` : ''}`, text, title })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- URL state -------------------------------------------------------------
|
||||||
|
|
||||||
|
function readUrl() {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
state.q = params.get('q') ?? ''
|
||||||
|
state.sort = params.get('sort') ?? 'newest'
|
||||||
|
state.source = params.get('source') ?? ''
|
||||||
|
state.cat = params.get('cat') ?? ''
|
||||||
|
state.poster = params.get('poster') ?? ''
|
||||||
|
for (const k of FIELDS) state[k] = params.get(k) ?? ''
|
||||||
|
for (const f of FILTERS) state[f] = params.get(f) === '1'
|
||||||
|
state.release = params.get('release') ? Number(params.get('release')) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUrl(push = false) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (state.q) params.set('q', state.q)
|
||||||
|
if (state.sort !== 'newest') params.set('sort', state.sort)
|
||||||
|
if (state.source) params.set('source', state.source)
|
||||||
|
if (state.cat) params.set('cat', state.cat)
|
||||||
|
if (state.poster) params.set('poster', state.poster)
|
||||||
|
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
|
||||||
|
for (const f of FILTERS) if (state[f]) params.set(f, '1')
|
||||||
|
if (state.release) params.set('release', String(state.release))
|
||||||
|
const url = `${location.pathname}${params.size ? `?${params}` : ''}`
|
||||||
|
history[push ? 'pushState' : 'replaceState'](null, '', url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncControls() {
|
||||||
|
$('#q').value = state.q
|
||||||
|
$('#sort').value = state.sort
|
||||||
|
$('#source').value = state.source
|
||||||
|
$('#cat').value = state.cat
|
||||||
|
for (const k of FIELDS) $(`#${k}`).value = state[k]
|
||||||
|
for (const f of FILTERS) {
|
||||||
|
document.querySelector(`[data-filter="${f}"]`).setAttribute('aria-pressed', String(state[f]))
|
||||||
|
}
|
||||||
|
$('#poster-filter').hidden = !state.poster
|
||||||
|
$('#poster-filter-label').textContent = `Poster: ${state.poster}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API -------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function api(path, { signal } = {}) {
|
||||||
|
const response = await fetch(path, { headers: { accept: 'application/json' }, signal })
|
||||||
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchParams(page) {
|
||||||
|
const params = new URLSearchParams({ sort: state.sort, page: String(page), per_page: '50' })
|
||||||
|
if (state.q) params.set('q', state.q)
|
||||||
|
if (state.source) params.set('source', state.source)
|
||||||
|
if (state.cat) params.set('cat', state.cat)
|
||||||
|
if (state.poster) params.set('poster', state.poster)
|
||||||
|
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
|
||||||
|
for (const f of FILTERS) if (state[f]) params.set(f, '1')
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stats -----------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const stats = await api('/api/v1/stats')
|
||||||
|
const readable = stats.releases ? Math.round((100 * stats.readableReleases) / stats.releases) : 0
|
||||||
|
// Estimated from Postgres statistics and a sample.
|
||||||
|
const about = stats.approximate ? '≈ ' : ''
|
||||||
|
const history = stats.ranges.history ? (100 * stats.ranges.historyDone) / stats.ranges.history : 0
|
||||||
|
const items = [
|
||||||
|
['Releases', about + number.format(stats.releases)],
|
||||||
|
['Readable', `${about}${readable}%`],
|
||||||
|
['Files', about + number.format(stats.files)],
|
||||||
|
['Size', about + formatSize(stats.size)],
|
||||||
|
['History', `${history < 10 ? history.toFixed(1) : Math.round(history)}%`],
|
||||||
|
['Live', formatAge(stats.tailIndexedAt) || '–'],
|
||||||
|
]
|
||||||
|
$('#stats').replaceChildren(...items.map(([dt, dd]) => el('div', {}, el('dt', { text: dt }), el('dd', { text: dd }))))
|
||||||
|
} catch {
|
||||||
|
// Stats are decoration; the page works without them.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Results ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function releaseRow(release) {
|
||||||
|
const row = $('#row-template').content.firstElementChild.cloneNode(true)
|
||||||
|
row.dataset.id = release.id
|
||||||
|
if (release.id === state.release) row.classList.add('selected')
|
||||||
|
|
||||||
|
const name = row.querySelector('.release-name')
|
||||||
|
name.textContent = release.name
|
||||||
|
if (!release.readable) name.classList.add('obfuscated')
|
||||||
|
|
||||||
|
const meta = [
|
||||||
|
formatAge(release.postedAt),
|
||||||
|
release.poster ? release.poster.replace(/\s*<.*>$/, '') : 'obfuscated poster',
|
||||||
|
release.groups?.length ? release.groups[0] + (release.groups.length > 1 ? ` +${release.groups.length - 1}` : '') : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
row.querySelector('.release-meta').textContent = meta.join(' · ')
|
||||||
|
|
||||||
|
const badges = row.querySelector('.badges')
|
||||||
|
const files = `${release.fileCount} ${release.fileCount === 1 ? 'file' : 'files'}`
|
||||||
|
if (release.complete) badges.append(badge(files, 'good', 'Complete'))
|
||||||
|
else if (release.fileTotal) badges.append(badge(`${release.fileCount}/${release.fileTotal}`, 'warn', 'Files present / announced'))
|
||||||
|
else badges.append(badge(files))
|
||||||
|
if (!release.settled) badges.append(badge('Collecting', 'accent', 'Recently seen: more files may still arrive'))
|
||||||
|
if (release.category && release.category !== 8010) badges.append(badge(categoryLabel(release.category), '', 'Category (from the name)'))
|
||||||
|
if (release.source && release.source !== 'stem' && release.source !== 'subject') {
|
||||||
|
badges.append(badge(SOURCE_LABELS[release.source] ?? release.source, 'accent', 'How this release was recognised'))
|
||||||
|
}
|
||||||
|
|
||||||
|
row.querySelector('.release-size').textContent = formatSize(release.size)
|
||||||
|
const nzb = row.querySelector('.nzb')
|
||||||
|
nzb.href = `/api/v1/releases/${release.id}/nzb`
|
||||||
|
row.querySelector('.release-main').addEventListener('click', () => openRelease(release.id, true))
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadResults({ append = false } = {}) {
|
||||||
|
const id = ++requestId
|
||||||
|
if (!append) state.page = 1
|
||||||
|
// A newer search makes the running one pointless: cancel it.
|
||||||
|
searchAbort?.abort()
|
||||||
|
const abort = (searchAbort = new AbortController())
|
||||||
|
$('#more').hidden = true
|
||||||
|
if (!append) {
|
||||||
|
$('#summary').textContent = 'Searching…'
|
||||||
|
$('#empty').hidden = true
|
||||||
|
// The old rows no longer match the search; grey them out until the new ones arrive.
|
||||||
|
$('#list').classList.add('stale')
|
||||||
|
$('.results').setAttribute('aria-busy', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api(`/api/v1/releases?${searchParams(state.page)}`, { signal: abort.signal })
|
||||||
|
if (id !== requestId) return // a newer search superseded this one
|
||||||
|
$('#list').classList.remove('stale')
|
||||||
|
$('.results').removeAttribute('aria-busy')
|
||||||
|
const rows = result.releases.map(releaseRow)
|
||||||
|
if (append) $('#list').append(...rows)
|
||||||
|
else $('#list').replaceChildren(...rows)
|
||||||
|
|
||||||
|
const shown = $('#list').children.length
|
||||||
|
$('#summary').textContent =
|
||||||
|
result.total === 0 ? '' : `${number.format(result.total)}${result.totalCapped ? '+' : ''} ${result.total === 1 ? 'release' : 'releases'}` +
|
||||||
|
(shown < result.total ? ` · showing ${number.format(shown)}` : '')
|
||||||
|
$('#empty').hidden = result.total !== 0
|
||||||
|
$('#more').hidden = shown >= result.total && !result.totalCapped
|
||||||
|
} catch (error) {
|
||||||
|
if (id !== requestId) return
|
||||||
|
$('#list').classList.remove('stale')
|
||||||
|
$('.results').removeAttribute('aria-busy')
|
||||||
|
$('#summary').textContent = `Search failed: ${error.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounce
|
||||||
|
function scheduleSearch() {
|
||||||
|
clearTimeout(debounce)
|
||||||
|
debounce = setTimeout(() => {
|
||||||
|
writeUrl()
|
||||||
|
loadResults()
|
||||||
|
}, 220)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Detail drawer ---------------------------------------------------------
|
||||||
|
|
||||||
|
function fact(label, value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null
|
||||||
|
return el('div', {}, el('dt', { text: label }), el('dd', {}, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title, note, content) {
|
||||||
|
return [el('h3', { class: 'section-title' }, title, note ? el('small', { text: note }) : null), content]
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(release) {
|
||||||
|
$('#detail-name').textContent = release.name
|
||||||
|
$('#detail-source').textContent = `Recognised by ${SOURCE_LABELS[release.source] ?? release.source ?? 'filename'} · ${categoryLabel(release.category)}`
|
||||||
|
|
||||||
|
const actions = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'actions' },
|
||||||
|
el('a', { class: 'button button-primary', href: `/api/v1/releases/${release.id}/nzb` },
|
||||||
|
el('span', { text: 'Download NZB' })),
|
||||||
|
el('button', {
|
||||||
|
class: 'button',
|
||||||
|
type: 'button',
|
||||||
|
text: 'Copy name',
|
||||||
|
onclick: async (event) => {
|
||||||
|
await navigator.clipboard?.writeText(release.name)
|
||||||
|
event.target.textContent = 'Copied'
|
||||||
|
setTimeout(() => (event.target.textContent = 'Copy name'), 1200)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const poster = release.poster
|
||||||
|
? el('button', {
|
||||||
|
class: 'link-button',
|
||||||
|
type: 'button',
|
||||||
|
text: release.poster.replace(/\s*<.*>$/, ''),
|
||||||
|
title: 'Show releases by this poster',
|
||||||
|
onclick: () => {
|
||||||
|
state.poster = release.poster
|
||||||
|
closeRelease()
|
||||||
|
syncControls()
|
||||||
|
writeUrl(true)
|
||||||
|
loadResults()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: 'Random per article (obfuscated)'
|
||||||
|
|
||||||
|
const facts = el(
|
||||||
|
'dl',
|
||||||
|
{ class: 'facts' },
|
||||||
|
fact('Size', formatSize(release.size)),
|
||||||
|
fact('Files', release.fileTotal ? `${release.fileCount} of ${release.fileTotal}` : `${release.fileCount} posted`),
|
||||||
|
fact('Articles', number.format(release.segments)),
|
||||||
|
fact('Posted', release.postedAt ? `${new Date(release.postedAt).toLocaleString()} (${formatAge(release.postedAt)})` : null),
|
||||||
|
fact('Poster', poster),
|
||||||
|
fact('Groups', release.groups?.join(', ')),
|
||||||
|
fact('Indexed', release.firstSeenAt ? `${new Date(release.firstSeenAt).toLocaleString()} (${formatAge(release.firstSeenAt)})` : null)
|
||||||
|
)
|
||||||
|
|
||||||
|
const notices = []
|
||||||
|
if (!release.settledAt) {
|
||||||
|
notices.push(el('div', { class: 'notice notice-warn', text: 'Recently seen: more files of this release may still arrive. The NZB has what is indexed so far.' }))
|
||||||
|
}
|
||||||
|
if (!release.readable) {
|
||||||
|
notices.push(el('div', { class: 'notice', text: 'Obfuscated file names: what this release holds is only known once its articles are read (phase 2).' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = el('table', { class: 'file-table' },
|
||||||
|
el('tbody', {}, release.files.map((f) =>
|
||||||
|
el('tr', {},
|
||||||
|
el('td', { class: 'name' }, f.name),
|
||||||
|
el('td', { class: 'num', text: formatSize(f.size) }),
|
||||||
|
el('td', { class: 'num', text: `${f.parts} ${f.parts === 1 ? 'part' : 'parts'}` })))))
|
||||||
|
|
||||||
|
$('#detail').replaceChildren(
|
||||||
|
actions,
|
||||||
|
...notices,
|
||||||
|
facts,
|
||||||
|
...section('Files', `${release.files.length} posted`, files)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRelease(id, push = false) {
|
||||||
|
state.release = id
|
||||||
|
writeUrl(push)
|
||||||
|
document.querySelectorAll('.release.selected').forEach((r) => r.classList.remove('selected'))
|
||||||
|
document.querySelector(`.release[data-id="${id}"]`)?.classList.add('selected')
|
||||||
|
|
||||||
|
$('#drawer').hidden = false
|
||||||
|
$('#scrim').hidden = false
|
||||||
|
$('#detail-name').textContent = ''
|
||||||
|
$('#detail-source').textContent = ''
|
||||||
|
$('#detail').replaceChildren(...Array.from({ length: 6 }, () => el('div', { class: 'skeleton' })))
|
||||||
|
$('#close').focus()
|
||||||
|
try {
|
||||||
|
renderDetail(await api(`/api/v1/releases/${id}`))
|
||||||
|
} catch (error) {
|
||||||
|
$('#detail').replaceChildren(el('p', { class: 'empty', text: `Could not load this release (${error.message}).` }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRelease(push = false) {
|
||||||
|
if (!state.release) return
|
||||||
|
const id = state.release
|
||||||
|
state.release = null
|
||||||
|
$('#drawer').hidden = true
|
||||||
|
$('#scrim').hidden = true
|
||||||
|
writeUrl(push)
|
||||||
|
document.querySelector(`.release[data-id="${id}"] .release-main`)?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wiring ----------------------------------------------------------------
|
||||||
|
|
||||||
|
$('#q').addEventListener('input', (event) => {
|
||||||
|
state.q = event.target.value.trim()
|
||||||
|
scheduleSearch()
|
||||||
|
})
|
||||||
|
for (const k of FIELDS) {
|
||||||
|
$(`#${k}`).addEventListener('input', (event) => {
|
||||||
|
state[k] = event.target.value.trim()
|
||||||
|
scheduleSearch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
$('#sort').addEventListener('change', (event) => {
|
||||||
|
state.sort = event.target.value
|
||||||
|
writeUrl()
|
||||||
|
loadResults()
|
||||||
|
})
|
||||||
|
for (const [parent, name, subs] of CATEGORIES) {
|
||||||
|
$('#cat').append(el('option', { value: String(parent), text: name }), ...subs.map(([id, sub]) => el('option', { value: String(id), text: `${name} > ${sub}` })))
|
||||||
|
}
|
||||||
|
$('#cat').addEventListener('change', (event) => {
|
||||||
|
state.cat = event.target.value
|
||||||
|
writeUrl()
|
||||||
|
loadResults()
|
||||||
|
})
|
||||||
|
$('#source').addEventListener('change', (event) => {
|
||||||
|
state.source = event.target.value
|
||||||
|
writeUrl()
|
||||||
|
loadResults()
|
||||||
|
})
|
||||||
|
for (const button of document.querySelectorAll('[data-filter]')) {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const f = button.dataset.filter
|
||||||
|
state[f] = !state[f]
|
||||||
|
button.setAttribute('aria-pressed', String(state[f]))
|
||||||
|
writeUrl()
|
||||||
|
loadResults()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
$('#poster-clear').addEventListener('click', () => {
|
||||||
|
state.poster = ''
|
||||||
|
syncControls()
|
||||||
|
writeUrl()
|
||||||
|
loadResults()
|
||||||
|
})
|
||||||
|
$('#more').addEventListener('click', () => {
|
||||||
|
state.page++
|
||||||
|
loadResults({ append: true })
|
||||||
|
})
|
||||||
|
$('#close').addEventListener('click', () => closeRelease(true))
|
||||||
|
$('#scrim').addEventListener('click', () => closeRelease(true))
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') closeRelease(true)
|
||||||
|
if (event.key === '/' && document.activeElement !== $('#q')) {
|
||||||
|
event.preventDefault()
|
||||||
|
$('#q').focus()
|
||||||
|
$('#q').select()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
const previous = { ...state }
|
||||||
|
readUrl()
|
||||||
|
syncControls()
|
||||||
|
const searchChanged = ['q', 'sort', 'source', 'cat', 'poster', ...FIELDS, ...FILTERS].some((k) => previous[k] !== state[k])
|
||||||
|
if (searchChanged) loadResults()
|
||||||
|
if (state.release && state.release !== previous.release) openRelease(state.release)
|
||||||
|
if (!state.release && previous.release) {
|
||||||
|
$('#drawer').hidden = true
|
||||||
|
$('#scrim').hidden = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
readUrl()
|
||||||
|
syncControls()
|
||||||
|
loadStats()
|
||||||
|
loadResults()
|
||||||
|
if (state.release) openRelease(state.release)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Voyager 2</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232f6fed'/%3E%3Cpath d='M9 10h14M9 16h14M9 22h9' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
|
||||||
|
<link rel="stylesheet" href="/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/" aria-label="Voyager 2, home">
|
||||||
|
<span class="brand-mark" aria-hidden="true"></span>
|
||||||
|
<span>Voyager 2</span>
|
||||||
|
</a>
|
||||||
|
<dl class="stats" id="stats" aria-label="Index statistics"></dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="search" aria-label="Search">
|
||||||
|
<div class="search-row">
|
||||||
|
<label class="search-box">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
|
||||||
|
<input id="q" type="search" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="Search release names…" aria-label="Search" />
|
||||||
|
<kbd aria-hidden="true">/</kbd>
|
||||||
|
</label>
|
||||||
|
<label class="select">
|
||||||
|
<span class="visually-hidden">Sort</span>
|
||||||
|
<select id="sort">
|
||||||
|
<option value="newest">Newest</option>
|
||||||
|
<option value="oldest">Oldest</option>
|
||||||
|
<option value="largest">Largest</option>
|
||||||
|
<option value="files">Most files</option>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="filters" role="group" aria-label="Filters">
|
||||||
|
<button class="chip" type="button" data-filter="readable" aria-pressed="false">Readable names</button>
|
||||||
|
<button class="chip" type="button" data-filter="complete" aria-pressed="false">Complete</button>
|
||||||
|
<label class="select select-small">
|
||||||
|
<span class="visually-hidden">Category</span>
|
||||||
|
<select id="cat">
|
||||||
|
<option value="">Any category</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="select select-small">
|
||||||
|
<span class="visually-hidden">Recognised by</span>
|
||||||
|
<select id="source">
|
||||||
|
<option value="">Any source</option>
|
||||||
|
<option value="stem">Filename</option>
|
||||||
|
<option value="subject">Subject</option>
|
||||||
|
<option value="post">Post id</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="field-chip" title="Release size in MB">
|
||||||
|
<label for="min_size">Size</label>
|
||||||
|
<input type="number" id="min_size" min="0" step="any" inputmode="decimal" placeholder="min" aria-label="Minimum size in MB" />
|
||||||
|
<span aria-hidden="true">–</span>
|
||||||
|
<input type="number" id="max_size" min="0" step="any" inputmode="decimal" placeholder="max" aria-label="Maximum size in MB" />
|
||||||
|
<span class="unit">MB</span>
|
||||||
|
</span>
|
||||||
|
<span class="chip chip-active" id="poster-filter" hidden>
|
||||||
|
<span id="poster-filter-label"></span>
|
||||||
|
<button type="button" id="poster-clear" aria-label="Remove poster filter">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="results" aria-live="polite">
|
||||||
|
<p class="summary" id="summary"></p>
|
||||||
|
<ol class="release-list" id="list"></ol>
|
||||||
|
<div class="more">
|
||||||
|
<button type="button" class="button" id="more" hidden>Load more</button>
|
||||||
|
</div>
|
||||||
|
<p class="empty" id="empty" hidden>No releases match. Try fewer words or turn off a filter.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="scrim" id="scrim" hidden></div>
|
||||||
|
<aside class="drawer" id="drawer" aria-labelledby="detail-name" hidden>
|
||||||
|
<div class="drawer-head">
|
||||||
|
<div class="drawer-title">
|
||||||
|
<p class="eyebrow" id="detail-source"></p>
|
||||||
|
<h2 id="detail-name"></h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-button" id="close" aria-label="Close details">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-body" id="detail"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<template id="row-template">
|
||||||
|
<li class="release">
|
||||||
|
<button type="button" class="release-main">
|
||||||
|
<span class="release-name"></span>
|
||||||
|
<span class="release-meta"></span>
|
||||||
|
</button>
|
||||||
|
<div class="release-side">
|
||||||
|
<span class="badges"></span>
|
||||||
|
<span class="release-size"></span>
|
||||||
|
<a class="icon-button nzb" title="Download NZB" aria-label="Download NZB">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14" /></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="/app.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -45,8 +45,14 @@ export default await Env.create(new URL('../', import.meta.url), {
|
|||||||
NNTP_CONNECTIONS: Env.schema.number.optional(),
|
NNTP_CONNECTIONS: Env.schema.number.optional(),
|
||||||
NNTP_TIMEOUT_MS: Env.schema.number.optional(),
|
NNTP_TIMEOUT_MS: Env.schema.number.optional(),
|
||||||
|
|
||||||
|
// Newznab API key (empty: no key needed)
|
||||||
|
NEWZNAB_API_KEY: Env.schema.secret.optional(),
|
||||||
|
|
||||||
// Coordinator
|
// Coordinator
|
||||||
RANGE_ARTICLES: Env.schema.number.optional(),
|
RANGE_ARTICLES: Env.schema.number.optional(),
|
||||||
|
TAIL_EVERY_SECONDS: Env.schema.number.optional(),
|
||||||
|
TAIL_MIN_ARTICLES: Env.schema.number.optional(),
|
||||||
|
TAIL_MAX_DELAY_MINUTES: Env.schema.number.optional(),
|
||||||
CHUNK_ARTICLES: Env.schema.number.optional(),
|
CHUNK_ARTICLES: Env.schema.number.optional(),
|
||||||
LEASE_TTL_SECONDS: Env.schema.number.optional(),
|
LEASE_TTL_SECONDS: Env.schema.number.optional(),
|
||||||
SPOOL_DIR: Env.schema.string.optional(),
|
SPOOL_DIR: Env.schema.string.optional(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ server.use([
|
|||||||
() => import('#middleware/force_json_response_middleware'),
|
() => import('#middleware/force_json_response_middleware'),
|
||||||
() => import('#middleware/container_bindings_middleware'),
|
() => import('#middleware/container_bindings_middleware'),
|
||||||
() => import('@adonisjs/cors/cors_middleware'),
|
() => import('@adonisjs/cors/cors_middleware'),
|
||||||
|
() => import('@adonisjs/static/static_middleware')
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+8
-9
@@ -11,9 +11,10 @@ import { middleware } from '#start/kernel'
|
|||||||
import router from '@adonisjs/core/services/router'
|
import router from '@adonisjs/core/services/router'
|
||||||
import { controllers } from '#generated/controllers'
|
import { controllers } from '#generated/controllers'
|
||||||
|
|
||||||
router.get('/', () => {
|
// The web interface is public/index.html (static middleware).
|
||||||
return { hello: 'world' }
|
router.get('/health', () => ({ ok: true }))
|
||||||
})
|
// Newznab API for Prowlarr, Sonarr, Radarr (from voyager 1).
|
||||||
|
router.get('/api', [controllers.Newznab, 'handle'])
|
||||||
|
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
@@ -34,13 +35,11 @@ router
|
|||||||
.as('profile')
|
.as('profile')
|
||||||
.use(middleware.auth())
|
.use(middleware.auth())
|
||||||
|
|
||||||
// The catalog (REBUILD.md, part 9).
|
// The catalog (REBUILD.md, part 9): open, as voyager 1's (the web interface uses it); Newznab has its key.
|
||||||
router
|
|
||||||
.group(() => {
|
|
||||||
router.get('releases', [controllers.Releases, 'index'])
|
router.get('releases', [controllers.Releases, 'index'])
|
||||||
router.get('releases/:id/nzb', [controllers.Releases, 'nzb'])
|
router.get('releases/:id', [controllers.Releases, 'show']).where('id', router.matchers.number())
|
||||||
})
|
router.get('releases/:id/nzb', [controllers.Releases, 'nzb']).where('id', router.matchers.number())
|
||||||
.use(middleware.auth({ guards: ['api'] }))
|
router.get('stats', [controllers.Releases, 'stats'])
|
||||||
|
|
||||||
// Edge workers (REBUILD.md, parts 1–2).
|
// Edge workers (REBUILD.md, parts 1–2).
|
||||||
router
|
router
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { test } from '@japa/runner'
|
import { test } from '@japa/runner'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
import testUtils from '@adonisjs/core/services/test_utils'
|
import testUtils from '@adonisjs/core/services/test_utils'
|
||||||
import User from '#models/user'
|
|
||||||
import BatchStore from '#services/batch_store'
|
import BatchStore from '#services/batch_store'
|
||||||
import { RangeIndexer } from '#lib/edge/index'
|
import { RangeIndexer } from '#lib/edge/index'
|
||||||
import { parseNzb } from '#lib/nzb/index'
|
import { parseNzb } from '#lib/nzb/index'
|
||||||
@@ -29,15 +28,13 @@ test.group('Releases API', (group) => {
|
|||||||
indexer.finish()
|
indexer.finish()
|
||||||
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
|
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
|
||||||
|
|
||||||
const user = await User.create({ email: 'u@example.invalid', password: 'secret-password' })
|
// Open, as voyager 1's catalog (the web interface uses it).
|
||||||
const token = (await User.accessTokens.create(user)).value!.release()
|
const found = await client.get('/api/v1/releases?q=show s01e01')
|
||||||
;(await client.get('/api/v1/releases?q=show s01e01')).assertStatus(401)
|
|
||||||
const found = await client.get('/api/v1/releases?q=show s01e01').bearerToken(token)
|
|
||||||
found.assertStatus(200)
|
found.assertStatus(200)
|
||||||
const releases = (found.body() as { releases: { id: number; name: string }[] }).releases
|
const releases = (found.body() as { releases: { id: number; name: string }[] }).releases
|
||||||
assert.lengthOf(releases, 1)
|
assert.lengthOf(releases, 1)
|
||||||
|
|
||||||
const nzb = await client.get(`/api/v1/releases/${releases[0].id}/nzb`).bearerToken(token)
|
const nzb = await client.get(`/api/v1/releases/${releases[0].id}/nzb`)
|
||||||
nzb.assertStatus(200)
|
nzb.assertStatus(200)
|
||||||
assert.include(nzb.header('content-type'), 'application/x-nzb')
|
assert.include(nzb.header('content-type'), 'application/x-nzb')
|
||||||
const parsed = parseNzb(nzb.text())!
|
const parsed = parseNzb(nzb.text())!
|
||||||
@@ -45,4 +42,38 @@ test.group('Releases API', (group) => {
|
|||||||
assert.deepEqual(parsed.files.map((f) => f.segments.length), [1, 3])
|
assert.deepEqual(parsed.files.map((f) => f.segments.length), [1, 3])
|
||||||
assert.equal(parsed.files[1].segments[2].id, 'Some.Show.S01E01.1080p.WEB.part1.rar-3@test')
|
assert.equal(parsed.files[1].segments[2].id, 'Some.Show.S01E01.1080p.WEB.part1.rar-3@test')
|
||||||
})
|
})
|
||||||
|
test('details with files, categories, stats and the Newznab API', async ({ client, assert }) => {
|
||||||
|
const [g] = await db.table('groups').insert({ name: GROUP, priority: 1 }).returning('id')
|
||||||
|
const [n] = await db.table('nodes').insert({ name: 'api-node' }).returning('id')
|
||||||
|
const [r] = await db.table('ranges').insert({ group_id: g.id, start_id: 1, end_id: 1000, next_id: 1, status: 'leased', node_id: n.id }).returning('id')
|
||||||
|
const indexer = new RangeIndexer({ group: GROUP, start: 1, end: 1000, rules: { ...DEFAULT_RULES, samplePercent: 0, droppedSignatures: [], priorities: { [GROUP]: 1 } } })
|
||||||
|
indexer.add([...file('Some.Show.S01E01.1080p.WEB.mkv', 'Some.Show.S01E01.1080p.WEB [1/1]', 2, 100), ...file('Other.Movie.2024.2160p.BluRay.x265.mkv', 'Other.Movie.2024.2160p.BluRay.x265 [1/1]', 1, 300)], 1001)
|
||||||
|
indexer.finish()
|
||||||
|
await BatchStore.store(Number(r.id), Number(n.id), indexer.take())
|
||||||
|
|
||||||
|
// Web API: a category (TV, expanded to its subcategories) and a release's files.
|
||||||
|
const tv = (await client.get('/api/v1/releases?cat=5000')).body() as { releases: { id: number; name: string; category: number }[] }
|
||||||
|
assert.deepEqual(tv.releases.map((x) => [x.name, x.category]), [['Some Show S01E01 1080p WEB', 5040]])
|
||||||
|
const detail = (await client.get(`/api/v1/releases/${tv.releases[0].id}`)).body() as { files: { name: string; parts: number; size: number }[] }
|
||||||
|
assert.deepEqual(detail.files, [{ ...detail.files[0], name: 'Some.Show.S01E01.1080p.WEB.mkv', parts: 2, size: 1400001 }])
|
||||||
|
;(await client.get('/api/v1/releases/999999')).assertStatus(404)
|
||||||
|
const stats = (await client.get('/api/v1/stats')).body() as { releases: number; ranges: { total: number } }
|
||||||
|
assert.equal(stats.releases, 2)
|
||||||
|
assert.equal(stats.ranges.total, 1)
|
||||||
|
|
||||||
|
// Newznab: caps, a TV search with season and episode, the NZB; the key when one is set.
|
||||||
|
const caps = await client.get('/api?t=caps')
|
||||||
|
assert.include(caps.text(), '<category id="5000" name="TV">')
|
||||||
|
const search = await client.get('/api?t=tvsearch&q=some show&season=1&ep=1')
|
||||||
|
assert.include(search.text(), '<title>Some Show S01E01 1080p WEB</title>')
|
||||||
|
assert.include(search.text(), '<newznab:attr name="category" value="5040"/>')
|
||||||
|
assert.notInclude(search.text(), 'Other Movie')
|
||||||
|
const movie = await client.get('/api?t=movie&cat=2045')
|
||||||
|
assert.include(movie.text(), 'Other Movie 2024 2160p BluRay x265')
|
||||||
|
const get = await client.get(`/api?t=get&id=${tv.releases[0].id}`)
|
||||||
|
assert.include(get.header('content-type'), 'application/x-nzb')
|
||||||
|
assert.equal(parseNzb(get.text())!.files.length, 1)
|
||||||
|
assert.include((await client.get('/api?t=get&id=999999')).text(), 'code="300"')
|
||||||
|
assert.include((await client.get('/api?t=nope')).text(), 'code="202"')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import { tailRanges, wildmatOf } from '#services/planner'
|
||||||
|
|
||||||
|
const options = { rangeArticles: 1000, minArticles: 100, maxDelayMinutes: 15, priority: -1 }
|
||||||
|
const now = new Date('2026-10-09T10:00:00Z')
|
||||||
|
const minutesAgo = (m: number) => new Date(now.getTime() - m * 60_000)
|
||||||
|
|
||||||
|
test.group('coordinator / live tail', () => {
|
||||||
|
test('a chunk of new articles, or a long enough wait, makes tail ranges', ({ assert }) => {
|
||||||
|
const rows = tailRanges(
|
||||||
|
[
|
||||||
|
// Enough new articles: planned at once, cut at rangeArticles.
|
||||||
|
{ id: 1, plannedTo: 5000, plannedAt: minutesAgo(2), high: 7500 },
|
||||||
|
// A few new articles, recently planned: waits.
|
||||||
|
{ id: 2, plannedTo: 100, plannedAt: minutesAgo(2), high: 150 },
|
||||||
|
// A few new articles, waited long enough.
|
||||||
|
{ id: 3, plannedTo: 100, plannedAt: minutesAgo(20), high: 103 },
|
||||||
|
// Nothing new, never planned, not listed: nothing.
|
||||||
|
{ id: 4, plannedTo: 100, plannedAt: minutesAgo(60), high: 100 },
|
||||||
|
{ id: 5, plannedTo: null, plannedAt: minutesAgo(60), high: 500 },
|
||||||
|
{ id: 6, plannedTo: 100, plannedAt: minutesAgo(60), high: null },
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => [r.group_id, r.start_id, r.end_id, r.next_id, r.priority]),
|
||||||
|
[
|
||||||
|
[1, 5001, 6000, 5001, -1],
|
||||||
|
[1, 6001, 7000, 6001, -1],
|
||||||
|
[1, 7001, 7500, 7001, -1],
|
||||||
|
[3, 101, 103, 101, -1],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('LIST ACTIVE patterns by the first two name parts', ({ assert }) => {
|
||||||
|
assert.equal(wildmatOf('alt.binaries.boneless'), 'alt.binaries.*')
|
||||||
|
assert.equal(wildmatOf('alt.binaries.multimedia.anime'), 'alt.binaries.*')
|
||||||
|
assert.equal(wildmatOf('alt.binaries'), 'alt.binaries')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,6 +39,16 @@ function server(): Promise<{ port: number; close: () => void }> {
|
|||||||
sock.write('224 Overview Information Follows\r\n')
|
sock.write('224 Overview Information Follows\r\n')
|
||||||
send(Buffer.concat([body, Buffer.from('.\r\n')]))
|
send(Buffer.concat([body, Buffer.from('.\r\n')]))
|
||||||
}
|
}
|
||||||
|
} else if (line.startsWith('LIST ACTIVE')) {
|
||||||
|
// As Eweka: compressed too once XFEATURE COMPRESS GZIP is on.
|
||||||
|
const body = Buffer.from('a.b.test 102 101 y\r\na.b.other 9000000000 2 y\r\n')
|
||||||
|
if (gzip) {
|
||||||
|
sock.write('215 NewsGroups Follow [COMPRESS=GZIP]\r\n')
|
||||||
|
send(Buffer.concat([deflateSync(body), Buffer.from('.\r\n')]))
|
||||||
|
} else {
|
||||||
|
sock.write('215 NewsGroups Follow\r\n')
|
||||||
|
send(Buffer.concat([body, Buffer.from('.\r\n')]))
|
||||||
|
}
|
||||||
} else if (line === 'QUIT') sock.end('205 bye\r\n')
|
} else if (line === 'QUIT') sock.end('205 bye\r\n')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -68,6 +78,10 @@ test.group('nntp / XoverClient', () => {
|
|||||||
assert.equal(overviews[1][1].subject, 'Show.S01E01 - "a.mkv" yEnc (2/2)')
|
assert.equal(overviews[1][1].subject, 'Show.S01E01 - "a.mkv" yEnc (2/2)')
|
||||||
assert.deepEqual(await client.xover(1, 1), [])
|
assert.deepEqual(await client.xover(1, 1), [])
|
||||||
assert.lengthOf(await client.xover(101, 102), 2)
|
assert.lengthOf(await client.xover(101, 102), 2)
|
||||||
|
assert.deepEqual(await client.listActive('a.b.*'), [
|
||||||
|
{ name: 'a.b.test', high: 102, low: 101 },
|
||||||
|
{ name: 'a.b.other', high: 9_000_000_000, low: 2 },
|
||||||
|
])
|
||||||
} finally {
|
} finally {
|
||||||
client.close()
|
client.close()
|
||||||
close()
|
close()
|
||||||
|
|||||||
Reference in new issue
Block a user