Files

123 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
}