feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署

This commit is contained in:
ice-a
2026-10-16 18:53:45 +08:00
commit d6999104bf
64 changed files with 16524 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import { getDb } from './mongo'
export type BtClientType = 'qbittorrent' | 'transmission' | 'aria2'
export interface BtClientConfig {
id: string
type: BtClientType
name: string
host: string
port: number
/** 自定义 RPC / API 路径,留空使用各客户端的默认路径 */
path?: string
username?: string
password?: string
downloadDir?: string
useHttps?: boolean
enabled?: boolean
}
export interface AppSettings {
/** 访问搜索源时使用的 HTTP 代理 */
searchProxy: string
searchTimeout: number
metadataTimeout: number
enabledProviders: string[]
btClients: BtClientConfig[]
}
/** 本机常见 BT 客户端的默认端点,用户没配置时自动探测这些 */
export const DEFAULT_CLIENTS: BtClientConfig[] = [
{ id: 'qbittorrent-local', type: 'qbittorrent', name: 'qBittorrent(本机)', host: '127.0.0.1', port: 8080, username: 'admin', password: '', enabled: true },
{ id: 'transmission-local', type: 'transmission', name: 'Transmission(本机)', host: '127.0.0.1', port: 9091, path: '/transmission/rpc', username: '', password: '', enabled: true },
{ id: 'aria2-local', type: 'aria2', name: 'aria2(本机)', host: '127.0.0.1', port: 6800, path: '/jsonrpc', password: '', enabled: true }
]
const SETTINGS_ID = 'app'
function envDefaults(): AppSettings {
return {
searchProxy: process.env.SEARCH_PROXY || '',
searchTimeout: Number(process.env.SEARCH_TIMEOUT || 12000),
metadataTimeout: Number(process.env.METADATA_TIMEOUT || 30000),
enabledProviders: ['apibay', 'nyaa', 'cilisousuo'],
btClients: DEFAULT_CLIENTS.map((c) => ({ ...c }))
}
}
let cache: { value: AppSettings; at: number; ttl: number } | null = null
export function invalidateSettingsCache() {
cache = null
}
export async function loadSettings(): Promise<AppSettings> {
if (cache && Date.now() - cache.at < cache.ttl) return cache.value
const base = envDefaults()
try {
const db = await getDb()
const doc = await db.collection('settings').findOne({ _id: SETTINGS_ID as never })
const stored = (doc?.value || {}) as Partial<AppSettings>
const merged: AppSettings = { ...base, ...stored }
if (!Array.isArray(merged.btClients) || merged.btClients.length === 0) merged.btClients = base.btClients
if (!Array.isArray(merged.enabledProviders) || merged.enabledProviders.length === 0) {
merged.enabledProviders = base.enabledProviders
}
cache = { value: merged, at: Date.now(), ttl: 5000 }
return merged
} catch {
// MongoDB 不可用:短时缓存默认值,避免每个请求都卡 3 秒去连库
cache = { value: base, at: Date.now(), ttl: 3000 }
return base
}
}
export async function saveSettings(patch: Partial<AppSettings>): Promise<AppSettings> {
const db = await getDb()
const current = await loadSettings()
const next: AppSettings = { ...current, ...patch }
await db
.collection('settings')
.updateOne(
{ _id: SETTINGS_ID as never },
{ $set: { value: next, updatedAt: new Date() } },
{ upsert: true }
)
cache = { value: next, at: Date.now(), ttl: 5000 }
return next
}
export function normalizeClient(raw: Partial<BtClientConfig>, index = 0): BtClientConfig {
const type = (raw.type || 'qbittorrent') as BtClientType
return {
id: raw.id || `${type}-${Date.now().toString(36)}-${index}`,
type,
name: raw.name || type,
host: raw.host || '127.0.0.1',
port: Number(raw.port) || defaultPort(type),
path: raw.path || undefined,
username: raw.username || '',
password: raw.password || '',
downloadDir: raw.downloadDir || '',
useHttps: !!raw.useHttps,
enabled: raw.enabled !== false
}
}
export function defaultPort(type: BtClientType): number {
if (type === 'transmission') return 9091
if (type === 'aria2') return 6800
return 8080
}
export function baseUrlOf(cfg: BtClientConfig): string {
const scheme = cfg.useHttps ? 'https' : 'http'
return `${scheme}://${cfg.host}:${cfg.port}`
}
+114
View File
@@ -0,0 +1,114 @@
import { baseUrlOf, type BtClientConfig } from '../appconfig'
import { httpFetch } from '../http'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ProbeResult } from './types'
function rpcUrl(cfg: BtClientConfig) {
const path = cfg.path && cfg.path.trim() ? cfg.path.trim() : '/jsonrpc'
return `${baseUrlOf(cfg)}${path.startsWith('/') ? path : `/${path}`}`
}
function headersOf(cfg: BtClientConfig) {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (cfg.username) {
headers.authorization = `Basic ${Buffer.from(`${cfg.username}:${cfg.password || ''}`).toString('base64')}`
}
return headers
}
/** aria2 的 RPC 密钥以 token:xxx 形式作为第一个参数传入 */
async function call(cfg: BtClientConfig, method: string, params: unknown[]) {
const secret = (cfg.password || '').trim()
const payload = {
jsonrpc: '2.0',
id: `dsl-${Date.now().toString(36)}`,
method,
params: secret ? [`token:${secret}`, ...params] : params
}
const res = await httpFetch(rpcUrl(cfg), {
method: 'POST',
timeoutMs: 10000,
headers: headersOf(cfg),
body: JSON.stringify(payload)
})
if (res.status === 401) throw new Error('aria2 认证失败,请检查 --rpc-user / --rpc-pass')
if (!res.ok) throw new Error(`aria2 返回 HTTP ${res.status}`)
const data = (await res.json()) as { result?: unknown; error?: { code: number; message: string } }
if (data.error) throw new Error(`aria2 错误 ${data.error.code}:${data.error.message}`)
return data.result
}
export const aria2Adapter: BtClientAdapter = {
type: 'aria2',
label: 'aria2',
defaultPort: 6800,
defaultPath: '/jsonrpc',
async probe(cfg: BtClientConfig): Promise<ProbeResult> {
const result = (await call(cfg, 'aria2.getVersion', [])) as { version?: string } | undefined
return { online: true, version: result?.version }
},
async add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult> {
if (!input.magnet && !input.torrentBase64) throw new Error('缺少磁力链或种子文件内容')
const dir = input.downloadDir || cfg.downloadDir
const options: Record<string, unknown> = {}
if (dir) options.dir = dir
// 磁力链需要先下载元数据,开启种子相关选项以便更好识别
options['bt-metadata-only'] = 'false'
const gid = input.magnet
? await call(cfg, 'aria2.addUri', [[input.magnet], options])
: await call(cfg, 'aria2.addTorrent', [input.torrentBase64, [], options])
return { ok: true, message: `已提交到 aria2(gid: ${String(gid)})`, detail: gid }
},
async getTasks(cfg: BtClientConfig): Promise<BtTask[]> {
// 获取正在下载、等待中、已停止的任务
const [active, waiting, stopped] = (await Promise.all([
call(cfg, 'aria2.tellActive', []),
call(cfg, 'aria2.tellWaiting', [0, 100]),
call(cfg, 'aria2.tellStopped', [0, 100])
])) as any[][]
const all = [...active, ...waiting, ...stopped]
return all.map((t) => {
const size = Number(t.totalLength) || 0
const downloaded = Number(t.completedLength) || 0
// aria2 的 infoHash 存在 bittorrent.info.hash
const infoHash = t.bittorrent?.info?.hash || t.infoHash || ''
return {
infoHash,
name: t.bittorrent?.info?.name || t.files?.[0]?.path || 'aria2-task',
progress: size > 0 ? downloaded / size : 0,
size,
downloaded,
uploaded: Number(t.uploadLength) || 0,
dlspeed: Number(t.downloadSpeed) || 0,
upspeed: Number(t.uploadSpeed) || 0,
status: mapAria2Status(t.status),
eta: calculateEta(size, downloaded, Number(t.downloadSpeed))
}
})
}
}
function mapAria2Status(status: string): BtTask['status'] {
switch (status) {
case 'active': return 'downloading'
case 'waiting': return 'queued'
case 'paused': return 'paused'
case 'error': return 'error'
case 'complete': return 'completed'
case 'removed': return 'unknown'
default: return 'unknown'
}
}
function calculateEta(total: number, current: number, speed: number): number | undefined {
if (speed <= 0 || total <= current) return undefined
return Math.floor((total - current) / speed)
}
+114
View File
@@ -0,0 +1,114 @@
import { baseUrlOf, loadSettings, type BtClientConfig } from '../appconfig'
import { aria2Adapter } from './aria2'
import { qbittorrentAdapter } from './qbittorrent'
import { transmissionAdapter } from './transmission'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ClientStatus } from './types'
export const ADAPTERS: BtClientAdapter[] = [qbittorrentAdapter, transmissionAdapter, aria2Adapter]
export function getAdapter(type: string): BtClientAdapter | undefined {
return ADAPTERS.find((a) => a.type === type)
}
export function endpointOf(cfg: BtClientConfig): string {
const adapter = getAdapter(cfg.type)
const path = cfg.path && cfg.path.trim() ? cfg.path : adapter?.defaultPath || ''
return `${baseUrlOf(cfg)}${path}`
}
export function friendlyClientError(err: unknown): string {
// undici 的 fetch 失败时外层只有 "fetch failed",真正的 ECONNREFUSED 藏在 err.cause 里
const parts: string[] = []
let current: unknown = err
for (let i = 0; i < 5 && current; i++) {
if (current instanceof Error) {
parts.push(`${(current as Error & { code?: string }).code || ''} ${current.message}`)
current = (current as Error & { cause?: unknown }).cause
} else {
parts.push(String(current))
break
}
}
const msg = parts.join(' | ')
if (/ECONNREFUSED/i.test(msg)) return '端口未监听:客户端没有启动,或地址 / 端口填错了'
if (/ETIMEDOUT|timeout|aborted|超时/i.test(msg)) return '连接超时:可能是防火墙拦截或地址填错了'
if (/ENOTFOUND|EAI_AGAIN/i.test(msg)) return '无法解析该主机名'
if (/self-signed|certificate|SSL|TLS/i.test(msg)) return 'TLS 证书错误:如果客户端是自签证书,请改用 http'
return err instanceof Error ? err.message : String(err)
}
/** 探测所有已启用的客户端,返回在线状态 */
export async function listClientStatuses(): Promise<ClientStatus[]> {
const settings = await loadSettings()
const targets = settings.btClients.filter((c) => c.enabled !== false)
if (!targets.length) return []
return await Promise.all(
targets.map(async (cfg): Promise<ClientStatus> => {
const adapter = getAdapter(cfg.type)
const base: ClientStatus = {
id: cfg.id,
type: cfg.type,
name: cfg.name,
label: adapter?.label || cfg.type,
endpoint: endpointOf(cfg),
online: false
}
if (!adapter) return { ...base, error: `不支持的客户端类型:${cfg.type}` }
try {
const result = await adapter.probe(cfg)
return { ...base, online: result.online, version: result.version, error: result.error }
} catch (err) {
return { ...base, error: friendlyClientError(err) }
}
})
)
}
export async function sendTask(
clientId: string,
input: AddTaskInput
): Promise<{ ok: boolean; message: string; client: BtClientConfig | null }> {
const settings = await loadSettings()
const cfg = settings.btClients.find((c) => c.id === clientId)
if (!cfg) throw new Error(`找不到客户端配置:${clientId}`)
const adapter = getAdapter(cfg.type)
if (!adapter) throw new Error(`不支持的客户端类型:${cfg.type}`)
const result: AddTaskResult = await adapter.add(cfg, input)
return { ok: result.ok, message: result.message, client: cfg }
}
export interface ClientTaskGroup {
clientId: string
clientName: string
online: boolean
error?: string
tasks: BtTask[]
}
/** 获取所有在线客户端的任务 */
export async function listAllTasks(): Promise<ClientTaskGroup[]> {
const settings = await loadSettings()
const targets = settings.btClients.filter((c) => c.enabled !== false)
return await Promise.all(
targets.map(async (cfg): Promise<ClientTaskGroup> => {
const adapter = getAdapter(cfg.type)
const group: ClientTaskGroup = {
clientId: cfg.id,
clientName: cfg.name,
online: false,
tasks: []
}
if (!adapter) return { ...group, error: `不支持的客户端类型:${cfg.type}` }
try {
const tasks = await adapter.getTasks(cfg)
return { ...group, online: true, tasks }
} catch (err) {
return { ...group, error: friendlyClientError(err) }
}
})
)
}
+122
View File
@@ -0,0 +1,122 @@
import { baseUrlOf, type BtClientConfig } from '../appconfig'
import { httpFetch } from '../http'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ProbeResult } from './types'
function base(cfg: BtClientConfig) {
return baseUrlOf(cfg)
}
/** 登录拿到 SID Cookie;新版 qBittorrent(5.x)Cookie 名为 QBT_SID_<port>,这里统一透传 */
async function login(cfg: BtClientConfig): Promise<string> {
const url = `${base(cfg)}/api/v2/auth/login`
const body = new URLSearchParams({
username: cfg.username || 'admin',
password: cfg.password || ''
})
const res = await httpFetch(url, {
method: 'POST',
body,
timeoutMs: 6000,
headers: {
'content-type': 'application/x-www-form-urlencoded',
referer: base(cfg),
origin: base(cfg)
}
})
const cookies = res.headers.getSetCookie?.() ?? []
const sid = cookies.map((c) => c.split(';')[0]).join('; ')
const text = (await res.text()).trim()
if (text === 'Fails.') throw new Error('qBittorrent 用户名或密码错误')
if (!res.ok || (text && text !== 'Ok.')) throw new Error(`qBittorrent 登录失败:${text || `HTTP ${res.status}`}`)
return sid
}
export const qbittorrentAdapter: BtClientAdapter = {
type: 'qbittorrent',
label: 'qBittorrent',
defaultPort: 8080,
async probe(cfg: BtClientConfig): Promise<ProbeResult> {
let sid = ''
try {
sid = await login(cfg)
} catch (err) {
// 有些配置开启了「本机免密」,登录失败但接口仍可访问,继续尝试读取版本
const fallback = await httpFetch(`${base(cfg)}/api/v2/app/version`, { timeoutMs: 5000 })
if (!fallback.ok) throw err
return { online: true, version: (await fallback.text()).trim().replace(/^v/, '') }
}
const res = await httpFetch(`${base(cfg)}/api/v2/app/version`, {
timeoutMs: 5000,
headers: sid ? { cookie: sid } : {}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return { online: true, version: (await res.text()).trim().replace(/^v/, '') }
},
async add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult> {
if (!input.magnet && !input.torrentBase64) throw new Error('缺少磁力链或种子文件内容')
const sid = await login(cfg)
const form = new FormData()
if (input.magnet) form.append('urls', input.magnet)
if (input.torrentBase64) {
const bytes = Buffer.from(input.torrentBase64, 'base64')
form.append('torrents', new Blob([bytes], { type: 'application/x-bittorrent' }), 'upload.torrent')
}
const savePath = input.downloadDir || cfg.downloadDir
if (savePath) form.append('savepath', savePath)
form.append('paused', 'false')
const res = await httpFetch(`${base(cfg)}/api/v2/torrents/add`, {
method: 'POST',
body: form,
timeoutMs: 15000,
headers: sid ? { cookie: sid, referer: base(cfg), origin: base(cfg) } : { referer: base(cfg) }
})
const text = (await res.text()).trim()
if (!res.ok) throw new Error(`qBittorrent 返回 HTTP ${res.status}${text ? `:${text}` : ''}`)
if (text === 'Fails.') throw new Error('qBittorrent 拒绝了该任务(可能是种子重复或路径不可用)')
return { ok: true, message: '已提交到 qBittorrent', detail: text }
},
async getTasks(cfg: BtClientConfig): Promise<BtTask[]> {
let sid = ''
try {
sid = await login(cfg)
} catch {
/* ignore login failure for local bypass */
}
const res = await httpFetch(`${base(cfg)}/api/v2/torrents/info`, {
headers: sid ? { cookie: sid } : {}
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const list = (await res.json()) as any[]
return list.map((t) => ({
infoHash: t.hash,
name: t.name,
progress: t.progress,
size: t.size,
downloaded: t.downloaded,
uploaded: t.uploaded,
dlspeed: t.dlspeed,
upspeed: t.upspeed,
status: mapQbtStatus(t.state),
eta: t.eta
}))
}
}
function mapQbtStatus(state: string): BtTask['status'] {
const s = state.toLowerCase()
if (s.includes('error') || s.includes('missing')) return 'error'
if (s.includes('checking')) return 'checking'
if (s.includes('paused')) return 'paused'
if (s.includes('queued')) return 'queued'
if (s.includes('dl')) return 'downloading'
if (s.includes('up')) return 'seeding'
if (s === 'completed' || s === 'uploading') return 'seeding'
return 'unknown'
}
+112
View File
@@ -0,0 +1,112 @@
import { baseUrlOf, type BtClientConfig } from '../appconfig'
import { httpFetch } from '../http'
import type { AddTaskInput, AddTaskResult, BtClientAdapter, ProbeResult } from './types'
function rpcUrl(cfg: BtClientConfig) {
const path = cfg.path && cfg.path.trim() ? cfg.path.trim() : '/transmission/rpc'
return `${baseUrlOf(cfg)}${path.startsWith('/') ? path : `/${path}`}`
}
function headersOf(cfg: BtClientConfig, sessionId?: string) {
const headers: Record<string, string> = { 'content-type': 'application/json' }
if (cfg.username) {
headers.authorization = `Basic ${Buffer.from(`${cfg.username}:${cfg.password || ''}`).toString('base64')}`
}
if (sessionId) headers['x-transmission-session-id'] = sessionId
return headers
}
/** Transmission 首次调用必定返回 409 并带上 session id,需要带着它重试一次 */
async function call(cfg: BtClientConfig, method: string, args: Record<string, unknown>) {
const send = (sessionId?: string) =>
httpFetch(rpcUrl(cfg), {
method: 'POST',
timeoutMs: 10000,
headers: headersOf(cfg, sessionId),
body: JSON.stringify({ method, arguments: args })
})
let res = await send()
if (res.status === 409) {
const sessionId = res.headers.get('x-transmission-session-id')
if (!sessionId) throw new Error('Transmission 返回 409 但没有 session id')
res = await send(sessionId)
}
if (res.status === 401) throw new Error('Transmission 认证失败,请检查用户名 / 密码')
if (!res.ok) throw new Error(`Transmission 返回 HTTP ${res.status}`)
const data = (await res.json()) as { result?: string; arguments?: Record<string, any> }
if (data.result && data.result !== 'success') throw new Error(`Transmission 调用失败:${data.result}`)
return data.arguments || {}
}
export const transmissionAdapter: BtClientAdapter = {
type: 'transmission',
label: 'Transmission',
defaultPort: 9091,
defaultPath: '/transmission/rpc',
async probe(cfg: BtClientConfig): Promise<ProbeResult> {
const args = await call(cfg, 'session-get', {})
return { online: true, version: args.version ? String(args.version) : undefined }
},
async add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult> {
if (!input.magnet && !input.torrentBase64) throw new Error('缺少磁力链或种子文件内容')
const args: Record<string, unknown> = {}
if (input.magnet) args.filename = input.magnet
else args.metainfo = input.torrentBase64
const dir = input.downloadDir || cfg.downloadDir
if (dir) args['download-dir'] = dir
const result = await call(cfg, 'torrent-add', args)
if (result['torrent-duplicate']) return { ok: true, message: 'Transmission 中已存在该任务(未重复添加)' }
const added = result['torrent-added']
return {
ok: true,
message: added?.name ? `已添加:${added.name}` : '已提交到 Transmission',
detail: result
}
},
async getTasks(cfg: BtClientConfig): Promise<BtTask[]> {
const fields = ['hashString', 'name', 'percentDone', 'totalSize', 'downloadedEver', 'uploadedEver', 'rateDownload', 'rateUpload', 'status', 'eta']
const result = await call(cfg, 'torrent-get', { fields })
const list = (result.torrents || []) as any[]
return list.map((t) => ({
infoHash: t.hashString,
name: t.name,
progress: Number(t.percentDone) || 0,
size: t.totalSize,
downloaded: t.downloadedEver,
uploaded: t.uploadedEver,
dlspeed: t.rateDownload,
upspeed: t.rateUpload,
status: mapTransmissionStatus(t.status),
eta: t.eta
}))
}
}
/**
* 0: STOPPED (Paused)
* 1: CHECK_WAIT
* 2: CHECK
* 3: DOWNLOAD_WAIT
* 4: DOWNLOAD
* 5: SEED_WAIT
* 6: SEED
*/
function mapTransmissionStatus(status: number): BtTask['status'] {
switch (status) {
case 0: return 'paused'
case 1:
case 2: return 'checking'
case 3:
case 5: return 'queued'
case 4: return 'downloading'
case 6: return 'seeding'
default: return 'unknown'
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { BtClientConfig, BtClientType } from '../appconfig'
export interface ProbeResult {
online: boolean
version?: string
error?: string
}
export interface AddTaskInput {
/** 磁力链 */
magnet?: string
/** .torrent 文件内容(base64) */
torrentBase64?: string
/** 覆盖客户端的默认下载目录 */
downloadDir?: string
/** 任务名,仅用于记录与显示 */
name?: string
}
export interface AddTaskResult {
ok: boolean
message: string
/** 客户端返回的原始信息,便于排错 */
detail?: unknown
}
export interface BtTask {
infoHash: string
name: string
progress: number
size: number
downloaded: number
uploaded: number
dlspeed: number
upspeed: number
status: 'downloading' | 'seeding' | 'paused' | 'error' | 'queued' | 'checking' | 'completed' | 'unknown'
eta?: number
}
export interface BtClientAdapter {
type: BtClientType
label: string
defaultPort: number
defaultPath?: string
probe(cfg: BtClientConfig): Promise<ProbeResult>
add(cfg: BtClientConfig, input: AddTaskInput): Promise<AddTaskResult>
/** 获取当前活跃或全部任务 */
getTasks(cfg: BtClientConfig): Promise<BtTask[]>
}
export interface ClientStatus {
id: string
type: BtClientType
name: string
label: string
endpoint: string
online: boolean
version?: string
error?: string
}
+54
View File
@@ -0,0 +1,54 @@
import { ProxyAgent, fetch as undiciFetch, type RequestInit } from 'undici'
const agentCache = new Map<string, ProxyAgent>()
export function getProxyAgent(proxy?: string | null): ProxyAgent | undefined {
const uri = (proxy || '').trim()
if (!uri) return undefined
let agent = agentCache.get(uri)
if (!agent) {
agent = new ProxyAgent(uri)
agentCache.set(uri, agent)
}
return agent
}
export interface HttpOptions extends RequestInit {
/** 走 HTTP/HTTPS 代理,例如 http://127.0.0.1:7890 */
proxy?: string | null
timeoutMs?: number
}
/** 统一出口:带超时 + 可选代理的 fetch */
export async function httpFetch(url: string, options: HttpOptions = {}) {
const { proxy, timeoutMs = 12000, ...init } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await undiciFetch(url, {
...init,
dispatcher: getProxyAgent(proxy),
signal: controller.signal
})
} catch (err) {
if (controller.signal.aborted) throw new Error(`请求超时(${timeoutMs}ms):${url}`)
throw err
} finally {
clearTimeout(timer)
}
}
export async function httpText(url: string, options: HttpOptions = {}): Promise<string> {
const res = await httpFetch(url, options)
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
return await res.text()
}
export async function httpJson<T = unknown>(url: string, options: HttpOptions = {}): Promise<T> {
const res = await httpFetch(url, options)
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
return (await res.json()) as T
}
export const DEFAULT_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
+93
View File
@@ -0,0 +1,93 @@
import { MongoClient, type Collection, type Db } from 'mongodb'
import type { DownloadDoc, SearchDoc, TorrentDoc } from './types'
let client: MongoClient | null = null
let connecting: Promise<MongoClient> | null = null
let indexState: 'pending' | 'done' = 'pending'
export const mongoUri = () => process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017'
export const mongoDbName = () => process.env.MONGODB_DB || 'torrent_console'
/** 最近一次连接失败信息,用于在 UI 上提示用户去装 / 启 MongoDB */
let lastError: { message: string; at: Date } | null = null
export async function getClient(): Promise<MongoClient> {
if (client) return client
if (!connecting) {
const next = new MongoClient(mongoUri(), {
serverSelectionTimeoutMS: 3000,
connectTimeoutMS: 3000
})
connecting = next
.connect()
.then((cli) => {
client = cli
connecting = null
lastError = null
return cli
})
.catch((err: unknown) => {
connecting = null
lastError = { message: err instanceof Error ? err.message : String(err), at: new Date() }
next.close().catch(() => {})
throw err
})
}
return connecting
}
export async function getDb(): Promise<Db> {
const cli = await getClient()
const db = cli.db(mongoDbName())
if (indexState === 'pending') {
await ensureIndexes(db)
indexState = 'done'
}
return db
}
async function ensureIndexes(db: Db) {
await db.collection<TorrentDoc>('torrents').createIndex({ infoHash: 1 }, { unique: true })
await db.collection<TorrentDoc>('torrents').createIndex({ updatedAt: -1 })
await db.collection<SearchDoc>('searches').createIndex({ createdAt: -1 })
await db.collection<SearchDoc>('searches').createIndex({ keyword: 1 })
await db.collection<DownloadDoc>('downloads').createIndex({ createdAt: -1 })
}
export async function torrentsCol(): Promise<Collection<TorrentDoc>> {
return (await getDb()).collection<TorrentDoc>('torrents')
}
export async function searchesCol(): Promise<Collection<SearchDoc>> {
return (await getDb()).collection<SearchDoc>('searches')
}
export async function downloadsCol(): Promise<Collection<DownloadDoc>> {
return (await getDb()).collection<DownloadDoc>('downloads')
}
/** 供 /api/health 使用:探测 MongoDB 是否可用 */
export async function mongoStatus() {
try {
const db = await getDb()
await db.command({ ping: 1 })
return { ok: true, uri: mongoUri(), db: mongoDbName(), error: null as string | null }
} catch (err) {
return {
ok: false,
uri: mongoUri(),
db: mongoDbName(),
error: lastError?.message || (err instanceof Error ? err.message : String(err))
}
}
}
/** MongoDB 不可用时的兜底:只记录日志,不打断主流程 */
export async function tryMongo<T>(fn: () => Promise<T>): Promise<T | null> {
try {
return await fn()
} catch (err) {
console.warn('[mongo] 操作失败,已跳过:', err instanceof Error ? err.message : err)
return null
}
}
+62
View File
@@ -0,0 +1,62 @@
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpJson } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
/** The Pirate Bay 分类编号 → 可读标签(只保留常见项) */
const TPB_CATEGORIES: Record<string, string> = {
'101': '音乐', '102': '有声书', '103': '视频', '104': '电影', '105': '剧集', '106': 'MV', '199': '其他',
'201': '软件', '202': '安卓', '203': 'Mac', '204': '游戏', '205': 'PC游戏', '206': 'Mac游戏', '207': '其他',
'301': '视频', '302': '视频', '303': '图片', '304': '其他', '399': '其他',
'401': '游戏', '402': 'PC游戏', '403': 'Mac游戏', '404': '其他',
'501': '其他', '502': '电子书', '503': '漫画', '504': '图片', '505': '其他', '599': '其他',
'601': '其他', '602': '电子书', '603': '音频', '604': '视频', '605': '其他', '699': '其他'
}
function apibayBase() {
return (process.env.APIBAY_BASE || 'https://apibay.org').replace(/\/+$/, '')
}
export const apibayProvider: SearchProvider = {
id: 'apibay',
label: 'The Pirate Bay',
homepage: 'https://thepiratebay.org',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `${apibayBase()}/q.php?q=${encodeURIComponent(query)}&cat=0`
const raw = await httpJson<any[]>(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/json', 'user-agent': DEFAULT_UA }
})
if (!Array.isArray(raw)) return []
return raw
.filter((it) => {
if (!it || typeof it.info_hash !== 'string') return false
if (it.info_hash === '0000000000000000000000000000000000000000') return false
if (typeof it.name === 'string' && /no results returned/i.test(it.name)) return false
return true
})
.map((it) => {
const infoHash = String(it.info_hash).toLowerCase()
const name = String(it.name || infoHash)
return {
infoHash,
name,
size: Number(it.size) || 0,
seeders: Number(it.seeders) || 0,
leechers: Number(it.leechers) || 0,
fileCount: Number(it.num_files) || null,
category: TPB_CATEGORIES[String(it.category)] || (it.category ? `分类 ${it.category}` : null),
source: 'apibay',
sourceLabel: 'The Pirate Bay',
sourceId: it.id ? String(it.id) : null,
sourceUrl: it.id ? `https://thepiratebay.org/description.php?id=${it.id}` : null,
addedAt: it.added ? new Date(Number(it.added) * 1000) : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
}
}
+145
View File
@@ -0,0 +1,145 @@
import { buildMagnet } from '../torrent'
import { DEFAULT_UA, httpText } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
const HOMEPAGE = 'https://cilisousuo.cc'
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1024,
kib: 1024,
mb: 1024 ** 2,
mib: 1024 ** 2,
gb: 1024 ** 3,
gib: 1024 ** 3,
tb: 1024 ** 4,
tib: 1024 ** 4
}
/** "330.3MB" / "1.1TB" → 字节数 */
function parseSize(input: unknown): number {
const text = String(input || '').trim()
const m = text.match(/^([\d.]+)\s*([a-zA-Z]+)?$/)
if (!m) return 0
const value = Number(m[1]) || 0
const unit = (m[2] || 'b').toLowerCase()
return Math.round(value * (SIZE_UNITS[unit] ?? 1))
}
function cilisousuoBase() {
return (process.env.CILISOUSUO_BASE || HOMEPAGE).replace(/\/+$/, '')
}
/** 去掉 HTML 标签并解码常见实体 */
function stripHtml(input: string): string {
return input
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#0?39;|&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim()
}
interface ListItem {
title: string
filename: string
size: number
code: string
}
/** 从列表页解析出条目(列表页只含名称/大小与 /magnet/{code},infoHash 在详情页) */
function parseList(html: string): ListItem[] {
const items: ListItem[] = []
const liRe = /<li class="item">([\s\S]*?)<\/li>/g
let m: RegExpExecArray | null
while ((m = liRe.exec(html))) {
const block = m[1]
const titleM = block.match(/<div class="result-title">([\s\S]*?)<\/div>/)
const fileM = block.match(/<div class="filename">([\s\S]*?)<\/div>/)
const sizeM = block.match(/<div class="size">([\s\S]*?)<\/div>/)
const codeM = block.match(/href="\/magnet\/([^"]+)"/)
if (!codeM) continue
const title = titleM ? stripHtml(titleM[1]) : ''
const filename = fileM ? stripHtml(fileM[1]) : ''
items.push({
title,
filename,
size: sizeM ? parseSize(sizeM[1]) : 0,
code: codeM[1]
})
}
return items
}
/** 并发受限地映射 */
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length)
let cursor = 0
async function worker() {
while (cursor < items.length) {
const i = cursor++
out[i] = await fn(items[i])
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
return out
}
async function fetchDetail(it: ListItem, ctx: ProviderContext): Promise<SearchResult | null> {
const url = `${cilisousuoBase()}/magnet/${it.code}`
const html = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: Math.min(ctx.timeoutMs, 8000),
headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': DEFAULT_UA }
})
const hashM = html.match(/xt=urn:btih:([a-f0-9]{40})/i)
if (!hashM) return null
const infoHash = hashM[1].toLowerCase()
const fileCountM = html.match(/文件数量[^\d]*<dd>\s*(\d+)/)
const fileCount = fileCountM ? Number(fileCountM[1]) : null
const name = it.title || it.filename || infoHash
return {
infoHash,
name,
size: it.size,
seeders: null,
leechers: null,
fileCount,
category: null,
source: 'cilisousuo',
sourceLabel: '磁力搜索',
sourceId: it.code,
sourceUrl: `${cilisousuoBase()}/magnet/${it.code}`,
addedAt: null,
magnet: buildMagnet(infoHash, name)
} satisfies SearchResult
}
export const cilisousuoProvider: SearchProvider = {
id: 'cilisousuo',
label: '磁力搜索',
homepage: HOMEPAGE,
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `${cilisousuoBase()}/search?q=${encodeURIComponent(query)}`
const html = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': DEFAULT_UA }
})
const items = parseList(html)
if (!items.length) return []
// 列表页不含 infoHash,逐条抓详情页补全磁力链;最多取前 24 条并发抓取
const capped = items.slice(0, 24)
const results = await mapWithConcurrency(capped, 5, (it) => fetchDetail(it, ctx).catch(() => null))
return results.filter((x): x is SearchResult => x !== null)
}
}
+86
View File
@@ -0,0 +1,86 @@
import { loadSettings } from '../appconfig'
import { apibayProvider } from './apibay'
import { cilisousuoProvider } from './cilisousuo'
import { nyaaProvider } from './nyaa'
import { solidtorrentsProvider } from './solidtorrents'
import type { ProviderReport, SearchProvider, SearchResult } from './types'
export const PROVIDERS: SearchProvider[] = [apibayProvider, nyaaProvider, solidtorrentsProvider, cilisousuoProvider]
export function listProviders() {
return PROVIDERS.map((p) => ({ id: p.id, label: p.label, homepage: p.homepage }))
}
function friendlyError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err)
if (/fetch failed|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up/i.test(msg)) {
return '网络不可达,通常是站点被墙,请在「设置」里配置 HTTP 代理后重试'
}
if (/aborted|超时/.test(msg)) return msg
if (/HTTP 5\d\d/.test(msg)) return `源站异常:${msg}`
if (/HTTP 4\d\d/.test(msg)) return `源站拒绝访问:${msg}`
return msg
}
export interface RunSearchOptions {
providers?: string[]
limit?: number
}
export interface RunSearchResult {
results: SearchResult[]
reports: ProviderReport[]
}
export async function runSearch(query: string, options: RunSearchOptions = {}): Promise<RunSearchResult> {
const settings = await loadSettings()
const wanted = options.providers?.length ? options.providers : settings.enabledProviders
const targets = PROVIDERS.filter((p) => wanted.includes(p.id))
const ctx = { proxy: settings.searchProxy || null, timeoutMs: settings.searchTimeout }
const reports = await Promise.all(
targets.map(async (provider): Promise<ProviderReport & { results: SearchResult[] }> => {
const started = Date.now()
try {
const results = await provider.search(query, ctx)
return {
id: provider.id,
label: provider.label,
ok: true,
count: results.length,
ms: Date.now() - started,
error: null,
results
}
} catch (err) {
return {
id: provider.id,
label: provider.label,
ok: false,
count: 0,
ms: Date.now() - started,
error: friendlyError(err),
results: []
}
}
})
)
// 按 infoHash 去重,保留做种数更高的那条
const merged = new Map<string, SearchResult>()
for (const report of reports) {
for (const item of report.results) {
const exists = merged.get(item.infoHash)
if (!exists || (item.seeders ?? 0) > (exists.seeders ?? 0)) merged.set(item.infoHash, item)
}
}
const results = [...merged.values()].sort(
(a, b) => (b.seeders ?? 0) - (a.seeders ?? 0) || b.size - a.size
)
return {
results: options.limit ? results.slice(0, options.limit) : results,
reports: reports.map(({ results: _omit, ...rest }) => rest)
}
}
+94
View File
@@ -0,0 +1,94 @@
import { XMLParser } from 'fast-xml-parser'
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpText } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1024,
kib: 1024,
mb: 1024 ** 2,
mib: 1024 ** 2,
gb: 1024 ** 3,
gib: 1024 ** 3,
tb: 1024 ** 4,
tib: 1024 ** 4
}
/** "1.23 GiB" / "456 MB" → 字节数 */
function parseSize(input: unknown): number {
if (typeof input === 'number') return input
const text = String(input || '').trim()
const m = text.match(/^([\d.]+)\s*([a-zA-Z]+)?$/)
if (!m) return 0
const value = Number(m[1]) || 0
const unit = (m[2] || 'b').toLowerCase()
return Math.round(value * (SIZE_UNITS[unit] ?? 1))
}
function nyaaBase() {
return (process.env.NYAA_BASE || 'https://nyaa.si').replace(/\/+$/, '')
}
function asArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined || value === null) return []
return Array.isArray(value) ? value : [value]
}
function pickString(value: unknown): string {
if (value === undefined || value === null) return ''
if (typeof value === 'object' && '#text' in (value as Record<string, unknown>)) {
return String((value as Record<string, unknown>)['#text'] ?? '')
}
return String(value)
}
export const nyaaProvider: SearchProvider = {
id: 'nyaa',
label: 'Nyaa',
homepage: 'https://nyaa.si',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
// c=0_0 全部分类;f=0 只过滤可信发布者之外的全部结果
const url = `${nyaaBase()}/?page=rss&q=${encodeURIComponent(query)}&c=0_0&f=0`
const xml = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/rss+xml, application/xml, text/xml', 'user-agent': DEFAULT_UA }
})
const parser = new XMLParser({ removeNSPrefix: true, trimValues: true, parseTagValue: false })
const doc = parser.parse(xml) as Record<string, any>
const items = asArray(doc?.rss?.channel?.item)
return items
.map((item: Record<string, any>) => {
const infoHash = pickString(item.infoHash).toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) return null
const name = pickString(item.title) || infoHash
const torrentUrl = pickString(item.guid) || pickString(item.link)
const category = pickString(item.category)
const pubDate = pickString(item.pubDate)
const added = pubDate ? new Date(pubDate) : null
return {
infoHash,
name,
size: parseSize(item.size),
seeders: Number(pickString(item.seeders)) || 0,
leechers: Number(pickString(item.leechers)) || 0,
fileCount: null,
category: category || null,
source: 'nyaa',
sourceLabel: 'Nyaa',
sourceId: pickString(item.link) || null,
sourceUrl: torrentUrl || null,
addedAt: added && !Number.isNaN(added.getTime()) ? added : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
.filter((item): item is SearchResult => item !== null)
}
}
+42
View File
@@ -0,0 +1,42 @@
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpJson } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
export const solidtorrentsProvider: SearchProvider = {
id: 'solidtorrents',
label: 'SolidTorrents',
homepage: 'https://solidtorrents.net',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `https://solidtorrents.net/api/v1/search?q=${encodeURIComponent(query)}&category=all&sort=seeders`
const raw = await httpJson<any>(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/json', 'user-agent': DEFAULT_UA }
})
if (!raw || !Array.isArray(raw.results)) return []
return raw.results.map((it: any) => {
const infoHash = String(it.infohash).toLowerCase()
const name = String(it.title || infoHash)
return {
infoHash,
name,
size: Number(it.size) || 0,
seeders: Number(it.swarm?.seeders) || 0,
leechers: Number(it.swarm?.leechers) || 0,
fileCount: null, // API doesn't seem to provide this in search list
category: it.category || null,
source: 'solidtorrents',
sourceLabel: 'SolidTorrents',
sourceId: null,
sourceUrl: `https://solidtorrents.to/view/${infoHash}`,
addedAt: it.createdAt ? new Date(it.createdAt) : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
}
}
+37
View File
@@ -0,0 +1,37 @@
export interface SearchResult {
infoHash: string
name: string
size: number
seeders: number | null
leechers: number | null
fileCount: number | null
category: string | null
source: string
sourceLabel: string
sourceId: string | null
sourceUrl: string | null
addedAt: Date | null
magnet: string
}
export interface ProviderContext {
proxy: string | null
timeoutMs: number
}
export interface SearchProvider {
id: string
label: string
/** 该站点所在的站点根地址,便于 UI 上提示 */
homepage: string
search(query: string, ctx: ProviderContext): Promise<SearchResult[]>
}
export interface ProviderReport {
id: string
label: string
ok: boolean
count: number
ms: number
error: string | null
}
+157
View File
@@ -0,0 +1,157 @@
import { downloadsCol, searchesCol, torrentsCol } from './mongo'
import type { SearchResult } from './search/types'
import type { TorrentInfo } from './torrent'
import type { DownloadDoc, SearchDoc, TorrentDoc } from './types'
function trackersFromMagnet(magnet: string): string[] {
const out: string[] = []
const re = /[?&]tr=([^&]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(magnet))) {
try {
out.push(decodeURIComponent(m[1]))
} catch {
out.push(m[1])
}
}
return out
}
/** 只保留不在 $set 里出现过的字段,避免 MongoDB 报「字段冲突」 */
function insertOnly(candidates: Record<string, unknown>, set: Record<string, unknown>) {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(candidates)) {
if (!(key in set)) out[key] = value
}
return out
}
export async function recordSearch(doc: Omit<SearchDoc, 'createdAt'>) {
const col = await searchesCol()
await col.insertOne({ ...doc, createdAt: new Date() })
}
/** 把搜索结果写入 torrents 集合(infoHash 唯一,重复搜索只更新易变字段) */
export async function upsertSearchResults(results: SearchResult[]) {
if (!results.length) return
const col = await torrentsCol()
const now = new Date()
await col.bulkWrite(
results.map((r) => {
const set: Record<string, unknown> = {
name: r.name,
size: r.size,
magnet: r.magnet,
trackers: trackersFromMagnet(r.magnet),
source: r.source,
sourceId: r.sourceId,
sourceUrl: r.sourceUrl,
category: r.category,
seeders: r.seeders,
leechers: r.leechers,
updatedAt: now
}
return {
updateOne: {
filter: { infoHash: r.infoHash },
update: {
$set: set,
$setOnInsert: {
infoHash: r.infoHash,
createdAt: now,
...insertOnly(
{
files: null,
fileCount: r.fileCount ?? null,
fileBase64: null,
comment: null,
creationDate: null,
resolvedAt: null,
resolveError: null
},
set
)
}
},
upsert: true
}
}
}),
{ ordered: false }
)
}
/** 把解析出来的种子信息写入 torrents 集合 */
export async function upsertParsedTorrent(
info: TorrentInfo,
extra: { fileBase64?: string | null; originalName?: string | null; source?: string } = {}
) {
const col = await torrentsCol()
const now = new Date()
const set: Record<string, unknown> = {
name: info.name,
size: info.size,
magnet: info.magnet,
trackers: info.trackers,
comment: info.comment,
creationDate: info.creationDate,
updatedAt: now
}
if (info.files) {
set.files = info.files
set.fileCount = info.files.length
set.resolvedAt = now
set.resolveError = null
}
if (extra.fileBase64) set.fileBase64 = extra.fileBase64
await col.updateOne(
{ infoHash: info.infoHash },
{
$set: set,
$setOnInsert: {
infoHash: info.infoHash,
createdAt: now,
source: extra.source || 'upload',
sourceId: null,
sourceUrl: null,
category: null,
seeders: null,
leechers: null,
...insertOnly(
{ files: null, fileCount: info.fileCount ?? null, fileBase64: null, resolvedAt: null, resolveError: null },
set
)
}
},
{ upsert: true }
)
}
export async function getTorrent(infoHash: string): Promise<TorrentDoc | null> {
const col = await torrentsCol()
return await col.findOne({ infoHash })
}
export async function markResolved(
infoHash: string,
data: { files: TorrentDoc['files']; size: number; name?: string } | null,
error: string | null
) {
const col = await torrentsCol()
const now = new Date()
const update: Record<string, unknown> = { resolveError: error, updatedAt: now }
if (data) {
update.files = data.files
update.fileCount = data.files ? data.files.length : null
update.size = data.size
update.resolvedAt = now
if (data.name) update.name = data.name
}
await col.updateOne({ infoHash }, { $set: update })
}
export async function recordDownload(doc: Omit<DownloadDoc, 'createdAt'>) {
const col = await downloadsCol()
await col.insertOne({ ...doc, createdAt: new Date() })
}
+90
View File
@@ -0,0 +1,90 @@
import { mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadWebTorrent, withDefaultTrackers } from './torrent'
/** 会话空闲多久后释放(毫秒) */
const IDLE_MS = 120_000
interface SwarmSession {
client: any
torrent: any
dir: string
ready: Promise<any>
timer: NodeJS.Timeout | null
}
const sessions = new Map<string, SwarmSession>()
/**
* 复用一个常驻的 webtorrent 会话来取文件内容。
* 每张图片都重建一次客户端就得重新找一遍节点(快则 1 秒,慢则十几秒),
* 所以同一个 infoHash 的连续预览共用一条会话,空闲 2 分钟自动释放。
*/
export async function withSwarm<T>(
infoHash: string,
magnet: string,
timeoutMs: number,
fn: (torrent: any) => Promise<T>
): Promise<T> {
const key = infoHash.toLowerCase()
let session = sessions.get(key)
if (!session) {
session = await createSession(key, magnet, timeoutMs)
sessions.set(key, session)
}
touchSession(key, session)
const torrent = await session.ready
return fn(torrent)
}
function touchSession(key: string, session: SwarmSession) {
if (session.timer) clearTimeout(session.timer)
session.timer = setTimeout(() => releaseSession(key), IDLE_MS)
session.timer.unref?.()
}
async function createSession(infoHash: string, magnet: string, timeoutMs: number): Promise<SwarmSession> {
const WebTorrent = await loadWebTorrent()
const dir = join(tmpdir(), `dsl-swarm-${infoHash.slice(0, 8)}-${Date.now().toString(36)}`)
await mkdir(dir, { recursive: true })
const client = new WebTorrent({ dht: true, lsd: false, utp: false })
client.on('error', () => {})
const torrent = client.add(withDefaultTrackers(magnet), { path: dir })
torrent.on('warning', () => {})
const ready = new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`获取种子元数据超时(${Math.round(timeoutMs / 1000)}s),可能没有可用节点或 tracker 被墙`))
}, timeoutMs)
const done = (fn: () => void) => {
clearTimeout(timer)
fn()
}
if (torrent.metadata) return done(() => resolve(torrent))
torrent.on('metadata', () => done(() => resolve(torrent)))
torrent.on('error', (err: Error) => done(() => reject(err)))
client.on('error', (err: Error) => done(() => reject(err)))
})
const session: SwarmSession = { client, torrent, dir, ready, timer: null }
touchSession(infoHash, session)
// 元数据都拿不到就别留着占资源
ready.catch(() => releaseSession(infoHash))
return session
}
function releaseSession(infoHash: string) {
const session = sessions.get(infoHash)
if (!session) return
sessions.delete(infoHash)
if (session.timer) clearTimeout(session.timer)
try {
session.client.destroy(() => {})
} catch {
/* ignore */
}
rm(session.dir, { recursive: true, force: true }).catch(() => {})
}
+242
View File
@@ -0,0 +1,242 @@
import { mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import parseTorrent, { toMagnetURI } from 'parse-torrent'
import type { TorrentFileEntry } from './types'
export interface TorrentInfo {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
comment: string | null
creationDate: Date | null
pieceLength: number | null
isPrivate: boolean
}
export const DEFAULT_TRACKERS = [
'udp://tracker.opentrackr.org:1337/announce',
'udp://open.tracker.cl:1337/announce',
'udp://open.demonii.com:1337/announce',
'udp://tracker.openbittorrent.com:6969/announce',
'udp://exodus.desync.com:6969/announce',
'udp://tracker.torrent.eu.org:451/announce'
]
export function buildMagnet(infoHash: string, name?: string | null, trackers: string[] = DEFAULT_TRACKERS): string {
const params = [`xt=urn:btih:${infoHash}`]
if (name) params.push(`dn=${encodeURIComponent(name)}`)
for (const tr of trackers) params.push(`tr=${encodeURIComponent(tr)}`)
return `magnet:?${params.join('&')}`
}
/** 从磁力链里取出已有的 tracker */
function trackersInMagnet(magnet: string): string[] {
const out = new Set<string>()
for (const m of magnet.matchAll(/[?&]tr=([^&]+)/g)) {
try {
out.add(decodeURIComponent(m[1]))
} catch {
/* 编码坏了就跳过 */
}
}
return [...out]
}
/**
* 给没有 tracker 的磁力链补上 tracker。
* 社区里流传的裸磁力链(只有 xt + dn)常常一个 tracker 都没有,
* 纯靠 DHT 在家庭网络/被墙环境里几乎拉不到元数据,补公共 tracker 能显著提高成功率。
*/
export function withDefaultTrackers(magnet: string, extra: string[] = []): string {
if (!magnet || /[?&]tr=/.test(magnet)) return magnet
const list = extra.length ? extra : DEFAULT_TRACKERS
return magnet + list.map((t) => `&tr=${encodeURIComponent(t)}`).join('')
}
function toInfoHash(value: unknown): string {
if (!value) return ''
if (typeof value === 'string') return value.toLowerCase()
if (value instanceof Uint8Array || Buffer.isBuffer(value)) return Buffer.from(value as Uint8Array).toString('hex')
return String(value).toLowerCase()
}
function pickTrackers(parsed: Record<string, unknown>): string[] {
const out = new Set<string>()
const announce = parsed.announce
if (typeof announce === 'string') out.add(announce)
else if (Array.isArray(announce)) for (const a of announce) if (typeof a === 'string') out.add(a)
const list = parsed.announceList
if (Array.isArray(list)) {
for (const tier of list) {
if (Array.isArray(tier)) for (const a of tier) if (typeof a === 'string') out.add(a)
}
}
return [...out]
}
function normalize(parsed: Record<string, any>, magnetFallback?: string): TorrentInfo {
const infoHash = toInfoHash(parsed.infoHash)
let files: TorrentFileEntry[] | null = null
if (Array.isArray(parsed.files) && parsed.files.length) {
files = parsed.files.map((f: any) => ({
// parse-torrent 用 path.sep 拼接,Windows 下是反斜杠,统一成正斜杠便于展示
path: String(f.path ?? f.name ?? '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
} else if (typeof parsed.length === 'number') {
// 单文件种子:parse-torrent 只给出 length + name
files = [{ path: String(parsed.name || 'unknown'), length: Number(parsed.length) || 0 }]
}
const size = files ? files.reduce((sum, f) => sum + (f.length || 0), 0) : Number(parsed.length) || 0
let trackers = pickTrackers(parsed)
let magnet = magnetFallback || ''
if (!magnet) {
try {
magnet = toMagnetURI(parsed as never)
} catch {
magnet = infoHash ? buildMagnet(infoHash, parsed.name, trackers.length ? trackers : DEFAULT_TRACKERS) : ''
}
}
magnet = withDefaultTrackers(magnet, trackers)
// 补过 tracker 的磁力链,trackers 以最终磁力链为准,避免 UI 显示“无 tracker”
if (!trackers.length) trackers = trackersInMagnet(magnet)
return {
infoHash,
name: String(parsed.name || ''),
size,
files,
fileCount: files ? files.length : null,
trackers,
magnet,
comment: typeof parsed.comment === 'string' ? parsed.comment : null,
creationDate: parsed.created instanceof Date
? parsed.created
: parsed.creationDate instanceof Date
? parsed.creationDate
: null,
pieceLength: typeof parsed.pieceLength === 'number' ? parsed.pieceLength : null,
isPrivate: parsed.private === true
}
}
/** 解析 .torrent 文件内容(parse-torrent v11 起为异步 API) */
export async function parseTorrentBuffer(buf: Buffer): Promise<TorrentInfo> {
let parsed: any
try {
parsed = await parseTorrent(buf)
} catch {
throw new Error('种子文件解析失败,可能不是合法的 .torrent 文件')
}
const info = normalize(parsed)
if (!info.infoHash) throw new Error('种子文件中缺少 info 字段,无法计算 infoHash')
return info
}
/** 解析磁力链(不含文件清单,需要通过 fetchMetadataFromMagnet 才能拿到) */
export async function parseMagnet(uri: string): Promise<TorrentInfo> {
const raw = uri.trim()
if (!/^magnet:\?/i.test(raw)) throw new Error('不是合法的磁力链接,应以 magnet:? 开头')
if (!/xt=urn:btih:[a-z0-9]+/i.test(raw)) throw new Error('磁力链接中缺少 xt=urn:btih: 参数')
let parsed: any
try {
parsed = await parseTorrent(raw)
} catch {
throw new Error('磁力链接解析失败')
}
const info = normalize(parsed, raw)
if (!info.infoHash) throw new Error('无法从磁力链接中提取 infoHash')
return info
}
/** 判断字符串是否为磁力链 */
export function isMagnet(input: string): boolean {
return /^magnet:\?/i.test(input.trim())
}
let webTorrentMod: unknown = null
export async function loadWebTorrent(): Promise<any> {
if (webTorrentMod) return webTorrentMod
try {
const mod: any = await import('webtorrent')
webTorrentMod = mod?.default || mod
return webTorrentMod
} catch {
throw new Error('未安装 webtorrent 依赖,无法通过磁力链在线获取文件清单(仅能显示 infoHash)')
}
}
export function hasWebTorrent(): boolean {
return webTorrentMod !== null
}
/** 探测 webtorrent 依赖是否可用(用于健康检查与 UI 提示) */
export async function checkWebTorrent(): Promise<boolean> {
try {
await loadWebTorrent()
return true
} catch {
return false
}
}
/**
* 通过磁力链从 DHT / tracker 拉取种子元数据,从而得到文件清单。
* 只取元数据,取到后立刻销毁客户端并清理临时目录。
*/
export async function fetchMetadataFromMagnet(
magnetUri: string,
timeoutMs = 30000
): Promise<{ name: string; files: TorrentFileEntry[]; size: number }> {
const WebTorrent = await loadWebTorrent()
const dir = join(tmpdir(), `dsl-meta-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`)
await mkdir(dir, { recursive: true })
const client = new WebTorrent({ dht: true, lsd: false, utp: false })
try {
// 纯 DHT 很可能找不到节点,没 tracker 的磁力链先补上公共 tracker
const torrent = client.add(withDefaultTrackers(magnetUri), { path: dir })
torrent.on('warning', () => {})
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`获取种子元数据超时(${Math.round(timeoutMs / 1000)}s),可能没有可用节点或 tracker 被墙`))
}, timeoutMs)
const done = (fn: () => void) => {
clearTimeout(timer)
fn()
}
if (torrent.metadata) return done(resolve)
torrent.on('metadata', () => done(resolve))
torrent.on('error', (err: Error) => done(() => reject(err)))
client.on('error', (err: Error) => done(() => reject(err)))
})
const files: TorrentFileEntry[] = (torrent.files || []).map((f: any) => ({
// Windows 下 webtorrent 用反斜杠拼路径,统一成正斜杠便于展示
path: String(f.path || f.name || '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
return {
name: String(torrent.name || ''),
files,
size: files.reduce((sum, f) => sum + f.length, 0)
}
} finally {
try {
// webtorrent v3 的 destroy 是回调式 API
await new Promise<void>((resolve) => client.destroy(() => resolve()))
} catch {
/* ignore */
}
rm(dir, { recursive: true, force: true }).catch(() => {})
}
}
+55
View File
@@ -0,0 +1,55 @@
/** 数据库文档结构定义(服务端内部使用) */
export interface TorrentFileEntry {
path: string
length: number
}
/** torrents 集合:种子元信息 + 解析结果缓存,infoHash 唯一 */
export interface TorrentDoc {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
source: string
sourceId: string | null
sourceUrl: string | null
category: string | null
seeders: number | null
leechers: number | null
comment: string | null
creationDate: Date | null
/** 用户上传的原始 .torrent 内容(base64,仅 <= 1MB 时保存) */
fileBase64: string | null
resolvedAt: Date | null
resolveError: string | null
createdAt: Date
updatedAt: Date
}
/** searches 集合:用户搜索记录 */
export interface SearchDoc {
keyword: string
providers: string[]
resultCount: number
durationMs: number
ok: boolean
error: string | null
createdAt: Date
}
/** downloads 集合:投递到本机 BT 客户端的记录 */
export interface DownloadDoc {
infoHash: string | null
name: string
clientId: string
clientType: string
clientName: string
downloadDir: string | null
ok: boolean
message: string
createdAt: Date
}