Search and the web interface use the prober's findings
Test and deploy / test (push) Successful in 48s
Test and deploy / deploy (push) Successful in 2m27s

- Search matches a word in the name or the inner name (trigram index on
  inner_name, partial, built concurrently); "Readable names" includes
  releases with an inner name.
- unlocked=1 (chip "No password") leaves out passworded releases.
- Results carry passworded and innerName: a Password badge, the inner
  name under the release name.
- The release view: Inside and Inspected facts, a passworded notice, and
  Contents (per probed article its yEnc name, kind, type, encryption and
  the names inside) from release_inspections.
- Newznab: the password attribute from the prober; an obfuscated release
  with an inner name is included, titled by it (without its extension).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-11 19:33:23 +02:00
1 parent 5ab5399b43
commit 92480174b9
8 files changed
+177 -16

No files matched your search

+5 -4
View File
@@ -32,7 +32,7 @@ function int(value: unknown): number | 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.
* Releases with readable names (or an inner name from the prober, which then is the title) that aren't known to miss files.
*/
export default class NewznabController {
async handle({ request, response }: HttpContext) {
@@ -91,15 +91,16 @@ export default class NewznabController {
const tv = /(?:^|[._ -])S(\d{1,2})E(\d{1,3})/i.exec(r.name)
return {
id: r.id,
title: r.name,
// An obfuscated release by what the prober found inside, without its extension (what clients parse).
title: r.readable || !r.innerName ? r.name : r.innerName.replace(/\.[a-z0-9]{2,4}$/i, ''),
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,
// From the prober (phase 2): encrypted archive entries or headers.
password: r.passworded === true,
season: tv ? `S${tv[1].padStart(2, '0')}` : undefined,
episode: tv ? `E${tv[2].padStart(2, '0')}` : undefined,
}
+2 -1
View File
@@ -13,7 +13,7 @@ function megabytes(value: unknown): number | undefined {
/** The catalog for the web interface (REBUILD.md, part 9). */
export default class ReleasesController {
/** GET /api/v1/releases?q=&cat=5000,2040&readable=1&complete=1&min_size=&max_size=(MB)&source=&poster=&sort=&page=&per_page= */
/** GET /api/v1/releases?q=&cat=5000,2040&readable=1&complete=1&unlocked=1&min_size=&max_size=(MB)&source=&poster=&sort=&page=&per_page= */
async index({ request }: HttpContext) {
const qs = request.qs()
const categories = String(qs.cat ?? '')
@@ -25,6 +25,7 @@ export default class ReleasesController {
categories: categories.length ? expandCategories(categories) : undefined,
readable: flag(qs.readable),
complete: flag(qs.complete),
unlocked: flag(qs.unlocked),
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),
+24 -7
View File
@@ -7,12 +7,14 @@ 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). */
/** Only releases with a file whose name is readable (not an obfuscated token), or an inner name from the prober. */
readable?: boolean
/** Only releases known to have all their files. */
complete?: boolean
/** Leave out releases known to miss files (Newznab). */
notIncomplete?: boolean
/** Leave out releases the prober found passworded (encrypted archive entries or headers). */
unlocked?: boolean
/** How the release was recognised: stem, subject or post. */
source?: string
poster?: string
@@ -49,6 +51,10 @@ export interface ReleaseSummary {
complete: boolean | null
category: number
settled: boolean
/** The prober found encrypted archive entries or headers; null: not probed (yet). */
passworded: boolean | null
/** What the prober found inside (an archive entry, a par2 set's file, a yEnc name), when it says more than the name. */
innerName: string | null
}
const SORTS: Record<SortOrder, string> = {
@@ -75,14 +81,15 @@ 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.
// Every word in the name or the inner name the prober found, through their trigram indexes; "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))
where.push('(r.name ILIKE ? OR r.inner_name ILIKE ?)')
bindings.push(contains(word), contains(word))
}
if (params.readable) where.push('r.readable')
if (params.readable) where.push('(r.readable OR r.inner_name IS NOT NULL)')
if (params.complete) where.push('r.complete')
if (params.notIncomplete) where.push('r.complete IS NOT FALSE')
if (params.unlocked) where.push('r.passworded IS NOT TRUE')
if (params.source) {
where.push('r.source = ?')
bindings.push(params.source)
@@ -122,7 +129,7 @@ class SearchService {
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
`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, r.passworded, r.inner_name
FROM releases r WHERE ${whereSql} ORDER BY ${order} LIMIT ? OFFSET ?`,
[...bindings, perPage, offset]
),
@@ -149,6 +156,8 @@ class SearchService {
complete: r.complete,
category: r.category,
settled: r.settled_at !== null,
passworded: r.passworded,
innerName: r.inner_name,
})),
}
}
@@ -157,7 +166,10 @@ class SearchService {
async release(id: number) {
const r = await db.from('releases').where('id', id).first()
if (!r) return null
const files = await NzbService.files(id)
const [files, { rows: inspections }] = await Promise.all([
NzbService.files(id),
db.rawQuery(`SELECT kind, result, created_at FROM release_inspections WHERE release_id = ? ORDER BY candidate_id`, [id]),
])
return {
id: Number(r.id),
name: r.name,
@@ -176,6 +188,11 @@ class SearchService {
firstSeenAt: r.first_seen_at,
lastSeenAt: r.last_seen_at,
settledAt: r.settled_at,
passworded: r.passworded,
innerName: r.inner_name,
inspectedAt: r.inspected_at,
/** What the prober read from the release's articles (lib/probe Inspection), by probe kind. */
inspections: (inspections as any[]).map((i) => ({ kind: i.kind, ...i.result, at: i.created_at })),
files: files.map((f) => ({
name: f.name,
subject: f.subject,