feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user