mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-21 08:06:55 +00:00
464 lines
16 KiB
JavaScript
464 lines
16 KiB
JavaScript
/**
|
||
* 下载器服务 - 统一管理 Aria2/Motrix/Gopeed/迅雷 的配置读取、连接检测、RPC 调用
|
||
* 供 Home.vue、DirectoryTree.vue、DownloadDialog.vue 等共用
|
||
*/
|
||
import axios from 'axios'
|
||
|
||
const STORAGE_KEY = 'nfd-aria2-local-config'
|
||
|
||
const DEFAULT_CONFIG = {
|
||
downloaderType: 'aria2',
|
||
rpcUrl: 'http://localhost:6800/jsonrpc',
|
||
rpcSecret: '',
|
||
downloadDir: ''
|
||
}
|
||
|
||
/**
|
||
* 从 localStorage 读取下载器配置
|
||
* @returns {{ downloaderType: string, rpcUrl: string, rpcSecret: string, downloadDir: string }}
|
||
*/
|
||
export function getConfig() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY)
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw)
|
||
return {
|
||
downloaderType: parsed.downloaderType || DEFAULT_CONFIG.downloaderType,
|
||
rpcUrl: parsed.rpcUrl || DEFAULT_CONFIG.rpcUrl,
|
||
rpcSecret: parsed.rpcSecret || '',
|
||
downloadDir: parsed.downloadDir || ''
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('读取下载器配置失败', e)
|
||
}
|
||
return { ...DEFAULT_CONFIG }
|
||
}
|
||
|
||
/**
|
||
* 保存下载器配置到 localStorage
|
||
* @param {{ downloaderType?: string, rpcUrl?: string, rpcSecret?: string, downloadDir?: string }} config
|
||
*/
|
||
export function saveConfig(config) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||
}
|
||
|
||
/**
|
||
* 构建 RPC 参数数组(自动添加 token)
|
||
* @param {string} rpcSecret
|
||
* @param {Array} extraParams
|
||
* @returns {Array}
|
||
*/
|
||
function buildRpcParams(rpcSecret, extraParams = []) {
|
||
const params = []
|
||
if (rpcSecret && rpcSecret.trim()) {
|
||
params.push(`token:${rpcSecret}`)
|
||
}
|
||
if (extraParams && extraParams.length > 0) {
|
||
params.push(...extraParams)
|
||
}
|
||
return params
|
||
}
|
||
|
||
/**
|
||
* 调用 Aria2 JSON-RPC 接口
|
||
* @param {string} rpcUrl
|
||
* @param {string} rpcSecret
|
||
* @param {string} method - 例如 'aria2.getVersion', 'aria2.addUri'
|
||
* @param {Array} [extraParams] - 除 token 外的参数
|
||
* @param {number} [timeout=5000]
|
||
* @returns {Promise<Object>} RPC 响应的 data
|
||
*/
|
||
export async function callRpc(rpcUrl, rpcSecret, method, extraParams = [], timeout = 5000) {
|
||
const requestBody = {
|
||
jsonrpc: '2.0',
|
||
id: Date.now().toString(),
|
||
method,
|
||
params: buildRpcParams(rpcSecret, extraParams)
|
||
}
|
||
const response = await axios.post(rpcUrl, requestBody, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
timeout
|
||
})
|
||
if (response.data && response.data.error) {
|
||
throw new Error(response.data.error.message || 'Aria2 RPC 错误')
|
||
}
|
||
return response.data
|
||
}
|
||
|
||
/**
|
||
* 判断 rpcUrl 是否指向 Gopeed(端口 9999 或 URL 含 /api/v1)
|
||
* @param {string} url
|
||
* @returns {boolean}
|
||
*/
|
||
function isGopeedUrl(url) {
|
||
if (!url) return false
|
||
return url.includes(':9999') || url.includes('/api/v1')
|
||
}
|
||
|
||
/**
|
||
* 从 Gopeed rpcUrl 中提取 baseUrl(去掉 /jsonrpc 或 /api/v1 后缀)
|
||
* 例如 "http://localhost:9999/jsonrpc" → "http://localhost:9999"
|
||
* @param {string} rpcUrl
|
||
* @returns {string}
|
||
*/
|
||
function gopeedBaseUrl(rpcUrl) {
|
||
return rpcUrl.replace(/\/jsonrpc$/, '').replace(/\/api\/v1.*$/, '')
|
||
}
|
||
|
||
/**
|
||
* 调用 Gopeed REST API
|
||
* @param {string} baseUrl - 例如 "http://localhost:9999"
|
||
* @param {string} rpcSecret - Bearer token
|
||
* @param {string} method - 'GET' | 'POST'
|
||
* @param {string} path - 例如 '/api/v1/version'
|
||
* @param {Object} [body] - POST body
|
||
* @param {number} [timeout=5000]
|
||
* @returns {Promise<Object>} 响应 data
|
||
*/
|
||
async function callGopeedApi(baseUrl, rpcSecret, method, path, body, timeout = 5000) {
|
||
const headers = { 'Content-Type': 'application/json' }
|
||
if (rpcSecret && rpcSecret.trim()) {
|
||
headers['X-Api-Token'] = rpcSecret
|
||
}
|
||
const url = baseUrl.replace(/\/$/, '') + path
|
||
const response = await axios({ method, url, headers, data: body, timeout })
|
||
return response.data
|
||
}
|
||
|
||
/**
|
||
* 测试下载器连接(自动识别 迅雷 / Gopeed / Aria2 / Motrix)
|
||
* @param {string} [rpcUrl] - 不传则自动读取配置
|
||
* @param {string} [rpcSecret] - 不传则自动读取配置
|
||
* @returns {Promise<{ connected: boolean, version: string }>}
|
||
*/
|
||
export async function testConnection(rpcUrl, rpcSecret) {
|
||
if (!rpcUrl) {
|
||
const config = getConfig()
|
||
// 迅雷不需要 RPC,直接检测 JS SDK
|
||
if (config.downloaderType === 'thunder') {
|
||
const available = typeof window !== 'undefined' && window.thunderLink && typeof window.thunderLink.newTask === 'function'
|
||
return { connected: available, version: available ? 'JS-SDK' : '' }
|
||
}
|
||
rpcUrl = config.rpcUrl
|
||
rpcSecret = rpcSecret ?? config.rpcSecret
|
||
}
|
||
try {
|
||
if (isGopeedUrl(rpcUrl)) {
|
||
// Gopeed 使用 REST API:GET /api/v1/info
|
||
const base = gopeedBaseUrl(rpcUrl)
|
||
const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000)
|
||
const d = (res && res.code === 0 && res.data) ? res.data : {}
|
||
const version = [d.version, d.runtime].filter(Boolean).join(' + ') || ''
|
||
return { connected: true, version }
|
||
} else {
|
||
// Aria2 / Motrix 使用 JSON-RPC
|
||
const res = await callRpc(rpcUrl, rpcSecret || '', 'aria2.getVersion', [], 3000)
|
||
if (res && res.result && res.result.version) {
|
||
return { connected: true, version: res.result.version }
|
||
}
|
||
return { connected: false, version: '' }
|
||
}
|
||
} catch {
|
||
return { connected: false, version: '' }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 自动检测本地下载器(依次尝试 Motrix/Gopeed/Aria2)
|
||
* @param {string} [rpcSecret] - 可选密钥
|
||
* @returns {Promise<{ found: boolean, type: string, rpcUrl: string, version: string }>}
|
||
*/
|
||
export async function autoDetect(rpcSecret = '') {
|
||
const candidates = [
|
||
{ type: 'motrix', port: 16800, path: '/jsonrpc' },
|
||
{ type: 'gopeed', port: 9999, path: '/api/v1/info', gopeed: true },
|
||
{ type: 'aria2', port: 6800, path: '/jsonrpc' }
|
||
]
|
||
for (const c of candidates) {
|
||
try {
|
||
if (c.gopeed) {
|
||
// Gopeed:直接调 REST GET /api/v1/info
|
||
const base = `http://localhost:${c.port}`
|
||
const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000)
|
||
const d = (res && res.code === 0 && res.data) ? res.data : {}
|
||
const version = [d.version, d.runtime].filter(Boolean).join(' + ') || 'unknown'
|
||
return { found: true, type: c.type, rpcUrl: `${base}/api/v1`, version }
|
||
} else {
|
||
const url = `http://localhost:${c.port}${c.path}`
|
||
const result = await testConnection(url, rpcSecret)
|
||
if (result.connected) {
|
||
return { found: true, type: c.type, rpcUrl: url, version: result.version }
|
||
}
|
||
}
|
||
} catch {
|
||
// 该端口未响应,继续下一个
|
||
}
|
||
}
|
||
return { found: false, type: '', rpcUrl: '', version: '' }
|
||
}
|
||
|
||
/**
|
||
* 发送下载任务到下载器(自动识别 迅雷 / Gopeed / Aria2 / Motrix)
|
||
* @param {string} downloadUrl - 文件下载地址
|
||
* @param {Object} [headers] - 请求头 {cookie, referer, user-agent, ...}
|
||
* @param {string} [fileName] - 输出文件名
|
||
* @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride] - 覆盖配置
|
||
* @returns {Promise<string>} 任务 ID / GID
|
||
*/
|
||
export async function addDownload(downloadUrl, headers, fileName, configOverride) {
|
||
const config = { ...getConfig(), ...configOverride }
|
||
|
||
if (config.downloaderType === 'thunder') {
|
||
return addThunderDownload([{ url: downloadUrl, headers, fileName }], config)
|
||
}
|
||
|
||
if (isGopeedUrl(config.rpcUrl)) {
|
||
// Gopeed REST API:POST /api/v1/tasks
|
||
const base = gopeedBaseUrl(config.rpcUrl)
|
||
const extraHeader = {}
|
||
if (headers && typeof headers === 'object') {
|
||
for (const [key, value] of Object.entries(headers)) {
|
||
if (key && value) extraHeader[key] = value
|
||
}
|
||
}
|
||
const body = {
|
||
req: { url: downloadUrl, extra: { header: extraHeader } },
|
||
opt: {}
|
||
}
|
||
if (config.downloadDir) body.opt.path = config.downloadDir
|
||
const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks', body, 10000)
|
||
// Gopeed 返回 { code: 0, data: "task-id" }
|
||
if (res && res.code !== undefined && res.code !== 0) throw new Error(res.message || 'Gopeed 发送失败')
|
||
if (res && res.data) return typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
|
||
return 'ok'
|
||
}
|
||
|
||
// Aria2 / Motrix JSON-RPC
|
||
const options = {}
|
||
if (headers && typeof headers === 'object') {
|
||
const headerArray = []
|
||
for (const [key, value] of Object.entries(headers)) {
|
||
if (key && value) headerArray.push(`${key}: ${value}`)
|
||
}
|
||
if (headerArray.length > 0) options.header = headerArray
|
||
}
|
||
if (fileName) options.out = fileName
|
||
if (config.downloadDir) options.dir = config.downloadDir
|
||
|
||
const res = await callRpc(config.rpcUrl, config.rpcSecret, 'aria2.addUri', [[downloadUrl], options], 10000)
|
||
if (res && res.result) return res.result // GID
|
||
throw new Error('未知错误')
|
||
}
|
||
|
||
/**
|
||
* 批量发送下载任务到下载器(aria2 用 system.multicall,gopeed 用 batch API,迅雷用 JS-SDK newTask)
|
||
* @param {{ url: string, headers?: Object, fileName?: string }[]} tasks - 下载任务列表
|
||
* @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride]
|
||
* @returns {Promise<{ succeeded: number, failed: number, errors: string[] }>}
|
||
*/
|
||
export async function batchAddDownload(tasks, configOverride) {
|
||
if (!tasks || tasks.length === 0) return { succeeded: 0, failed: 0, errors: [] }
|
||
if (tasks.length === 1) {
|
||
try {
|
||
await addDownload(tasks[0].url, tasks[0].headers, tasks[0].fileName, configOverride)
|
||
return { succeeded: 1, failed: 0, errors: [] }
|
||
} catch (e) {
|
||
return { succeeded: 0, failed: 1, errors: [e.message || '未知错误'] }
|
||
}
|
||
}
|
||
|
||
const config = { ...getConfig(), ...configOverride }
|
||
|
||
if (config.downloaderType === 'thunder') {
|
||
try {
|
||
await addThunderDownload(tasks, config)
|
||
return { succeeded: tasks.length, failed: 0, errors: [] }
|
||
} catch (e) {
|
||
return { succeeded: 0, failed: tasks.length, errors: [e.message || '迅雷下载失败'] }
|
||
}
|
||
}
|
||
|
||
if (isGopeedUrl(config.rpcUrl)) {
|
||
return batchAddGopeed(tasks, config)
|
||
} else {
|
||
return batchAddAria2(tasks, config)
|
||
}
|
||
}
|
||
|
||
async function batchAddAria2(tasks, config) {
|
||
const calls = tasks.map(task => {
|
||
const options = {}
|
||
if (task.headers && typeof task.headers === 'object') {
|
||
const headerArray = []
|
||
for (const [key, value] of Object.entries(task.headers)) {
|
||
if (key && value) headerArray.push(`${key}: ${value}`)
|
||
}
|
||
if (headerArray.length > 0) options.header = headerArray
|
||
}
|
||
if (task.fileName) options.out = task.fileName
|
||
if (config.downloadDir) options.dir = config.downloadDir
|
||
|
||
const params = []
|
||
if (config.rpcSecret && config.rpcSecret.trim()) {
|
||
params.push(`token:${config.rpcSecret}`)
|
||
}
|
||
params.push([task.url], options)
|
||
return { methodName: 'aria2.addUri', params }
|
||
})
|
||
|
||
try {
|
||
const requestBody = {
|
||
jsonrpc: '2.0',
|
||
id: Date.now().toString(),
|
||
method: 'system.multicall',
|
||
params: [calls]
|
||
}
|
||
const response = await axios.post(config.rpcUrl, requestBody, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
timeout: Math.max(10000, tasks.length * 500)
|
||
})
|
||
const results = response.data && response.data.result
|
||
if (!Array.isArray(results)) {
|
||
throw new Error(response.data?.error?.message || 'system.multicall 返回异常')
|
||
}
|
||
let succeeded = 0, failed = 0
|
||
const errors = []
|
||
for (let i = 0; i < results.length; i++) {
|
||
const r = results[i]
|
||
if (Array.isArray(r) && r.length > 0 && typeof r[0] === 'string') {
|
||
succeeded++
|
||
} else if (r && r.faultCode) {
|
||
failed++
|
||
errors.push(`${tasks[i].fileName || tasks[i].url}: ${r.faultString || '未知错误'}`)
|
||
} else {
|
||
succeeded++
|
||
}
|
||
}
|
||
return { succeeded, failed, errors }
|
||
} catch (e) {
|
||
return { succeeded: 0, failed: tasks.length, errors: [e.message || 'multicall 请求失败'] }
|
||
}
|
||
}
|
||
|
||
async function batchAddGopeed(tasks, config) {
|
||
const base = gopeedBaseUrl(config.rpcUrl)
|
||
const reqs = tasks.map(task => {
|
||
const extraHeader = {}
|
||
if (task.headers && typeof task.headers === 'object') {
|
||
for (const [key, value] of Object.entries(task.headers)) {
|
||
if (key && value) extraHeader[key] = value
|
||
}
|
||
}
|
||
const item = { req: { url: task.url, extra: { header: extraHeader } } }
|
||
if (task.fileName) {
|
||
item.opts = { name: task.fileName }
|
||
}
|
||
return item
|
||
})
|
||
|
||
const body = { reqs }
|
||
if (config.downloadDir) body.opts = { path: config.downloadDir }
|
||
|
||
try {
|
||
const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks/batch', body,
|
||
Math.max(10000, tasks.length * 500))
|
||
if (res && res.code !== undefined && res.code !== 0) {
|
||
return { succeeded: 0, failed: tasks.length, errors: [res.message || 'Gopeed batch 失败'] }
|
||
}
|
||
const ids = Array.isArray(res?.data) ? res.data : []
|
||
return { succeeded: ids.length || tasks.length, failed: 0, errors: [] }
|
||
} catch (e) {
|
||
return { succeeded: 0, failed: tasks.length, errors: [e.message || 'Gopeed batch 请求失败'] }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过迅雷 JS-SDK 发送下载任务
|
||
* @param {{ url: string, headers?: Object, fileName?: string }[]} tasks
|
||
* @param {{ downloadDir?: string }} config
|
||
* @returns {Promise<string>}
|
||
*/
|
||
function addThunderDownload(tasks, config) {
|
||
if (typeof window === 'undefined' || !window.thunderLink || typeof window.thunderLink.newTask !== 'function') {
|
||
return Promise.reject(new Error('迅雷客户端未检测到,请确认已安装并启动迅雷'))
|
||
}
|
||
// 迅雷 JS-SDK 不支持自定义 Cookie,含 Cookie 的下载链接无法通过迅雷下载
|
||
const firstHeaders = (tasks[0] && tasks[0].headers) || {}
|
||
if (firstHeaders.cookie || firstHeaders.Cookie) {
|
||
return Promise.reject(new Error('该文件需要 Cookie 认证,迅雷不支持自定义 Cookie,请使用 Aria2/Motrix/Gopeed'))
|
||
}
|
||
|
||
// 遍历所有 header key 大小写不敏感地提取 referer / user-agent
|
||
let referer = ''
|
||
let userAgent = ''
|
||
for (const [key, value] of Object.entries(firstHeaders)) {
|
||
const lk = key.toLowerCase()
|
||
if (lk === 'referer' && value) referer = value
|
||
if (lk === 'user-agent' && value) userAgent = value
|
||
}
|
||
|
||
const taskParam = {
|
||
tasks: tasks.map(t => {
|
||
const item = { url: t.url }
|
||
if (t.fileName) item.name = t.fileName
|
||
return item
|
||
})
|
||
}
|
||
if (config.downloadDir) taskParam.downloadDir = config.downloadDir
|
||
if (referer) taskParam.referer = referer
|
||
if (userAgent) taskParam.userAgent = userAgent
|
||
taskParam.threadCount = '1'
|
||
|
||
console.log('[Thunder SDK] newTask params:', JSON.stringify(taskParam))
|
||
window.thunderLink.newTask(taskParam)
|
||
return Promise.resolve('thunder-ok')
|
||
}
|
||
|
||
/**
|
||
* 根据 RPC URL 猜测下载器类型
|
||
* @param {string} url
|
||
* @returns {string}
|
||
*/
|
||
export function guessDownloaderType(url) {
|
||
if (!url) return 'aria2'
|
||
if (url.includes(':16800')) return 'motrix'
|
||
if (url.includes(':9999')) return 'gopeed'
|
||
return 'aria2'
|
||
}
|
||
|
||
/**
|
||
* 检查下载头中是否含有 Cookie(迅雷不支持)
|
||
* @param {Object} [headers]
|
||
* @returns {boolean}
|
||
*/
|
||
export function hasCookieHeader(headers) {
|
||
if (!headers || typeof headers !== 'object') return false
|
||
return !!(headers.cookie || headers.Cookie)
|
||
}
|
||
|
||
/**
|
||
* 检查下载头中是否含有自定义 User-Agent(迅雷客户端可能不支持)
|
||
* @param {Object} [headers]
|
||
* @returns {boolean}
|
||
*/
|
||
export function hasCustomUaHeader(headers) {
|
||
if (!headers || typeof headers !== 'object') return false
|
||
for (const key of Object.keys(headers)) {
|
||
if (key.toLowerCase() === 'user-agent' && headers[key]) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
export default {
|
||
getConfig,
|
||
saveConfig,
|
||
callRpc,
|
||
testConnection,
|
||
autoDetect,
|
||
addDownload,
|
||
batchAddDownload,
|
||
guessDownloaderType,
|
||
hasCookieHeader
|
||
}
|