feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/�?39;|'/g, "'")
|
||||
.replace(/"/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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user