From 0cd3b34e112ec5639727df295c0011a8ff6c92bd Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 7 Jun 2026 14:24:23 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E4=B8=8B=E8=BD=BD=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + src/app/api/offline-download/route.ts | 4 +- src/lib/offline-downloader.ts | 203 +++++++++++--------------- 3 files changed, 88 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 55e0d74..582e006 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 | NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback | | NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false | | OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data | +| OFFLINE_DOWNLOAD_PROXY | 离线下载代理 | http://host:port | (空) | | VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) | | NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true | | MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 | diff --git a/src/app/api/offline-download/route.ts b/src/app/api/offline-download/route.ts index 3e670a5..888af01 100644 --- a/src/app/api/offline-download/route.ts +++ b/src/app/api/offline-download/route.ts @@ -12,6 +12,7 @@ import { OfflineDownloader, OfflineDownloadTask } from '@/lib/offline-downloader // 检查是否启用离线下载功能 const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true'; const OFFLINE_DOWNLOAD_DIR = process.env.OFFLINE_DOWNLOAD_DIR || '/data'; +const OFFLINE_DOWNLOAD_PROXY = process.env.OFFLINE_DOWNLOAD_PROXY || ''; // 全局下载器实例 let downloader: OfflineDownloader | null = null; @@ -88,10 +89,11 @@ function loadTasks(): void { function getDownloader(): OfflineDownloader { if (!downloader) { - downloader = new OfflineDownloader(OFFLINE_DOWNLOAD_DIR); + downloader = new OfflineDownloader(OFFLINE_DOWNLOAD_DIR, OFFLINE_DOWNLOAD_PROXY); // 首次初始化时加载已保存的任务 loadTasks(); } + return downloader; } diff --git a/src/lib/offline-downloader.ts b/src/lib/offline-downloader.ts index dd3b3e0..fbd5744 100644 --- a/src/lib/offline-downloader.ts +++ b/src/lib/offline-downloader.ts @@ -4,11 +4,14 @@ */ import * as fs from 'fs'; -import * as http from 'http'; -import * as https from 'https'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import nodeFetch, { RequestInit } from 'node-fetch'; import * as path from 'path'; import { URL } from 'url'; -import * as zlib from 'zlib'; + +type NodeFetchOptions = RequestInit & { + agent?: HttpsProxyAgent; +}; export interface OfflineDownloadTask { id: string; @@ -53,9 +56,11 @@ export class OfflineDownloader { private maxRetries = 3; private retryDelay = 1000; // ms private concurrency = 6; + private proxy?: string; - constructor(baseDir: string) { + constructor(baseDir: string, proxy?: string) { this.baseDir = baseDir; + this.proxy = proxy?.trim() || undefined; this.ensureDir(this.baseDir); } @@ -419,76 +424,83 @@ export class OfflineDownloader { }; } + /** + * 检测是否在 Cloudflare 环境中运行。Cloudflare Workers 不支持 Node.js Agent, + * 因此系统代理配置在该环境下无效。 + */ + private isCloudflareEnvironment(): boolean { + return ( + process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare' + ); + } + + private getRequestOptions(url: string, timeout = 30000): NodeFetchOptions { + const options: NodeFetchOptions = { + headers: this.getHeaders(url), + signal: AbortSignal.timeout(timeout) as unknown as RequestInit['signal'], + }; + + if (this.proxy && !this.isCloudflareEnvironment()) { + options.agent = new HttpsProxyAgent(this.proxy, { + timeout: 30000, + keepAlive: false, + }); + } + + return options; + } + + private async fetchUrl(url: string, timeout = 30000): Promise { + if (this.isCloudflareEnvironment()) { + return fetch(url, { + headers: this.getHeaders(url), + signal: AbortSignal.timeout(timeout), + }); + } + + return nodeFetch(url, this.getRequestOptions(url, timeout)) as unknown as Promise; + } + /** * 下载单个文件 */ private async downloadFile(url: string, savePath: string): Promise { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const client = urlObj.protocol === 'https:' ? https : http; + const response = await this.fetchUrl(url, 30000); - const options = { - headers: this.getHeaders(url), + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + if (!response.body) { + throw new Error('响应体为空'); + } + + const body = response.body as unknown as NodeJS.ReadableStream | null; + + if (!body || typeof body.pipe !== 'function') { + const arrayBuffer = await response.arrayBuffer(); + fs.writeFileSync(savePath, Buffer.from(arrayBuffer)); + return; + } + + await new Promise((resolve, reject) => { + const fileStream = fs.createWriteStream(savePath); + body.pipe(fileStream); + + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + + const handleError = (err: Error) => { + fs.unlink(savePath, () => { + // Ignore unlink errors + }); + reject(err); }; - const request = client.get(url, options, (response) => { - if (response.statusCode === 301 || response.statusCode === 302) { - // 处理重定向 - const redirectUrl = response.headers.location; - if (redirectUrl) { - this.downloadFile(redirectUrl, savePath).then(resolve).catch(reject); - return; - } - } - - if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode}`)); - return; - } - - // 检查内容编码,处理压缩 - const encoding = response.headers['content-encoding']; - let stream: NodeJS.ReadableStream = response; - - if (encoding === 'gzip') { - stream = response.pipe(zlib.createGunzip()); - } else if (encoding === 'deflate') { - stream = response.pipe(zlib.createInflate()); - } else if (encoding === 'br') { - stream = response.pipe(zlib.createBrotliDecompress()); - } - - const fileStream = fs.createWriteStream(savePath); - stream.pipe(fileStream); - - fileStream.on('finish', () => { - fileStream.close(); - resolve(); - }); - - fileStream.on('error', (err) => { - fs.unlink(savePath, () => { - // Ignore unlink errors - }); - reject(err); - }); - - stream.on('error', (err) => { - fs.unlink(savePath, () => { - // Ignore unlink errors - }); - reject(err); - }); - }); - - request.on('error', (err) => { - reject(err); - }); - - request.setTimeout(30000, () => { - request.destroy(); - reject(new Error('Request timeout')); - }); + fileStream.on('error', handleError); + body.on('error', handleError); }); } @@ -496,60 +508,13 @@ export class OfflineDownloader { * 获取内容(用于 M3U8 文件) */ private async fetchContent(url: string): Promise { - return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const client = urlObj.protocol === 'https:' ? https : http; + const response = await this.fetchUrl(url, 10000); - const options = { - headers: this.getHeaders(url), - }; + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } - const request = client.get(url, options, (response) => { - if (response.statusCode === 301 || response.statusCode === 302) { - const redirectUrl = response.headers.location; - if (redirectUrl) { - this.fetchContent(redirectUrl).then(resolve).catch(reject); - return; - } - } - - if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode}`)); - return; - } - - // 检查内容编码,处理压缩 - const encoding = response.headers['content-encoding']; - let stream: NodeJS.ReadableStream = response; - - if (encoding === 'gzip') { - stream = response.pipe(zlib.createGunzip()); - } else if (encoding === 'deflate') { - stream = response.pipe(zlib.createInflate()); - } else if (encoding === 'br') { - stream = response.pipe(zlib.createBrotliDecompress()); - } - - let data = ''; - stream.on('data', (chunk) => { - data += chunk.toString('utf-8'); - }); - - stream.on('end', () => { - resolve(data); - }); - - stream.on('error', (err) => { - reject(err); - }); - }); - - request.on('error', reject); - request.setTimeout(10000, () => { - request.destroy(); - reject(new Error('Request timeout')); - }); - }); + return response.text(); } /**