Files
netdisk-fast-download/web-front/src/utils/downloaderService.js

464 lines
16 KiB
JavaScript
Raw 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.
/**
* 下载器服务 - 统一管理 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 APIGET /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 APIPOST /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.multicallgopeed 用 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
}