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 = { 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()