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