import fs from 'node:fs' import { Transform } from 'node:stream' import { pipeline } from 'node:stream/promises' import { logger } from 'node-karin' import type { AxiosInstance } from 'node-karin/axios' import { AxiosError } from 'node-karin/axios' import { calculateBackoffDelay, formatBytes, getErrorDescription, isRecoverableNetworkError, isThrottlingError, sanitizeHeaders } from './helpers' import { ThrottleStream } from './ThrottleStream' import type { CustomAxiosRequestConfig, DownloadResult, ProgressCallback, ThrottleConfig } from './types' import { DEFAULT_THROTTLE_CONFIG } from './types' /** * 正在下载的任务进度表。 * * QQ 面板上有个「📊 查询下载进度」按钮,用户点一下就知道现在下到哪了 —— * 不然解析大文件时群里只有一句「收到请求,开始下载」,完全看不到动静。 * 只保留最近 2 分钟内有更新的条目,避免这里变成内存垃圾堆。 */ export interface DownloadProgressEntry { /** 文件名(给用户看的) */ name: string /** 已下载字节 */ bytes: number /** 总字节(0 = 未知) */ total: number /** 最近一次更新的时间戳 */ at: number /** * 阶段说明。有它就能显示「正在获取下载链接」「正在合并音轨」这类 * **还没开始传输字节**的状态 —— 否则用户刚点完就查进度,只会看到「未正在下载」,很误导。 */ stage?: string } const activeDownloads = new Map() /** 清掉所有「阶段」条目(真正开始传字节时调用,避免和真实进度同时显示) */ const clearStages = () => { for (const key of [...activeDownloads.keys()]) { if (key.startsWith('stage:')) activeDownloads.delete(key) } } /** 登记一次进度(由下载器内部调用) */ export function reportDownloadProgress (filepath: string, bytes: number, total: number) { try { // 开始真正传字节了:收掉「获取下载链接」这类阶段条目,避免和真实进度同时显示 clearStages() const name = String(filepath ?? '').split(/[\\/]/).pop() || '未知文件' activeDownloads.set(filepath, { name, bytes, total, at: Date.now() }) if (activeDownloads.size > 64) activeDownloads.delete(activeDownloads.keys().next().value as string) } catch { /* 观测失败不影响下载 */ } } /** * 登记一个「还没开始下载」的阶段(获取下载链接 / 合并音轨等)。 * * @param key 任务标识(用任务名即可,避免和真实下载的文件路径冲突) * @param name 给用户看的名字 * @param stage 阶段说明 */ export function reportDownloadStage (key: string, name: string, stage: string) { try { lastStageNames.set(key, name) activeDownloads.set('stage:' + key, { name, bytes: 0, total: 0, at: Date.now(), stage }) if (activeDownloads.size > 64) activeDownloads.delete(activeDownloads.keys().next().value as string) } catch { /* 观测失败不影响流程 */ } } /** * 每个 key 最近一次用过的任务名。 * * 阶段之间会被 {@link clearParseStage} 清掉(免得状态卡死),但名字要留着 —— * 「正在烧录」「正在发送」这些出口拿不到平台名,得接着用「抖音解析」这个名字。 */ const lastStageNames = new Map() /** 结束阶段登记(进入真正下载或流程结束时调用) */ export function clearDownloadStage (key: string) { activeDownloads.delete('stage:' + key) } /* ------------------------------------------------------------------ * * 解析阶段(一次解析共用一个 key) * ------------------------------------------------------------------ */ /** * 一次解析共用的阶段 key。 * * 五个阶段(获取下载链接 / 合并音轨 / 烧录 / 准备在线播放 / 发送)都写同一个 key —— * `reportDownloadStage` 是**覆盖**语义,所以同一次解析在进度表里永远只有一条阶段记录, * 不会出现「正在烧录」和「正在发送」并排打架。 * (同一时刻只可能有一个解析在跑,这也是 parseTip 里原本的约定。) */ export const PARSE_STAGE_KEY = 'parse' /** * 阶段文案。 * * 集中放这里有两个好处:调用方各写一份中文容易写歪;冒烟测试也能直接引用同一份常量断言。 */ export const DOWNLOAD_STAGES = { /** 解析开始、还没拿到直链 */ fetching: '正在获取下载链接', /** 音视频合成 / 实况合成 */ merging: '正在合并音轨', /** 真烧录(把弹幕画进画面,要跑 ffmpeg) */ burning: '正在烧录', /** 在线播放:登记播放会话、准备播放页 */ preparingPlayer: '正在准备在线播放', /** 上传给聊天平台 */ sending: '正在发送' } as const /** * 更新当前解析的阶段(沿用已有条目的任务名)。 * * 「上传 / 烧录」这些出口拿不到平台名,用这里读到开始时登记的名字, * 用户看到的始终是「抖音解析 正在烧录」这种完整的一条。 * @param stage 阶段文案(用 {@link DOWNLOAD_STAGES} 里的常量) * @param fallbackName 阶段表里还没有条目时用的名字 */ export function updateDownloadStage (stage: string, fallbackName = '视频解析') { const existing = activeDownloads.get('stage:' + PARSE_STAGE_KEY) const name = existing?.name ?? lastStageNames.get(PARSE_STAGE_KEY) ?? fallbackName reportDownloadStage(PARSE_STAGE_KEY, name, stage) } /** 清掉当前解析的阶段条目(解析结束 / 失败兜底调用,别让状态卡死残留;任务名会留着给下一阶段用) */ export function clearParseStage () { clearDownloadStage(PARSE_STAGE_KEY) } /** * 在某个阶段里跑一段工作:开始登记阶段,**成功失败都清掉**。 * * 烧录 / 发送这类出口用它包一层就行,不用在每个函数体里各写一遍 try/finally。 * @param stage 阶段文案(用 {@link DOWNLOAD_STAGES} 里的常量) * @param run 真正要做的事 * @param fallbackName 阶段表里还没有条目时用的名字 */ export async function withDownloadStage ( stage: string, run: () => Promise, fallbackName = '视频解析' ): Promise { updateDownloadStage(stage, fallbackName) try { return await run() } finally { clearParseStage() } } /** 结束一次下载(成功/失败都要清掉,否则按钮会一直显示已完成的任务) */ export function clearDownloadProgress (filepath: string) { activeDownloads.delete(filepath) } /** 当前仍在进行的下载(60 秒内有过更新才算「进行中」) */ export function listActiveDownloads (): DownloadProgressEntry[] { const now = Date.now() const result: DownloadProgressEntry[] = [] for (const [key, value] of activeDownloads) { if (now - value.at > 60_000) { activeDownloads.delete(key) continue } result.push(value) } return result.sort((left, right) => right.at - left.at) } /* ------------------------------------------------------------------ * * 坏源判定阈值 * ------------------------------------------------------------------ */ /** * 换源前的热身时间:新源先给这么久证明自己。 * * B站 playurl 经常把直链指向 `*.mcdn.bilivideo.cn` 这类 PCDN 边缘节点: * TCP 连得上、响应头也回得来(content-length 都正常),就是几乎不吐数据 * (实测 118 KB / 60 秒 ≈ 2 KB/s)。在这种源上干等毫无意义。 */ const STALL_WARMUP_MS = 20_000 /** 热身期过后速度仍低于这个值(byte/s)且还有备用地址时,判定为坏源并换源 */ const STALL_MIN_SPEED = 64 * 1024 /** * 重试同一个源之前,本轮至少要传这么多字节,才值得原地重试。 * * 传了几 MB 再断,说明源本身是好的(只是被掐了),留着进度续传最划算; * 传了几百 KB 就断,那就是坏源,直接换。 */ const SWITCH_IF_UNDER_BYTES = 4 * 1024 * 1024 /** 单次尝试的硬上限:既没数据、又没有备用源可换的「涓流」源不能永远挂着 */ const MAX_ATTEMPT_MS = 30 * 60_000 /** 打个码的地址(只留 host,别把带签名的完整直链写进日志) */ const safeHost = (url: string): string => { try { return new URL(url).host } catch { return '(非法地址)' } } /** * 文件下载器 * 支持断点续传、限速下载、自动重试,以及**坏源自动换源** */ export class Downloader { private axiosInstance: AxiosInstance private url: string private filepath: string private headers: Record private timeout: number private maxRetries: number private throttleConfig: ThrottleConfig private currentSpeed: number private consecutiveResets: number /** 备用直链:当前源被判定为坏源时按顺序换过去(B站 dash 的 backup_url) */ private backupUrls: string[] /** * 本次尝试「被自己掐断」的原因: * - `timeout`:空闲超时(连续 timeout 毫秒没收到数据) * - `slow`:坏源看门狗主动放弃 * * 自己掐断的必须和「服务器重置」区分开 —— Node 把 abort 报成 * `Error: aborted`(code ECONNRESET),照抄进日志会把排查方向带偏。 */ private abortReason: 'timeout' | 'slow' | null = null /** 已经换过几次源(仅用于日志) */ private sourceSwitches = 0 constructor( axiosInstance: AxiosInstance, url: string, filepath: string, headers: Record, timeout: number, maxRetries: number, throttleConfig?: Partial, backupUrls: string[] = [] ) { this.axiosInstance = axiosInstance this.url = url this.filepath = filepath this.headers = headers this.timeout = timeout this.maxRetries = maxRetries this.throttleConfig = { ...DEFAULT_THROTTLE_CONFIG, ...throttleConfig } this.currentSpeed = this.throttleConfig.maxSpeed this.consecutiveResets = 0 this.backupUrls = [...backupUrls] } /** * 执行下载 * @param progressCallback 进度回调 * @param retryCount 当前重试次数 */ async download(progressCallback: ProgressCallback, retryCount = 0): Promise { // URL 校验 if (!this.url || !/^https?:\/\//i.test(this.url)) { const sanitized = sanitizeHeaders(this.headers) throw new Error(`Invalid URL: ${this.url || '(empty)'}, Headers: ${JSON.stringify(sanitized)}`) } if (!this.filepath) { throw new Error('未指定文件保存路径: filepath 为空') } // 每次尝试都从「没被掐断」开始 this.abortReason = null const controller = new AbortController() let intervalId: NodeJS.Timeout | null = null let throttleStream: ThrottleStream | null = null let writer: fs.WriteStream | null = null /** 本轮尝试收到的字节数(用于坏源判定,断点续传时只算这一轮新增的) */ let attemptBytes = 0 /** 本轮尝试的开始时间 */ const attemptStart = Date.now() let timeoutId: NodeJS.Timeout | null = null let attemptCapId: NodeJS.Timeout | null = null const clearTimers = () => { if (timeoutId) clearTimeout(timeoutId) if (attemptCapId) clearTimeout(attemptCapId) timeoutId = null attemptCapId = null } /** * 重新计时:**这是空闲超时,不是总时长超时**。 * * 以前这里是一个从头开始算的定时器(`setTimeout(abort, 60000)` 并且全程不再重置), * 于是任何需要超过 60 秒的文件(按 1.5 MB/s 算就是 90 MB 以上)都**永远下不完**: * 到点就被 abort,而 Node 把「自己 abort」报成 `Error: aborted`(code ECONNRESET), * 日志里就成了「下载失败: 连接被重置 (ECONNRESET): aborted」,把所有排查方向都带偏。 * 现在每收到一块数据就重新计时,只有**真的没数据**才超时。 */ const armIdleTimeout = () => { if (this.timeout <= 0) return if (timeoutId) clearTimeout(timeoutId) timeoutId = setTimeout(() => { this.abortReason = 'timeout' controller.abort() }, this.timeout) } armIdleTimeout() // 兜底:涓流源(一直有数据但慢得离谱、又没有备用源)不能在这一次尝试里挂几个小时 attemptCapId = setTimeout(() => { this.abortReason = 'timeout' controller.abort() }, MAX_ATTEMPT_MS) try { // 检查断点续传 let startByte = 0 if (fs.existsSync(this.filepath)) { const stats = fs.statSync(this.filepath) // 由于 stream.pipeline 出错时可能丢失内部缓冲区数据(最多约 32KB), // 保守起见回退 256KB 重新下载,避免文件损坏 startByte = Math.max(0, stats.size - 256 * 1024) if (startByte > 0 && startByte < stats.size) { // 截断文件到回退位置,避免 r+ 模式下旧数据残留在文件末尾 fs.truncateSync(this.filepath, startByte) logger.debug(`检测到部分下载文件,截断到 ${formatBytes(startByte)} 后重新下载(回退 256KB 安全裕量)`) } else if (startByte > 0) { logger.debug(`检测到部分下载文件,从 ${formatBytes(startByte)} 处继续下载(回退 256KB 安全裕量)`) } else { logger.debug('检测到部分下载文件,文件较小,将重新下载') } } // 构建请求配置 const requestConfig: CustomAxiosRequestConfig = { url: this.url, method: 'GET', responseType: 'stream', signal: controller.signal, headers: { ...this.headers }, skipRetry: true } // 断点续传 if (startByte > 0) { requestConfig.headers = { ...requestConfig.headers, Range: `bytes=${startByte}-` } } logger.debug('开始下载流', { url: this.url, headers: sanitizeHeaders(requestConfig.headers), throttleEnabled: this.throttleConfig.enabled, currentSpeed: this.throttleConfig.enabled ? formatBytes(this.currentSpeed) + '/s' : '不限速' }) const response = await this.axiosInstance(requestConfig) // 注意:这里**不**清除定时器 —— 传输阶段的空闲超时还得靠它(每收到数据会重新计时) // 检查 HTTP 状态码 // 416 Range Not Satisfiable if (response.status === 416) { logger.warn('服务器返回 416,文件可能已下载完成,验证文件大小...') if (fs.existsSync(this.filepath)) { const stats = fs.statSync(this.filepath) logger.debug(`当前文件大小: ${formatBytes(stats.size)}`) // 如果文件大小合理(大于 1KB),认为下载完成 if (stats.size > 1024) { logger.debug('文件大小合理,认为下载已完成') return { filepath: this.filepath, totalBytes: stats.size } } else { // 文件太小,删除并重新下载 logger.warn('文件太小,删除并重新下载') fs.unlinkSync(this.filepath) return this.download(progressCallback, retryCount + 1) } } } if (response.status !== 200 && response.status !== 206) { logger.error(`下载失败: HTTP ${response.status}, URL: ${this.url}`) logger.error(`响应头: ${JSON.stringify(response.headers)}`) // 如果响应体很小,可能是错误信息,尝试读取 if (response.headers['content-length'] && parseInt(response.headers['content-length']) < 10240) { let errorBody = '' response.data.on('data', (chunk: Buffer) => { errorBody += chunk.toString() }) await new Promise((resolve) => setTimeout(resolve, 100)) logger.error(`响应内容: ${errorBody}`) } throw new Error(`HTTP ${response.status}: ${this.url}`) } // 检查服务器是否支持断点续传 const supportsRange = response.status === 206 if (startByte > 0 && !supportsRange) { logger.warn('服务器不支持断点续传,将重新下载整个文件') if (fs.existsSync(this.filepath)) { fs.unlinkSync(this.filepath) } startByte = 0 } // 验证 206 响应的 Content-Range 起始位置,防止 CDN 返回错误范围导致文件损坏 if (supportsRange && response.headers['content-range']) { const contentRange = String(response.headers['content-range']) const rangeMatch = contentRange.match(/bytes\s*(\d+)-\d+\/\d+/) if (rangeMatch) { const responseStartByte = Number.parseInt(rangeMatch[1], 10) if (responseStartByte !== startByte) { logger.warn(`Content-Range 起始位置不匹配: 请求 ${startByte}, 实际 ${responseStartByte},将重新下载`) if (fs.existsSync(this.filepath)) { fs.unlinkSync(this.filepath) } startByte = 0 } } } // 解析内容长度 const rawContentLength = response.headers['content-length'] const contentLength = Number.parseInt(rawContentLength ?? '-1', 10) if (Number.isNaN(contentLength)) { const sanitized = sanitizeHeaders(this.headers) throw new Error(`无效的 content-length 响应头, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`) } const totalBytes = supportsRange ? startByte + contentLength : contentLength let downloadedBytes = startByte let lastPrintedPercentage = -1 // 创建写入流 // 使用 r+ 模式和 start 选项,从指定位置覆盖写入,避免 append 模式导致的数据错位 writer = fs.createWriteStream(this.filepath, { flags: startByte > 0 ? 'r+' : 'w', start: startByte > 0 ? startByte : undefined }) // 进度回调 const printProgress = () => { if (totalBytes > 0) { const progressPercentage = Math.floor((downloadedBytes / totalBytes) * 100) if (progressPercentage !== lastPrintedPercentage) { progressCallback(downloadedBytes, totalBytes) lastPrintedPercentage = progressPercentage } } else { progressCallback(downloadedBytes, totalBytes) } // 登记进度:面板上的「📊 查询下载进度」按钮读的就是这里 reportDownloadProgress(this.filepath, downloadedBytes, totalBytes) } const interval = totalBytes > 0 && totalBytes < 10 * 1024 * 1024 ? 1000 : 500 /** * 坏源看门狗:有备用地址时,热身期过了速度还低得离谱就直接放弃这个源。 * * 没有备用地址时不介入 —— 慢总比失败强,真正的死源交给空闲超时兜底。 */ const checkStall = () => { if (this.backupUrls.length === 0) return const elapsed = Date.now() - attemptStart if (elapsed < STALL_WARMUP_MS) return const speed = attemptBytes / (elapsed / 1000) if (speed >= STALL_MIN_SPEED) return logger.warn(`当前下载源速度过低 (${formatBytes(Math.round(speed))}/s),准备切换备用地址`) this.abortReason = 'slow' controller.abort() } intervalId = setInterval(() => { printProgress() checkStall() }, interval) // 创建计数流 const counterStream = new Transform({ transform(chunk, encoding, callback) { downloadedBytes += chunk.length attemptBytes += chunk.length // 还有数据在流,说明这个源活着:把空闲超时往后推 armIdleTimeout() callback(null, chunk) } }) // 根据配置决定是否使用限速流 if (this.throttleConfig.enabled) { throttleStream = new ThrottleStream(this.currentSpeed) logger.debug(`启用限速下载: ${formatBytes(this.currentSpeed)}/s`) await pipeline(response.data, throttleStream, counterStream, writer as fs.WriteStream) } else { await pipeline(response.data, counterStream, writer as fs.WriteStream) } // pipeline 已经等待所有流完成,包括 writer 的 finish 事件 logger.debug('文件下载并写入完成') // 验证文件大小 if (fs.existsSync(this.filepath)) { const stats = fs.statSync(this.filepath) const actualSize = stats.size const expectedSize = totalBytes > 0 ? totalBytes : downloadedBytes // 检查文件是否太小(可能是错误响应) if (actualSize < 1024 && expectedSize < 1024) { logger.error(`下载的文件异常小 (${formatBytes(actualSize)}),可能是错误响应`) // 尝试读取文件内容 try { const content = fs.readFileSync(this.filepath, 'utf-8') logger.error(`文件内容: ${content}`) } catch { logger.error('无法读取文件内容(可能是二进制文件)') } throw new Error(`下载的文件异常小: ${formatBytes(actualSize)},可能是错误响应或链接失效`) } if (actualSize < expectedSize) { logger.warn(`文件大小不匹配: 实际 ${formatBytes(actualSize)}, 预期 ${formatBytes(expectedSize)}`) logger.warn( `差异: ${formatBytes(expectedSize - actualSize)} (${(((expectedSize - actualSize) / expectedSize) * 100).toFixed(2)}%)` ) // 如果差异大于 10KB,认为下载不完整 if (expectedSize - actualSize > 10 * 1024) { throw new Error(`文件下载不完整: 实际 ${formatBytes(actualSize)}, 预期 ${formatBytes(expectedSize)}`) } } else { logger.debug(`文件大小验证通过: ${formatBytes(actualSize)}`) } } // 下载成功,重置连续重置计数 this.consecutiveResets = 0 clearDownloadProgress(this.filepath) return { filepath: this.filepath, totalBytes: totalBytes > 0 ? totalBytes : downloadedBytes } } catch (error) { clearTimers() if (intervalId) clearInterval(intervalId) // 失败/中断也要清掉,不然「查询下载进度」会一直卡在旧任务上 clearDownloadProgress(this.filepath) /** * 自己掐断的(空闲超时 / 坏源看门狗)不能按「服务器重置」处理。 * * Node 对 `request.abort()` 抛的是 `Error: aborted`(code ECONNRESET), * 直接套 code→文案的映射就会打印成「连接被重置」,既误导排查、 * 又会让下面的「服务器断流自动降速」误判(把一个坏源当成风控)。 */ const idleTimeout = this.abortReason === 'timeout' const slowSource = this.abortReason === 'slow' const ownAbort = idleTimeout || slowSource const isRecoverable = ownAbort || isRecoverableNetworkError(error) const isThrottling = !ownAbort && isThrottlingError(error) const errorDesc = idleTimeout ? `下载超时(${Math.round(this.timeout / 1000)} 秒内没有收到新数据)` : slowSource ? '下载源速度过低,主动放弃该源' : getErrorDescription(error) if (error instanceof AxiosError && !ownAbort) { const sanitized = sanitizeHeaders(this.headers) logger.error(`请求失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`) } else { logger.error(ownAbort ? `下载中断: ${errorDesc}` : `下载失败: ${errorDesc}`) } // 如果是断流错误,自动降速 if (isThrottling && this.throttleConfig.enabled) { this.consecutiveResets++ const newSpeed = Math.max(this.currentSpeed * this.throttleConfig.autoReduceRatio, this.throttleConfig.minSpeed) if (newSpeed < this.currentSpeed) { logger.warn( `检测到服务器断流 (连续 ${this.consecutiveResets} 次),自动降速: ${formatBytes(this.currentSpeed)}/s -> ${formatBytes(newSpeed)}/s` ) this.currentSpeed = newSpeed } else { logger.warn(`已达到最低速度限制 ${formatBytes(this.throttleConfig.minSpeed)}/s,无法继续降速`) } } /** * 坏源优先换源。 * * 同一份数据在别的镜像上往往能跑满带宽,而坏源上耗满 maxRetries 次 * (每次都要再等一个超时)纯属浪费时间;反过来,如果本轮已经传了好几 MB * 只是被掐了一下,那就该留在原源续传,别把进度扔掉。 */ if ((slowSource || (idleTimeout && attemptBytes < SWITCH_IF_UNDER_BYTES)) && this.backupUrls.length > 0) { const next = this.backupUrls.shift() as string this.sourceSwitches++ logger.warn( `当前源不可用,换备用下载源 (${this.sourceSwitches},剩余 ${this.backupUrls.length}): ` + `${safeHost(this.url)} -> ${safeHost(next)}` ) this.url = next await new Promise((resolve) => setTimeout(resolve, 1000)) return this.download(progressCallback, 0) } const nextDelay = calculateBackoffDelay(retryCount) if (retryCount < this.maxRetries) { // 等待 writer 完全关闭,确保异步写入操作已完成,避免 stat 获取到不准确的文件大小 if (writer && !(writer.closed ?? false)) { const ws = writer await new Promise((resolve) => { const timeout = setTimeout(() => { ws.off('close', onClose) resolve() }, 1000) const onClose = () => { clearTimeout(timeout) resolve() } ws.once('close', onClose) }) } if (isRecoverable && fs.existsSync(this.filepath)) { const stats = fs.statSync(this.filepath) logger.warn(`检测到可恢复的网络错误,保留已下载的 ${formatBytes(stats.size)} 数据`) logger.warn(`正在重试下载... (${retryCount + 1}/${this.maxRetries}),将在 ${nextDelay / 1000} 秒后使用断点续传重试`) } else { logger.warn(`正在重试下载... (${retryCount + 1}/${this.maxRetries}),将在 ${nextDelay / 1000} 秒后重试`) } await new Promise((resolve) => setTimeout(resolve, nextDelay)) return this.download(progressCallback, retryCount + 1) } else { // 最终失败处理 if (fs.existsSync(this.filepath)) { const stats = fs.statSync(this.filepath) if (isRecoverable && stats.size > 0) { logger.warn(`下载失败但保留了部分文件 (${formatBytes(stats.size)}): ${this.filepath}`) logger.warn('这可能是由于网络环境变化或服务器风控导致的,文件已保留供后续恢复') if (isThrottling) { logger.warn('建议: 服务器可能有下载速度限制,请尝试在配置中降低 maxSpeed 参数') } if (idleTimeout) { logger.warn('提示: 这条直链所属的 CDN 节点(常见于 *.mcdn.bilivideo.cn 这类 PCDN)很可能已失效,稍后重试或换个画质通常会换到别的节点') } } else { try { fs.unlinkSync(this.filepath) logger.debug('已清理部分下载的文件') } catch (cleanupError) { logger.warn('清理部分下载文件失败:', cleanupError) } } } const sanitized = sanitizeHeaders(this.headers) throw new Error(`在 ${this.maxRetries} 次尝试后下载失败: ${errorDesc}, URL: ${this.url}, Headers: ${JSON.stringify(sanitized)}`) } } finally { // 所有出口(成功 / 416 提前返回 / 换源 / 重试 / 抛错)都要把定时器和轮询清干净 clearTimers() if (intervalId) clearInterval(intervalId) } } /** * 手动设置下载速度 * @param speed 速度 (bytes/s) */ setSpeed(speed: number): void { this.currentSpeed = Math.max(speed, this.throttleConfig.minSpeed) logger.debug(`手动设置下载速度: ${formatBytes(this.currentSpeed)}/s`) } /** * 获取当前速度设置 */ getSpeed(): number { return this.currentSpeed } }