285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
import db from '@adonisjs/lucid/services/db'
|
|
import { buildNzb } from '#lib/nzb/index'
|
|
import SpotService from '#services/SpotService'
|
|
|
|
export type SortOrder = 'newest' | 'oldest' | 'largest' | 'files' | 'name'
|
|
|
|
export interface SearchParams {
|
|
q?: string
|
|
/** Only releases whose name is readable (not an obfuscated token). */
|
|
readable?: boolean
|
|
/** Only releases that have all their files. */
|
|
complete?: boolean
|
|
/** Hide password-protected releases. */
|
|
hideEncrypted?: boolean
|
|
source?: string
|
|
poster?: string
|
|
sort?: SortOrder
|
|
page?: number
|
|
perPage?: number
|
|
/** Newznab: exact category ids (already expanded from parents). */
|
|
categories?: number[]
|
|
/** Newznab: leave out releases known to be missing files. */
|
|
notIncomplete?: boolean
|
|
maxAgeDays?: number
|
|
/** TV: season and episode numbers, matched as S01E02 or 1x02 in the name. */
|
|
season?: number
|
|
episode?: number
|
|
/** Overrides `page` (Newznab uses offsets). */
|
|
offset?: number
|
|
/** Leave out releases that are only an encrypted container (nzbe). */
|
|
hideContainers?: boolean
|
|
/** Include incomplete releases that consist of par2 files only (hidden by default). */
|
|
includePar2Only?: 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.size DESC, r.id DESC',
|
|
files: 'r.file_count DESC, r.id DESC',
|
|
name: 'r.name ASC, r.id ASC',
|
|
}
|
|
|
|
/**
|
|
* Shown in search: releases with files, and Spotnet listings whose NZB is
|
|
* fetched on first download (CONCEPT.md D29). Matches the partial indexes.
|
|
*/
|
|
const VISIBLE_SQL = `(r.file_count > 0 OR r.source = 'spot')`
|
|
|
|
/** Incomplete, and every file is a par2 file: nothing to download but repair data. */
|
|
const PAR2_ONLY_SQL = `(r.file_count > 0 AND NOT r.complete AND NOT EXISTS (
|
|
SELECT 1 FROM files f WHERE f.release_id = r.id
|
|
AND f.content_type IS DISTINCT FROM 'par2' AND f.filename !~* '\\.par2$'))`
|
|
|
|
/** A search word as an ILIKE pattern; dots, underscores, dashes and spaces are interchangeable. */
|
|
function pattern(word: string): string {
|
|
const escaped = word.replace(/[\\%]/g, (c) => `\\${c}`)
|
|
return `%${escaped.replace(/[._\- ]+/g, '_')}%`
|
|
}
|
|
|
|
/**
|
|
* Searching and browsing releases for the web interface (CONCEPT.md D22).
|
|
*/
|
|
class SearchService {
|
|
async search(params: SearchParams) {
|
|
const where: string[] = [
|
|
VISIBLE_SQL,
|
|
// Retracted by its poster or a Spotnet moderator (CONCEPT.md D29).
|
|
`NOT EXISTS (SELECT 1 FROM spots s WHERE s.release_id = r.id AND s.status = 'removed')`,
|
|
]
|
|
const bindings: unknown[] = []
|
|
|
|
const words = (params.q ?? '').trim().split(/\s+/).filter(Boolean).slice(0, 8)
|
|
for (const word of words) {
|
|
const p = pattern(word)
|
|
// Every word must match the release name, a file name, or the archive contents.
|
|
// One trigram-indexed lookup per table, not a subquery per release.
|
|
where.push(`(r.name ILIKE ? OR r.contents::text ILIKE ?
|
|
OR r.id IN (SELECT f.release_id FROM files f WHERE f.filename ILIKE ? OR f.real_name ILIKE ?))`)
|
|
bindings.push(p, p, p, p)
|
|
}
|
|
if (params.readable) where.push('is_readable_name(r.name)')
|
|
if (params.hideEncrypted) where.push('r.encrypted IS NOT TRUE')
|
|
if (params.hideContainers) where.push('r.container IS NULL')
|
|
// par2 files whose data never arrived (e.g. uploads spread over groups we can't fetch).
|
|
if (!params.includePar2Only) where.push(`(${PAR2_ONLY_SQL}) IS NOT TRUE`)
|
|
// Kept current by triggers (migration …860).
|
|
if (params.complete) where.push('r.complete')
|
|
if (params.source) {
|
|
where.push('r.source = ?')
|
|
bindings.push(params.source)
|
|
}
|
|
if (params.poster) {
|
|
where.push('r.poster ILIKE ?')
|
|
bindings.push(pattern(params.poster))
|
|
}
|
|
if (params.categories?.length) {
|
|
where.push('r.category = ANY(?)')
|
|
bindings.push(params.categories)
|
|
}
|
|
if (params.notIncomplete) {
|
|
where.push(`(r.complete OR (r.file_total IS NULL AND NOT EXISTS (SELECT 1 FROM release_files rf WHERE rf.release_id = r.id)))`)
|
|
}
|
|
if (params.maxAgeDays) {
|
|
where.push(`r.posted_at >= now() - make_interval(days => ?)`)
|
|
bindings.push(params.maxAgeDays)
|
|
}
|
|
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 [{ rows }, { rows: countRows }] = await Promise.all([
|
|
db.rawQuery(
|
|
`SELECT r.id, r.name, r.source, r.poster, r.groups, r.posted_at, r.file_total, r.file_count, r.category, r.container,
|
|
r.size, r.encrypted, is_readable_name(r.name) AS readable,
|
|
(SELECT count(*) FROM release_files rf WHERE rf.release_id = r.id) AS listed_files,
|
|
(SELECT count(*) FROM release_files rf WHERE rf.release_id = r.id AND EXISTS (
|
|
SELECT 1 FROM files f JOIN binaries b ON b.file_id = f.id
|
|
WHERE f.release_id = r.id AND (b.size = rf.size OR f.real_name = rf.name))) AS listed_present,
|
|
jsonb_array_length(coalesce(r.contents, '[]'::jsonb)) AS archive_entries,
|
|
r.complete, ${PAR2_ONLY_SQL} AS par2_only
|
|
FROM releases r WHERE ${whereSql}
|
|
ORDER BY ${order} LIMIT ? OFFSET ?`,
|
|
[...bindings, perPage, offset]
|
|
),
|
|
db.rawQuery(`SELECT count(*) AS total FROM releases r WHERE ${whereSql}`, bindings),
|
|
])
|
|
|
|
return {
|
|
page,
|
|
perPage,
|
|
offset,
|
|
total: Number(countRows[0].total),
|
|
releases: rows.map((r: any) => ({
|
|
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: r.file_count,
|
|
size: Number(r.size),
|
|
encrypted: r.encrypted,
|
|
readable: r.readable,
|
|
listedFiles: Number(r.listed_files),
|
|
listedPresent: Number(r.listed_present),
|
|
archiveEntries: Number(r.archive_entries),
|
|
complete: r.complete,
|
|
category: r.category,
|
|
container: r.container,
|
|
par2Only: r.par2_only,
|
|
})),
|
|
}
|
|
}
|
|
|
|
async release(id: number) {
|
|
const release = await db.from('releases').where('id', id).first()
|
|
if (!release) return null
|
|
|
|
const [files, listed, pending] = await Promise.all([
|
|
db
|
|
.from('files as f')
|
|
.leftJoin('binaries as b', 'b.file_id', 'f.id')
|
|
.where('f.release_id', id)
|
|
.orderByRaw('coalesce(f.real_name, f.filename)')
|
|
.select('f.id', 'f.filename', 'f.real_name', 'f.size', 'f.parts', 'f.date', 'f.poster', 'b.size as exact_size'),
|
|
db.rawQuery(
|
|
`SELECT rf.name, rf.size,
|
|
EXISTS (SELECT 1 FROM files f JOIN binaries b ON b.file_id = f.id
|
|
WHERE f.release_id = rf.release_id AND (f.real_name = rf.name OR b.size = rf.size)) AS present
|
|
FROM release_files rf WHERE rf.release_id = ? ORDER BY rf.name`,
|
|
[id]
|
|
),
|
|
db.rawQuery(
|
|
`SELECT count(*) AS binaries, coalesce(sum(b.total_parts), 0) AS parts_total,
|
|
coalesce(sum((SELECT count(*) FROM parts p WHERE p.binary_id = b.id)), 0) AS parts_have
|
|
FROM binaries b WHERE b.release_id = ? AND b.completed_at IS NULL AND b.conflicted_at IS NULL`,
|
|
[id]
|
|
),
|
|
])
|
|
|
|
const spot = release.source === 'spot'
|
|
? await db.from('spots').where('release_id', id).select('tag', 'poster', 'category', 'website').first()
|
|
: null
|
|
return {
|
|
id: Number(release.id),
|
|
name: release.name,
|
|
source: release.source,
|
|
spot: spot ? { tag: spot.tag, poster: spot.poster, category: spot.category, website: spot.website } : null,
|
|
listingOnly: release.source === 'spot' && Number(release.file_count) === 0,
|
|
poster: release.poster,
|
|
groups: release.groups,
|
|
postedAt: release.posted_at,
|
|
fileTotal: release.file_total,
|
|
fileCount: release.file_count,
|
|
size: Number(release.size),
|
|
encrypted: release.encrypted,
|
|
container: release.container,
|
|
contents: release.contents ?? [],
|
|
files: files.map((f) => ({
|
|
id: Number(f.id),
|
|
name: f.real_name ?? f.filename,
|
|
postedName: f.filename,
|
|
realName: f.real_name,
|
|
size: Number(f.exact_size ?? f.size),
|
|
parts: f.parts,
|
|
postedAt: f.date ? new Date(Number(f.date)) : null,
|
|
poster: f.poster,
|
|
})),
|
|
par2Files: listed.rows.map((r: any) => ({ name: r.name, size: Number(r.size), present: r.present })),
|
|
pending: {
|
|
binaries: Number(pending.rows[0].binaries),
|
|
partsHave: Number(pending.rows[0].parts_have),
|
|
partsTotal: Number(pending.rows[0].parts_total),
|
|
},
|
|
}
|
|
}
|
|
|
|
async nzb(id: number): Promise<{ name: string; xml: string } | null> {
|
|
let release = await db.from('releases').where('id', id).first()
|
|
if (!release) return null
|
|
if (release.source === 'spot' && Number(release.file_count) === 0) {
|
|
// A Spotnet listing: read its NZB from Usenet now (the import may join known files elsewhere).
|
|
const imported = await SpotService.importListing(id)
|
|
if (!imported) return null
|
|
id = imported
|
|
release = await db.from('releases').where('id', id).first()
|
|
}
|
|
const files = await db
|
|
.from('files')
|
|
.where('release_id', id)
|
|
.orderByRaw('coalesce(real_name, filename)')
|
|
.select('filename', 'real_name', 'poster', 'date', 'groups', 'message_ids')
|
|
const xml = buildNzb(
|
|
files.map((f) => ({
|
|
name: f.real_name ?? f.filename,
|
|
poster: f.poster,
|
|
date: Number(f.date),
|
|
groups: f.groups?.length ? f.groups : ['alt.binaries.test'],
|
|
segments: f.message_ids,
|
|
})),
|
|
{ title: release.name, password: release.password ?? undefined }
|
|
)
|
|
return { name: release.name, xml }
|
|
}
|
|
|
|
async stats() {
|
|
const { rows } = await db.rawQuery(`
|
|
SELECT (SELECT count(*) FROM releases r WHERE ${VISIBLE_SQL}) AS releases,
|
|
(SELECT count(*) FROM releases r WHERE ${VISIBLE_SQL} AND is_readable_name(name)) AS readable_releases,
|
|
(SELECT count(*) FROM files) AS files,
|
|
(SELECT coalesce(sum(size), 0) FROM files) AS size,
|
|
(SELECT count(*) FROM releases WHERE encrypted) AS encrypted_releases,
|
|
(SELECT max(updated_at) FROM groups) AS last_indexed_at,
|
|
(SELECT json_agg(json_build_object('source', source, 'releases', n) ORDER BY n DESC)
|
|
FROM (SELECT coalesce(source, 'unknown') AS source, count(*) AS n FROM releases r WHERE ${VISIBLE_SQL} GROUP BY 1) s) AS sources`)
|
|
const r = rows[0]
|
|
return {
|
|
releases: Number(r.releases),
|
|
readableReleases: Number(r.readable_releases),
|
|
files: Number(r.files),
|
|
size: Number(r.size),
|
|
encryptedReleases: Number(r.encrypted_releases),
|
|
lastIndexedAt: r.last_indexed_at,
|
|
sources: r.sources ?? [],
|
|
}
|
|
}
|
|
}
|
|
|
|
export default new SearchService()
|