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
+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
}