增加离线下载功能
This commit is contained in:
@@ -7,7 +7,6 @@
|
||||
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -28,6 +27,7 @@
|
||||
- 🪒**自定义去广告**:你可以自定义你的去广告代码,实现更强力的去广告功能
|
||||
- 🎭 **观影室**:支持多人同步观影、实时聊天、语音通话等功能(实验性)。
|
||||
- 📥 **M3U8完整下载**:通过合并m3u8片段实现完整视频下载。
|
||||
- 💾 **服务器离线下载**:支持在服务器端下载视频文件,支持断点续传,提前下载到家秒加载 。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
@@ -250,8 +250,10 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能 | true/false | false |
|
||||
| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,后续可能会开发外部服务器) | true/false | false |
|
||||
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||
|
||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||
|
||||
|
||||
@@ -4534,7 +4534,7 @@ const ThemeConfigComponent = ({
|
||||
)}
|
||||
</div>
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
启用后,用户浏览器会缓存CSS文件指定时间,减少服务器负载。修改主题后会自动更新缓存。
|
||||
启用后,用户浏览器会缓存CSS文件指定时间,减少服务器负载。启用该项可能会导致主题更新延迟。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 本地下载视频播放代理 API - 动态路由版本
|
||||
* 路径格式: /api/offline-download/local/[source]/[videoId]/[episodeIndex]/[file]
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// 检查是否启用离线下载功能
|
||||
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
||||
const OFFLINE_DOWNLOAD_DIR = process.env.OFFLINE_DOWNLOAD_DIR || '/data';
|
||||
|
||||
/**
|
||||
* 检查用户权限(仅管理员和站长)
|
||||
*/
|
||||
function checkPermission(request: NextRequest): boolean {
|
||||
if (!OFFLINE_DOWNLOAD_ENABLED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有管理员和站长可以访问
|
||||
return authInfo.role === 'owner' || authInfo.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - 代理本地视频文件(动态路由)
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { source: string; videoId: string; episodeIndex: string; file: string[] } }
|
||||
) {
|
||||
if (!checkPermission(request)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { source, videoId, episodeIndex, file } = params;
|
||||
const fileName = file.join('/'); // 支持嵌套路径
|
||||
|
||||
if (!source || !videoId || !episodeIndex || !fileName) {
|
||||
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 构建文件路径
|
||||
const downloadDir = path.join(
|
||||
OFFLINE_DOWNLOAD_DIR,
|
||||
source,
|
||||
videoId,
|
||||
`ep${parseInt(episodeIndex) + 1}`
|
||||
);
|
||||
const filePath = path.join(downloadDir, fileName);
|
||||
|
||||
// 安全检查:确保文件路径在下载目录内
|
||||
const normalizedFilePath = path.normalize(filePath);
|
||||
const normalizedDownloadDir = path.normalize(downloadDir);
|
||||
if (!normalizedFilePath.startsWith(normalizedDownloadDir)) {
|
||||
return NextResponse.json({ error: '非法路径' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
|
||||
// 如果是 m3u8 文件,需要修改内容使片段指向代理地址
|
||||
if (fileName === 'playlist.m3u8') {
|
||||
let content = fileBuffer.toString('utf-8');
|
||||
const lines = content.split('\n');
|
||||
const modifiedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 处理 Key URI
|
||||
if (trimmedLine.startsWith('#EXT-X-KEY:')) {
|
||||
const modifiedLine = trimmedLine.replace(
|
||||
/URI="([^"]+)"/,
|
||||
`URI="/api/offline-download/local/${source}/${videoId}/${episodeIndex}/$1"`
|
||||
);
|
||||
modifiedLines.push(modifiedLine);
|
||||
}
|
||||
// 处理 ts 片段
|
||||
else if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
modifiedLines.push(
|
||||
`/api/offline-download/local/${source}/${videoId}/${episodeIndex}/${trimmedLine}`
|
||||
);
|
||||
} else {
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
content = modifiedLines.join('\n');
|
||||
|
||||
return new NextResponse(content, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 其他文件(ts、key 等)直接返回
|
||||
const contentType = fileName.endsWith('.ts')
|
||||
? 'video/mp2t'
|
||||
: fileName.endsWith('.key')
|
||||
? 'application/octet-stream'
|
||||
: 'application/octet-stream';
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('代理本地文件失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '代理失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
132
src/app/api/offline-download/local/route.ts
Normal file
132
src/app/api/offline-download/local/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 本地下载视频播放代理 API
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// 检查是否启用离线下载功能
|
||||
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
||||
const OFFLINE_DOWNLOAD_DIR = process.env.OFFLINE_DOWNLOAD_DIR || '/data';
|
||||
|
||||
/**
|
||||
* 检查用户权限(仅管理员和站长)
|
||||
*/
|
||||
function checkPermission(request: NextRequest): boolean {
|
||||
if (!OFFLINE_DOWNLOAD_ENABLED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有管理员和站长可以访问
|
||||
return authInfo.role === 'owner' || authInfo.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - 代理本地视频文件
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!checkPermission(request)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = searchParams.get('source');
|
||||
const videoId = searchParams.get('videoId');
|
||||
const episodeIndex = searchParams.get('episodeIndex');
|
||||
const file = searchParams.get('file'); // 'playlist.m3u8', 'segment_00001.ts', 'key.key' 等
|
||||
|
||||
if (!source || !videoId || episodeIndex === null || !file) {
|
||||
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 构建文件路径
|
||||
const downloadDir = path.join(
|
||||
OFFLINE_DOWNLOAD_DIR,
|
||||
source,
|
||||
videoId,
|
||||
`ep${parseInt(episodeIndex) + 1}`
|
||||
);
|
||||
const filePath = path.join(downloadDir, file);
|
||||
|
||||
// 安全检查:确保文件路径在下载目录内
|
||||
const normalizedFilePath = path.normalize(filePath);
|
||||
const normalizedDownloadDir = path.normalize(downloadDir);
|
||||
if (!normalizedFilePath.startsWith(normalizedDownloadDir)) {
|
||||
return NextResponse.json({ error: '非法路径' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
|
||||
// 如果是 m3u8 文件,需要修改内容使片段指向代理地址
|
||||
if (file === 'playlist.m3u8') {
|
||||
let content = fileBuffer.toString('utf-8');
|
||||
const lines = content.split('\n');
|
||||
const modifiedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 处理 Key URI
|
||||
if (trimmedLine.startsWith('#EXT-X-KEY:')) {
|
||||
const modifiedLine = trimmedLine.replace(
|
||||
/URI="([^"]+)"/,
|
||||
`URI="/api/offline-download/local?source=${source}&videoId=${videoId}&episodeIndex=${episodeIndex}&file=$1"`
|
||||
);
|
||||
modifiedLines.push(modifiedLine);
|
||||
}
|
||||
// 处理 ts 片段
|
||||
else if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
modifiedLines.push(
|
||||
`/api/offline-download/local?source=${source}&videoId=${videoId}&episodeIndex=${episodeIndex}&file=${trimmedLine}`
|
||||
);
|
||||
} else {
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
content = modifiedLines.join('\n');
|
||||
|
||||
return new NextResponse(content, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 其他文件(ts、key 等)直接返回
|
||||
const contentType = file.endsWith('.ts')
|
||||
? 'video/mp2t'
|
||||
: file.endsWith('.key')
|
||||
? 'application/octet-stream'
|
||||
: 'application/octet-stream';
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('代理本地文件失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '代理失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
411
src/app/api/offline-download/route.ts
Normal file
411
src/app/api/offline-download/route.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 离线下载任务管理 API
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { OfflineDownloader, OfflineDownloadTask } from '@/lib/offline-downloader';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// 检查是否启用离线下载功能
|
||||
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
||||
const OFFLINE_DOWNLOAD_DIR = process.env.OFFLINE_DOWNLOAD_DIR || '/data';
|
||||
|
||||
// 全局下载器实例
|
||||
let downloader: OfflineDownloader | null = null;
|
||||
|
||||
// 任务存储(内存中)
|
||||
const tasks = new Map<string, OfflineDownloadTask>();
|
||||
|
||||
// 活跃的下载Promise
|
||||
const activeDownloads = new Map<string, Promise<void>>();
|
||||
|
||||
// 任务持久化文件路径
|
||||
const TASKS_FILE = path.join(OFFLINE_DOWNLOAD_DIR, 'tasks.json');
|
||||
|
||||
/**
|
||||
* 保存任务到文件
|
||||
*/
|
||||
function saveTasks(): void {
|
||||
try {
|
||||
const tasksArray = Array.from(tasks.values()).map((task) => ({
|
||||
...task,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
updatedAt: task.updatedAt.toISOString(),
|
||||
}));
|
||||
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(TASKS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(TASKS_FILE, JSON.stringify(tasksArray, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('保存任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载任务
|
||||
*/
|
||||
function loadTasks(): void {
|
||||
try {
|
||||
console.log('尝试加载任务文件:', TASKS_FILE);
|
||||
|
||||
if (!fs.existsSync(TASKS_FILE)) {
|
||||
console.log('任务文件不存在:', TASKS_FILE);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(TASKS_FILE, 'utf-8');
|
||||
const tasksArray = JSON.parse(content);
|
||||
console.log(`从文件读取到 ${tasksArray.length} 个任务`);
|
||||
|
||||
for (const taskData of tasksArray) {
|
||||
const task: OfflineDownloadTask = {
|
||||
...taskData,
|
||||
createdAt: new Date(taskData.createdAt),
|
||||
updatedAt: new Date(taskData.updatedAt),
|
||||
};
|
||||
|
||||
// 如果任务在下载或等待中,说明服务器重启了,将状态改为暂停
|
||||
if (task.status === 'downloading' || task.status === 'pending') {
|
||||
task.status = 'paused';
|
||||
task.errorMessage = '服务器重启,任务已暂停';
|
||||
}
|
||||
|
||||
tasks.set(task.id, task);
|
||||
}
|
||||
|
||||
console.log(`已加载 ${tasks.size} 个离线下载任务到内存`);
|
||||
} catch (error) {
|
||||
console.error('加载任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloader(): OfflineDownloader {
|
||||
if (!downloader) {
|
||||
downloader = new OfflineDownloader(OFFLINE_DOWNLOAD_DIR);
|
||||
// 首次初始化时加载已保存的任务
|
||||
loadTasks();
|
||||
}
|
||||
return downloader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户权限(仅管理员和站长)
|
||||
*/
|
||||
function checkPermission(request: NextRequest): boolean {
|
||||
if (!OFFLINE_DOWNLOAD_ENABLED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有管理员和站长可以使用
|
||||
return authInfo.role === 'owner' || authInfo.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - 获取任务列表或检查下载状态
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!checkPermission(request)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 确保下载器已初始化(这会触发任务加载)
|
||||
getDownloader();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
|
||||
// 检查视频是否已下载
|
||||
if (action === 'check') {
|
||||
const source = searchParams.get('source');
|
||||
const videoId = searchParams.get('videoId');
|
||||
const episodeIndex = searchParams.get('episodeIndex');
|
||||
|
||||
if (!source || !videoId || episodeIndex === null) {
|
||||
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
|
||||
}
|
||||
|
||||
const downloader = getDownloader();
|
||||
const downloaded = downloader.checkDownloaded(source, videoId, parseInt(episodeIndex));
|
||||
|
||||
return NextResponse.json({ downloaded });
|
||||
}
|
||||
|
||||
// 获取所有任务列表
|
||||
const taskList = Array.from(tasks.values()).map((task) => ({
|
||||
...task,
|
||||
// 转换 Date 对象为 ISO 字符串
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
updatedAt: task.updatedAt.toISOString(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ tasks: taskList });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - 创建离线下载任务
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!checkPermission(request)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { source, videoId, episodeIndex, title, m3u8Url, metadata } = body;
|
||||
|
||||
if (!source || !videoId || episodeIndex === undefined || !title || !m3u8Url) {
|
||||
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
|
||||
}
|
||||
|
||||
const downloader = getDownloader();
|
||||
|
||||
// 1. 首先检查是否已经有相同的任务(任何状态)
|
||||
const existingTask = Array.from(tasks.values()).find(
|
||||
(t) =>
|
||||
t.source === source &&
|
||||
t.videoId === videoId &&
|
||||
t.episodeIndex === episodeIndex
|
||||
);
|
||||
|
||||
if (existingTask) {
|
||||
// 如果任务正在下载或等待中,不允许重复创建
|
||||
if (existingTask.status === 'downloading' || existingTask.status === 'pending') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
task: {
|
||||
...existingTask,
|
||||
createdAt: existingTask.createdAt.toISOString(),
|
||||
updatedAt: existingTask.updatedAt.toISOString(),
|
||||
},
|
||||
message: '该任务正在下载中,请勿重复添加',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果任务已完成,不允许重复创建
|
||||
if (existingTask.status === 'completed') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
task: {
|
||||
...existingTask,
|
||||
createdAt: existingTask.createdAt.toISOString(),
|
||||
updatedAt: existingTask.updatedAt.toISOString(),
|
||||
},
|
||||
message: '该视频已下载完成,如需重新下载请先删除任务',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 如果任务处于错误或暂停状态,提示用户使用重试功能
|
||||
if (existingTask.status === 'error' || existingTask.status === 'paused') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
task: {
|
||||
...existingTask,
|
||||
createdAt: existingTask.createdAt.toISOString(),
|
||||
updatedAt: existingTask.updatedAt.toISOString(),
|
||||
},
|
||||
message: '该任务已存在但未完成,请使用重试功能继续下载',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查文件系统中是否已下载完成(防止任务被删除但文件还在的情况)
|
||||
const downloaded = downloader.checkDownloaded(source, videoId, episodeIndex);
|
||||
if (downloaded) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: '该视频文件已存在,无需重复下载',
|
||||
downloaded: true,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建新任务
|
||||
const task = await downloader.createTask(source, videoId, episodeIndex, title, m3u8Url, metadata);
|
||||
tasks.set(task.id, task);
|
||||
saveTasks(); // 持久化任务
|
||||
|
||||
// 开始下载(异步)
|
||||
const downloadPromise = downloader
|
||||
.startDownload(task, (updatedTask) => {
|
||||
// 更新任务状态
|
||||
tasks.set(updatedTask.id, updatedTask);
|
||||
saveTasks(); // 持久化任务
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('下载失败:', error);
|
||||
task.status = 'error';
|
||||
task.errorMessage = error.message;
|
||||
tasks.set(task.id, task);
|
||||
saveTasks(); // 持久化任务
|
||||
})
|
||||
.finally(() => {
|
||||
// 下载完成后,从活跃下载列表中移除
|
||||
activeDownloads.delete(task.id);
|
||||
});
|
||||
|
||||
activeDownloads.set(task.id, downloadPromise);
|
||||
|
||||
return NextResponse.json({
|
||||
task: {
|
||||
...task,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
updatedAt: task.updatedAt.toISOString(),
|
||||
},
|
||||
message: '任务已创建',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建任务失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '创建任务失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE - 删除任务
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
if (!checkPermission(request)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = searchParams.get('taskId');
|
||||
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ error: '缺少任务ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = tasks.get(taskId);
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const downloader = getDownloader();
|
||||
|
||||
// 删除文件
|
||||
await downloader.deleteTask(task);
|
||||
|
||||
// 从任务列表中移除
|
||||
tasks.delete(taskId);
|
||||
saveTasks(); // 持久化任务
|
||||
|
||||
// 如果正在下载,等待下载完成后再删除
|
||||
const downloadPromise = activeDownloads.get(taskId);
|
||||
if (downloadPromise) {
|
||||
activeDownloads.delete(taskId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: '任务已删除' });
|
||||
} catch (error) {
|
||||
console.error('删除任务失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '删除任务失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT - 重试任务
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!checkPermission(request)) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const taskId = searchParams.get('taskId');
|
||||
const action = searchParams.get('action');
|
||||
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ error: '缺少任务ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action !== 'retry') {
|
||||
return NextResponse.json({ error: '无效的操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = tasks.get(taskId);
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查任务状态,只有错误、暂停或完成状态可以重试
|
||||
if (task.status === 'downloading' || task.status === 'pending') {
|
||||
return NextResponse.json({ error: '任务正在进行中,无法重试' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查是否已经在重试中
|
||||
if (activeDownloads.has(taskId)) {
|
||||
return NextResponse.json({ error: '任务已在重试中' }, { status: 400 });
|
||||
}
|
||||
|
||||
const downloader = getDownloader();
|
||||
|
||||
// 重置任务状态(保留已下载的进度,只重试失败的片段)
|
||||
task.status = 'pending';
|
||||
// 不重置 progress 和 downloadedSegments,让下载器自动跳过已下载的片段
|
||||
task.errorMessage = undefined;
|
||||
task.updatedAt = new Date();
|
||||
tasks.set(taskId, task);
|
||||
saveTasks(); // 持久化任务
|
||||
|
||||
// 开始重新下载(异步)
|
||||
const downloadPromise = downloader
|
||||
.startDownload(task, (updatedTask) => {
|
||||
// 更新任务状态
|
||||
tasks.set(updatedTask.id, updatedTask);
|
||||
saveTasks(); // 持久化任务
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('重试下载失败:', error);
|
||||
task.status = 'error';
|
||||
task.errorMessage = error.message;
|
||||
tasks.set(task.id, task);
|
||||
saveTasks(); // 持久化任务
|
||||
})
|
||||
.finally(() => {
|
||||
// 下载完成后,从活跃下载列表中移除
|
||||
activeDownloads.delete(task.id);
|
||||
});
|
||||
|
||||
activeDownloads.set(task.id, downloadPromise);
|
||||
|
||||
return NextResponse.json({
|
||||
task: {
|
||||
...task,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
updatedAt: task.updatedAt.toISOString(),
|
||||
},
|
||||
message: '任务已重新开始',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('重试任务失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '重试任务失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { usePlaySync } from '@/hooks/usePlaySync';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { useDownload } from '@/contexts/DownloadContext';
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import {
|
||||
deleteFavorite,
|
||||
@@ -71,6 +72,15 @@ function PlayPageClient() {
|
||||
// 获取 Proxy M3U8 Token
|
||||
const proxyToken = typeof window !== 'undefined' ? process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN || '' : '';
|
||||
|
||||
// 获取用户认证信息
|
||||
const authInfo = typeof window !== 'undefined' ? getAuthInfoFromBrowserCookie() : null;
|
||||
|
||||
// 离线下载功能配置
|
||||
const enableOfflineDownload = typeof window !== 'undefined'
|
||||
? process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true'
|
||||
: false;
|
||||
const hasOfflinePermission = authInfo?.role === 'owner' || authInfo?.role === 'admin';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 状态变量(State)
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -739,8 +749,34 @@ function PlayPageClient() {
|
||||
return Math.round(score * 100) / 100; // 保留两位小数
|
||||
};
|
||||
|
||||
// 检查是否有本地下载的视频
|
||||
const checkLocalDownload = async (
|
||||
source: string,
|
||||
videoId: string,
|
||||
episodeIndex: number
|
||||
): Promise<boolean> => {
|
||||
if (!enableOfflineDownload || !hasOfflinePermission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/offline-download?action=check&source=${encodeURIComponent(source)}&videoId=${encodeURIComponent(videoId)}&episodeIndex=${episodeIndex}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.downloaded || false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查本地下载失败:', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// 更新视频地址
|
||||
const updateVideoUrl = (
|
||||
const updateVideoUrl = async (
|
||||
detailData: SearchResult | null,
|
||||
episodeIndex: number
|
||||
) => {
|
||||
@@ -752,14 +788,25 @@ function PlayPageClient() {
|
||||
setVideoUrl('');
|
||||
return;
|
||||
}
|
||||
const newUrl = detailData?.episodes[episodeIndex] || '';
|
||||
|
||||
let newUrl = detailData?.episodes[episodeIndex] || '';
|
||||
|
||||
// 检查是否有本地下载的文件
|
||||
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
|
||||
|
||||
if (hasLocalFile) {
|
||||
// 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别
|
||||
newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`;
|
||||
console.log('使用本地下载文件播放:', newUrl);
|
||||
}
|
||||
|
||||
if (newUrl !== videoUrl) {
|
||||
setVideoUrl(newUrl);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理下载指定集数(支持批量下载)
|
||||
const handleDownloadEpisode = async (episodeIndexes: number[]) => {
|
||||
const handleDownloadEpisode = async (episodeIndexes: number[], offlineMode = false) => {
|
||||
if (!detail || !detail.episodes || episodeIndexes.length === 0) {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '无法获取视频地址';
|
||||
@@ -781,12 +828,55 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
const episodeUrl = detail.episodes[episodeIndex];
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: episodeUrl;
|
||||
|
||||
// 离线下载模式:无论是否开启去广告,都走非去广告逻辑
|
||||
const proxyUrl = offlineMode
|
||||
? episodeUrl // 离线下载不使用代理,直接使用原始URL
|
||||
: (externalPlayerAdBlock
|
||||
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: episodeUrl);
|
||||
|
||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u8') || episodeUrl.toLowerCase().includes('/m3u8/');
|
||||
|
||||
if (isM3u8) {
|
||||
if (offlineMode && isM3u8) {
|
||||
// 离线下载模式 - 调用服务器API
|
||||
try {
|
||||
const downloadTitle = `${videoTitle}_第${episodeIndex + 1}集`;
|
||||
const response = await fetch('/api/offline-download', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: currentSource,
|
||||
videoId: currentId,
|
||||
episodeIndex,
|
||||
title: downloadTitle,
|
||||
m3u8Url: proxyUrl,
|
||||
metadata: detail ? {
|
||||
videoTitle: detail.title,
|
||||
cover: detail.poster,
|
||||
description: detail.desc,
|
||||
year: detail.year,
|
||||
rating: undefined, // SearchResult 没有 rating 字段
|
||||
totalEpisodes: detail.episodes?.length,
|
||||
} : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error(`离线下载任务创建失败 (第${episodeIndex + 1}集):`, data.error);
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`离线下载任务创建失败 (第${episodeIndex + 1}集):`, error);
|
||||
failCount++;
|
||||
}
|
||||
} else if (isM3u8) {
|
||||
// M3U8格式 - 使用新的下载器,TS 格式
|
||||
try {
|
||||
const downloadTitle = `${videoTitle}_第${episodeIndex + 1}集`;
|
||||
@@ -819,7 +909,9 @@ function PlayPageClient() {
|
||||
// 显示结果通知
|
||||
if (artPlayerRef.current) {
|
||||
if (failCount === 0) {
|
||||
artPlayerRef.current.notice.show = `已添加 ${successCount} 个下载任务!`;
|
||||
artPlayerRef.current.notice.show = offlineMode
|
||||
? `已创建 ${successCount} 个离线下载任务!`
|
||||
: `已添加 ${successCount} 个下载任务!`;
|
||||
} else if (successCount === 0) {
|
||||
artPlayerRef.current.notice.show = '下载失败,请重试';
|
||||
} else {
|
||||
@@ -2692,6 +2784,10 @@ function PlayPageClient() {
|
||||
if (video.hls) {
|
||||
video.hls.destroy();
|
||||
}
|
||||
|
||||
// 每次创建HLS实例时,都读取最新的blockAdEnabled状态
|
||||
const shouldUseCustomLoader = blockAdEnabledRef.current;
|
||||
|
||||
const hls = new Hls({
|
||||
debug: false, // 关闭日志
|
||||
enableWorker: true, // WebWorker 解码,降低主线程压力
|
||||
@@ -2703,7 +2799,7 @@ function PlayPageClient() {
|
||||
maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理
|
||||
|
||||
/* 自定义loader */
|
||||
loader: (blockAdEnabledRef.current
|
||||
loader: (shouldUseCustomLoader
|
||||
? CustomHlsJsLoader
|
||||
: Hls.DefaultConfig.loader) as any,
|
||||
});
|
||||
@@ -4171,6 +4267,8 @@ function PlayPageClient() {
|
||||
videoTitle={videoTitle}
|
||||
currentEpisodeIndex={currentEpisodeIndex}
|
||||
onDownload={handleDownloadEpisode}
|
||||
enableOfflineDownload={enableOfflineDownload}
|
||||
hasOfflinePermission={hasOfflinePermission}
|
||||
/>
|
||||
|
||||
{/* 弹幕过滤设置对话框 */}
|
||||
|
||||
@@ -16,7 +16,11 @@ interface DownloadEpisodeSelectorProps {
|
||||
/** 当前集数索引(0开始) */
|
||||
currentEpisodeIndex: number;
|
||||
/** 下载回调 - 支持批量下载 */
|
||||
onDownload: (episodeIndexes: number[]) => void;
|
||||
onDownload: (episodeIndexes: number[], offlineMode: boolean) => void;
|
||||
/** 是否启用离线下载功能 */
|
||||
enableOfflineDownload?: boolean;
|
||||
/** 是否有离线下载权限(管理员或站长) */
|
||||
hasOfflinePermission?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,12 +34,17 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
|
||||
videoTitle,
|
||||
currentEpisodeIndex,
|
||||
onDownload,
|
||||
enableOfflineDownload = false,
|
||||
hasOfflinePermission = false,
|
||||
}) => {
|
||||
// 多选状态 - 使用 Set 存储选中的集数索引
|
||||
const [selectedEpisodes, setSelectedEpisodes] = useState<Set<number>>(
|
||||
new Set([currentEpisodeIndex])
|
||||
);
|
||||
|
||||
// 离线下载模式
|
||||
const [offlineMode, setOfflineMode] = useState(false);
|
||||
|
||||
// 每页显示的集数
|
||||
const episodesPerPage = 50;
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
@@ -105,7 +114,7 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
|
||||
|
||||
const handleDownload = () => {
|
||||
const episodeIndexes = Array.from(selectedEpisodes).sort((a, b) => a - b);
|
||||
onDownload(episodeIndexes);
|
||||
onDownload(episodeIndexes, offlineMode);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -161,6 +170,50 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 离线下载开关 - 仅管理员和站长可见 */}
|
||||
{enableOfflineDownload && hasOfflinePermission && (
|
||||
<div className='flex items-center justify-between px-6 py-3 bg-blue-50 dark:bg-blue-900/10 border-b border-blue-100 dark:border-blue-900/30'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{/* 服务器图标 */}
|
||||
<div className='flex-shrink-0 w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center'>
|
||||
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
服务器离线下载
|
||||
</h3>
|
||||
{offlineMode && (
|
||||
<span className='px-2 py-0.5 text-xs font-medium bg-blue-500 text-white rounded'>
|
||||
已启用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-xs text-gray-600 dark:text-gray-400 mt-0.5'>
|
||||
开启后将在服务器端下载视频文件,支持断点续传和后台下载
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 开关 */}
|
||||
<button
|
||||
onClick={() => setOfflineMode(!offlineMode)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
offlineMode
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||
offlineMode ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页标签 */}
|
||||
{pageCount > 1 && (
|
||||
<div className='flex items-center gap-4 px-6 py-3 border-b border-gray-200 dark:border-gray-700'>
|
||||
@@ -283,9 +336,13 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={selectedEpisodes.size === 0}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 rounded-md transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-green-500'
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-md transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
offlineMode && enableOfflineDownload && hasOfflinePermission
|
||||
? 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
|
||||
: 'bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 disabled:hover:bg-green-500'
|
||||
}`}
|
||||
>
|
||||
下载 {selectedEpisodes.size > 0 && `(${selectedEpisodes.size})`}
|
||||
{offlineMode && enableOfflineDownload && hasOfflinePermission ? '离线' : ''}下载 {selectedEpisodes.size > 0 && `(${selectedEpisodes.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
466
src/components/OfflineDownloadPanel.tsx
Normal file
466
src/components/OfflineDownloadPanel.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface OfflineDownloadTask {
|
||||
id: string;
|
||||
source: string;
|
||||
videoId: string;
|
||||
episodeIndex: number;
|
||||
title: string;
|
||||
m3u8Url: string;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'error' | 'paused';
|
||||
progress: number;
|
||||
totalSegments: number;
|
||||
downloadedSegments: number;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
downloadDir: string;
|
||||
metadata?: {
|
||||
videoTitle?: string;
|
||||
cover?: string;
|
||||
description?: string;
|
||||
year?: string;
|
||||
rating?: number;
|
||||
totalEpisodes?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface OfflineDownloadPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function OfflineDownloadPanel({ isOpen, onClose }: OfflineDownloadPanelProps) {
|
||||
const [tasks, setTasks] = useState<OfflineDownloadTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'tasks' | 'library'>('tasks'); // 视图模式:任务列表或视频库
|
||||
|
||||
// 确保只在客户端渲染
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 获取任务列表
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/offline-download');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.tasks || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取离线下载任务列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除任务
|
||||
const handleDeleteTask = async (taskId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/offline-download?taskId=${taskId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// 从列表中移除
|
||||
setTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(`删除失败: ${data.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除任务失败:', error);
|
||||
alert('删除任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 重试任务
|
||||
const handleRetryTask = async (taskId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/offline-download?taskId=${taskId}&action=retry`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// 更新任务状态(保留进度,只重试失败的片段)
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === taskId
|
||||
? {
|
||||
...t,
|
||||
status: 'pending',
|
||||
errorMessage: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: t
|
||||
)
|
||||
);
|
||||
// 立即刷新以获取最新状态
|
||||
fetchTasks();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(`重试失败: ${data.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重试任务失败:', error);
|
||||
alert('重试任务失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 定期刷新任务列表
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchTasks();
|
||||
const interval = setInterval(fetchTasks, 3000); // 每3秒刷新一次
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen || !mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatusText = (status: OfflineDownloadTask['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '等待中';
|
||||
case 'downloading':
|
||||
return '下载中';
|
||||
case 'paused':
|
||||
return '已暂停';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'error':
|
||||
return '错误';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: OfflineDownloadTask['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'text-gray-500 dark:text-gray-400';
|
||||
case 'downloading':
|
||||
return 'text-blue-500 dark:text-blue-400';
|
||||
case 'paused':
|
||||
return 'text-yellow-500 dark:text-yellow-400';
|
||||
case 'completed':
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
case 'error':
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
default:
|
||||
return 'text-gray-500 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取视频库中的视频(按videoId分组,包含已完成和有进度的任务)
|
||||
const getLibraryVideos = () => {
|
||||
// 筛选已完成或有下载进度的任务
|
||||
const libraryTasks = tasks.filter((t) => t.status === 'completed' || t.progress > 0);
|
||||
const videoMap = new Map<string, { video: OfflineDownloadTask; episodes: OfflineDownloadTask[] }>();
|
||||
|
||||
libraryTasks.forEach((task) => {
|
||||
const key = `${task.source}_${task.videoId}`;
|
||||
if (!videoMap.has(key)) {
|
||||
videoMap.set(key, { video: task, episodes: [] });
|
||||
}
|
||||
videoMap.get(key)!.episodes.push(task);
|
||||
});
|
||||
|
||||
// 按集数排序
|
||||
videoMap.forEach((value) => {
|
||||
value.episodes.sort((a, b) => a.episodeIndex - b.episodeIndex);
|
||||
});
|
||||
|
||||
return Array.from(videoMap.values());
|
||||
};
|
||||
|
||||
const libraryVideos = getLibraryVideos();
|
||||
|
||||
const panelContent = (
|
||||
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h2 className='text-xl font-bold text-gray-900 dark:text-white'>
|
||||
{viewMode === 'tasks' ? '离线下载任务列表' : '视频库'}
|
||||
</h2>
|
||||
<div className='flex items-center gap-3'>
|
||||
{/* 视图切换按钮 */}
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
onClick={() => setViewMode('tasks')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'tasks'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
任务列表
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('library')}
|
||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||
viewMode === 'library'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
视频库 ({libraryVideos.length})
|
||||
</button>
|
||||
</div>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors'
|
||||
>
|
||||
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className='flex-1 overflow-y-auto p-4 space-y-3'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center h-full'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500'></div>
|
||||
</div>
|
||||
) : viewMode === 'library' ? (
|
||||
// 视频库视图
|
||||
libraryVideos.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
|
||||
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z'
|
||||
/>
|
||||
</svg>
|
||||
<p className='text-lg'>暂无已完成的视频</p>
|
||||
</div>
|
||||
) : (
|
||||
libraryVideos.map(({ video, episodes }) => (
|
||||
<div
|
||||
key={`${video.source}_${video.videoId}`}
|
||||
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
|
||||
>
|
||||
<div className='flex gap-4'>
|
||||
{/* 封面图 */}
|
||||
{video.metadata?.cover && (
|
||||
<div className='flex-shrink-0'>
|
||||
<img
|
||||
src={video.metadata.cover}
|
||||
alt={video.metadata.videoTitle || video.title}
|
||||
className='w-32 h-48 object-cover rounded'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 视频信息 */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='text-lg font-bold text-gray-900 dark:text-white mb-2'>
|
||||
{video.metadata?.videoTitle || video.title}
|
||||
</h3>
|
||||
{video.metadata?.year && (
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>年份: {video.metadata.year}</p>
|
||||
)}
|
||||
{video.metadata?.rating && (
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>
|
||||
评分: {video.metadata.rating.toFixed(1)}
|
||||
</p>
|
||||
)}
|
||||
{video.metadata?.description && (
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2'>
|
||||
{video.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
已下载 {episodes.length} 集
|
||||
</span>
|
||||
{video.metadata?.totalEpisodes && (
|
||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
/ 共 {video.metadata.totalEpisodes} 集
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 集数列表 */}
|
||||
<div className='flex flex-wrap gap-2 mb-3'>
|
||||
{episodes.map((ep) => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className='flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 text-xs rounded group'
|
||||
>
|
||||
<span>第{ep.episodeIndex + 1}集</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除第${ep.episodeIndex + 1}集吗?`)) {
|
||||
handleDeleteTask(ep.id);
|
||||
}
|
||||
}}
|
||||
className='ml-1 opacity-0 group-hover:opacity-100 transition-opacity text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300'
|
||||
title='删除此集'
|
||||
>
|
||||
<svg className='w-3 h-3' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 删除全部按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除《${video.metadata?.videoTitle || video.title}》的所有已下载集数吗?`)) {
|
||||
episodes.forEach((ep) => handleDeleteTask(ep.id));
|
||||
}
|
||||
}}
|
||||
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
|
||||
>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
|
||||
/>
|
||||
</svg>
|
||||
删除全部集数
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : tasks.length === 0 ? (
|
||||
// 任务列表为空
|
||||
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
|
||||
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4'
|
||||
/>
|
||||
</svg>
|
||||
<p className='text-lg'>暂无离线下载任务</p>
|
||||
</div>
|
||||
) : (
|
||||
// 任务列表视图
|
||||
tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
|
||||
>
|
||||
{/* 任务信息 */}
|
||||
<div className='flex items-start justify-between mb-3'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-white truncate mb-1'>
|
||||
{task.title}
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
来源: {task.source} | 视频ID: {task.videoId} | 第{task.episodeIndex + 1}集
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 ml-4'>
|
||||
<span className={`text-xs font-medium ${getStatusColor(task.status)}`}>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{task.totalSegments > 0 && (
|
||||
<div className='mb-3'>
|
||||
<div className='flex items-center justify-between text-xs text-gray-600 dark:text-gray-300 mb-1'>
|
||||
<span>
|
||||
{task.downloadedSegments} / {task.totalSegments} 片段
|
||||
</span>
|
||||
<span>{task.progress.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className='w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden'>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
task.status === 'downloading'
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-600 animate-pulse'
|
||||
: task.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: task.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: 'bg-gray-400'
|
||||
}`}
|
||||
style={{ width: `${task.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{task.errorMessage && (
|
||||
<div className='mb-3'>
|
||||
<div className='text-xs text-red-500 dark:text-red-400'>{task.errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3'>
|
||||
<span>创建: {new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
||||
<span>更新: {new Date(task.updatedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* 重试按钮 - 只在错误或暂停状态显示 */}
|
||||
{(task.status === 'error' || task.status === 'paused') && (
|
||||
<button
|
||||
onClick={() => handleRetryTask(task.id)}
|
||||
className='flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-medium rounded transition-colors'
|
||||
>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
|
||||
/>
|
||||
</svg>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
|
||||
>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
|
||||
/>
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(panelContent, document.body);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
@@ -26,6 +27,7 @@ import { UpdateStatus } from '@/lib/version_check';
|
||||
|
||||
import { useVersionCheck } from './VersionCheckProvider';
|
||||
import { VersionPanel } from './VersionPanel';
|
||||
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
|
||||
|
||||
interface AuthInfo {
|
||||
username?: string;
|
||||
@@ -40,6 +42,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
|
||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -52,7 +55,7 @@ export const UserMenu: React.FC = () => {
|
||||
|
||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
||||
useEffect(() => {
|
||||
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen) {
|
||||
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen) {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
@@ -71,7 +74,7 @@ export const UserMenu: React.FC = () => {
|
||||
html.style.overflow = originalHtmlOverflow;
|
||||
};
|
||||
}
|
||||
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen]);
|
||||
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen]);
|
||||
|
||||
// 设置相关状态
|
||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||
@@ -530,6 +533,12 @@ export const UserMenu: React.FC = () => {
|
||||
const showAdminPanel =
|
||||
authInfo?.role === 'owner' || authInfo?.role === 'admin';
|
||||
|
||||
// 检查是否显示离线下载按钮
|
||||
const showOfflineDownload =
|
||||
(authInfo?.role === 'owner' || authInfo?.role === 'admin') &&
|
||||
typeof window !== 'undefined' &&
|
||||
process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
|
||||
|
||||
// 检查是否显示修改密码按钮
|
||||
const showChangePassword =
|
||||
authInfo?.role !== 'owner' && storageType !== 'localstorage';
|
||||
@@ -611,6 +620,20 @@ export const UserMenu: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 离线下载按钮 */}
|
||||
{showOfflineDownload && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOfflineDownloadPanelOpen(true);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||
>
|
||||
<Download className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
<span className='font-medium'>离线下载</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 修改密码按钮 */}
|
||||
{showChangePassword && (
|
||||
<button
|
||||
@@ -1361,6 +1384,12 @@ export const UserMenu: React.FC = () => {
|
||||
isOpen={isVersionPanelOpen}
|
||||
onClose={() => setIsVersionPanelOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 离线下载面板 */}
|
||||
<OfflineDownloadPanel
|
||||
isOpen={isOfflineDownloadPanelOpen}
|
||||
onClose={() => setIsOfflineDownloadPanelOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export function getAuthInfoFromCookie(request: NextRequest): {
|
||||
username?: string;
|
||||
signature?: string;
|
||||
timestamp?: number;
|
||||
role?: 'owner' | 'admin' | 'user';
|
||||
} | null {
|
||||
const authCookie = request.cookies.get('auth');
|
||||
|
||||
|
||||
692
src/lib/offline-downloader.ts
Normal file
692
src/lib/offline-downloader.ts
Normal file
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* 服务器端离线下载器
|
||||
* 用于在服务器端下载 M3U8 视频到本地文件系统
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
import * as zlib from 'zlib';
|
||||
import { URL } from 'url';
|
||||
|
||||
export interface OfflineDownloadTask {
|
||||
id: string;
|
||||
source: string;
|
||||
videoId: string;
|
||||
episodeIndex: number;
|
||||
title: string;
|
||||
m3u8Url: string;
|
||||
status: 'pending' | 'downloading' | 'completed' | 'error' | 'paused';
|
||||
progress: number;
|
||||
totalSegments: number;
|
||||
downloadedSegments: number;
|
||||
errorMessage?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
downloadDir: string;
|
||||
// 视频元数据
|
||||
metadata?: {
|
||||
videoTitle?: string; // 视频总标题(如:某某动漫)
|
||||
cover?: string; // 封面图片URL
|
||||
description?: string; // 视频描述
|
||||
year?: string; // 年份
|
||||
rating?: number; // 评分
|
||||
totalEpisodes?: number; // 总集数
|
||||
};
|
||||
}
|
||||
|
||||
interface SegmentInfo {
|
||||
url: string;
|
||||
filename: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface KeyInfo {
|
||||
method: string;
|
||||
uri: string;
|
||||
iv?: string;
|
||||
}
|
||||
|
||||
export class OfflineDownloader {
|
||||
private baseDir: string;
|
||||
private maxRetries = 3;
|
||||
private retryDelay = 1000; // ms
|
||||
private concurrency = 6;
|
||||
|
||||
constructor(baseDir: string) {
|
||||
this.baseDir = baseDir;
|
||||
this.ensureDir(this.baseDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建下载任务
|
||||
*/
|
||||
async createTask(
|
||||
source: string,
|
||||
videoId: string,
|
||||
episodeIndex: number,
|
||||
title: string,
|
||||
m3u8Url: string,
|
||||
metadata?: {
|
||||
videoTitle?: string;
|
||||
cover?: string;
|
||||
description?: string;
|
||||
year?: string;
|
||||
rating?: number;
|
||||
totalEpisodes?: number;
|
||||
}
|
||||
): Promise<OfflineDownloadTask> {
|
||||
const taskId = `${source}_${videoId}_${episodeIndex}_${Date.now()}`;
|
||||
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
|
||||
|
||||
const task: OfflineDownloadTask = {
|
||||
id: taskId,
|
||||
source,
|
||||
videoId,
|
||||
episodeIndex,
|
||||
title,
|
||||
m3u8Url,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
totalSegments: 0,
|
||||
downloadedSegments: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
downloadDir,
|
||||
metadata,
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始下载任务
|
||||
*/
|
||||
async startDownload(
|
||||
task: OfflineDownloadTask,
|
||||
onProgress?: (task: OfflineDownloadTask) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
task.status = 'downloading';
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
|
||||
// 确保下载目录存在
|
||||
this.ensureDir(task.downloadDir);
|
||||
|
||||
// 检查是否已经下载过(避免重复下载)
|
||||
const playlistPath = path.join(task.downloadDir, 'playlist.m3u8');
|
||||
if (fs.existsSync(playlistPath)) {
|
||||
// 检查所有文件是否完整
|
||||
const isComplete = await this.verifyDownload(task.downloadDir);
|
||||
if (isComplete) {
|
||||
task.status = 'completed';
|
||||
task.progress = 100;
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 下载 M3U8 文件
|
||||
let m3u8Content = await this.fetchContent(task.m3u8Url);
|
||||
let finalM3u8Url = task.m3u8Url;
|
||||
|
||||
// 检查是否为主播放列表(包含多个分辨率)
|
||||
if (this.isMasterPlaylist(m3u8Content)) {
|
||||
console.log('检测到主播放列表,正在选择最高分辨率...');
|
||||
|
||||
// 解析主播放列表,获取最高分辨率的子播放列表URL
|
||||
const bestVariantUrl = this.selectBestVariant(m3u8Content, task.m3u8Url);
|
||||
|
||||
if (bestVariantUrl) {
|
||||
console.log('已选择最高分辨率流:', bestVariantUrl);
|
||||
finalM3u8Url = bestVariantUrl;
|
||||
|
||||
// 下载子播放列表
|
||||
m3u8Content = await this.fetchContent(bestVariantUrl);
|
||||
} else {
|
||||
console.warn('无法找到子播放列表,使用原始URL');
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 M3U8 文件
|
||||
const { segments, keyInfo } = this.parseM3U8(m3u8Content, finalM3u8Url);
|
||||
|
||||
task.totalSegments = segments.length;
|
||||
onProgress?.(task);
|
||||
|
||||
// 下载解密 Key(如果有)
|
||||
if (keyInfo && keyInfo.uri) {
|
||||
await this.downloadKey(keyInfo, task.downloadDir);
|
||||
}
|
||||
|
||||
// 下载所有片段
|
||||
await this.downloadSegments(segments, task, onProgress);
|
||||
|
||||
// 生成本地播放列表,保持原始格式
|
||||
this.generateLocalPlaylist(m3u8Content, segments, keyInfo, task.downloadDir);
|
||||
|
||||
task.status = 'completed';
|
||||
task.progress = 100;
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
} catch (error) {
|
||||
task.status = 'error';
|
||||
task.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为主播放列表(Master Playlist)
|
||||
*/
|
||||
private isMasterPlaylist(content: string): boolean {
|
||||
return content.includes('#EXT-X-STREAM-INF:');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从主播放列表中选择最高分辨率的变体
|
||||
*/
|
||||
private selectBestVariant(content: string, baseUrl: string): string | null {
|
||||
const lines = content.split('\n').map((line) => line.trim());
|
||||
|
||||
interface Variant {
|
||||
url: string;
|
||||
bandwidth: number;
|
||||
resolution?: { width: number; height: number };
|
||||
}
|
||||
|
||||
const variants: Variant[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
// 提取带宽信息
|
||||
const bandwidthMatch = line.match(/BANDWIDTH=(\d+)/);
|
||||
const bandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
|
||||
|
||||
// 提取分辨率信息
|
||||
const resolutionMatch = line.match(/RESOLUTION=(\d+)x(\d+)/);
|
||||
const resolution = resolutionMatch
|
||||
? { width: parseInt(resolutionMatch[1], 10), height: parseInt(resolutionMatch[2], 10) }
|
||||
: undefined;
|
||||
|
||||
// 下一行应该是子播放列表的URL
|
||||
if (i + 1 < lines.length) {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine && !nextLine.startsWith('#')) {
|
||||
const variantUrl = this.resolveUrl(nextLine, baseUrl);
|
||||
variants.push({ url: variantUrl, bandwidth, resolution });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 优先按分辨率排序(宽度 * 高度),如果没有分辨率信息则按带宽排序
|
||||
variants.sort((a, b) => {
|
||||
// 如果两者都有分辨率信息,按分辨率排序
|
||||
if (a.resolution && b.resolution) {
|
||||
const aPixels = a.resolution.width * a.resolution.height;
|
||||
const bPixels = b.resolution.width * b.resolution.height;
|
||||
if (aPixels !== bPixels) {
|
||||
return bPixels - aPixels; // 降序
|
||||
}
|
||||
}
|
||||
|
||||
// 如果只有一个有分辨率信息,优先选择有分辨率的
|
||||
if (a.resolution && !b.resolution) return -1;
|
||||
if (!a.resolution && b.resolution) return 1;
|
||||
|
||||
// 都没有分辨率信息,或分辨率相同,则按带宽排序
|
||||
return b.bandwidth - a.bandwidth; // 降序
|
||||
});
|
||||
|
||||
console.log('可用的流变体:', variants.map(v => ({
|
||||
url: v.url,
|
||||
bandwidth: v.bandwidth,
|
||||
resolution: v.resolution ? `${v.resolution.width}x${v.resolution.height}` : '未知',
|
||||
})));
|
||||
|
||||
return variants[0].url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 M3U8 文件
|
||||
*/
|
||||
private parseM3U8(
|
||||
content: string,
|
||||
baseUrl: string
|
||||
): { segments: SegmentInfo[]; keyInfo: KeyInfo | null } {
|
||||
const lines = content.split('\n').map((line) => line.trim());
|
||||
const segments: SegmentInfo[] = [];
|
||||
let keyInfo: KeyInfo | null = null;
|
||||
let currentDuration = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// 解析时长
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
const match = line.match(/#EXTINF:([\d.]+)/);
|
||||
if (match) {
|
||||
currentDuration = parseFloat(match[1]);
|
||||
}
|
||||
}
|
||||
// 解析加密信息
|
||||
else if (line.startsWith('#EXT-X-KEY:')) {
|
||||
const methodMatch = line.match(/METHOD=([^,]+)/);
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
const ivMatch = line.match(/IV=([^,\s]+)/);
|
||||
|
||||
if (methodMatch && methodMatch[1] !== 'NONE') {
|
||||
keyInfo = {
|
||||
method: methodMatch[1],
|
||||
uri: uriMatch ? this.resolveUrl(uriMatch[1], baseUrl) : '',
|
||||
iv: ivMatch ? ivMatch[1] : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
// 解析片段 URL
|
||||
else if (line && !line.startsWith('#')) {
|
||||
const segmentUrl = this.resolveUrl(line, baseUrl);
|
||||
const filename = `segment_${segments.length.toString().padStart(5, '0')}.ts`;
|
||||
|
||||
segments.push({
|
||||
url: segmentUrl,
|
||||
filename,
|
||||
duration: currentDuration,
|
||||
});
|
||||
|
||||
currentDuration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { segments, keyInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载所有片段(带重试和并发控制)
|
||||
*/
|
||||
private async downloadSegments(
|
||||
segments: SegmentInfo[],
|
||||
task: OfflineDownloadTask,
|
||||
onProgress?: (task: OfflineDownloadTask) => void
|
||||
): Promise<void> {
|
||||
const queue = [...segments];
|
||||
const downloading: Promise<void>[] = [];
|
||||
let downloadedCount = 0;
|
||||
|
||||
const downloadNext = async (): Promise<void> => {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const segment = queue.shift()!;
|
||||
const segmentPath = path.join(task.downloadDir, segment.filename);
|
||||
|
||||
// 如果文件已存在且大小 > 0,跳过下载
|
||||
if (fs.existsSync(segmentPath) && fs.statSync(segmentPath).size > 0) {
|
||||
downloadedCount++;
|
||||
task.downloadedSegments = downloadedCount;
|
||||
task.progress = Math.round((downloadedCount / task.totalSegments) * 100);
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
return downloadNext();
|
||||
}
|
||||
|
||||
// 下载片段(带重试)
|
||||
await this.downloadWithRetry(segment.url, segmentPath);
|
||||
|
||||
downloadedCount++;
|
||||
task.downloadedSegments = downloadedCount;
|
||||
task.progress = Math.round((downloadedCount / task.totalSegments) * 100);
|
||||
task.updatedAt = new Date();
|
||||
onProgress?.(task);
|
||||
|
||||
return downloadNext();
|
||||
};
|
||||
|
||||
// 并发下载
|
||||
for (let i = 0; i < Math.min(this.concurrency, segments.length); i++) {
|
||||
downloading.push(downloadNext());
|
||||
}
|
||||
|
||||
await Promise.all(downloading);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载解密 Key
|
||||
*/
|
||||
private async downloadKey(keyInfo: KeyInfo, downloadDir: string): Promise<void> {
|
||||
if (!keyInfo.uri) return;
|
||||
|
||||
const keyPath = path.join(downloadDir, 'key.key');
|
||||
|
||||
// 如果 key 已存在,跳过下载
|
||||
if (fs.existsSync(keyPath) && fs.statSync(keyPath).size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.downloadWithRetry(keyInfo.uri, keyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件(带重试)
|
||||
*/
|
||||
private async downloadWithRetry(url: string, savePath: string): Promise<void> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
||||
try {
|
||||
await this.downloadFile(url, savePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
console.error(`下载失败 (尝试 ${attempt + 1}/${this.maxRetries}): ${url}`, error);
|
||||
|
||||
if (attempt < this.maxRetries - 1) {
|
||||
await this.sleep(this.retryDelay * (attempt + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`下载失败(已重试 ${this.maxRetries} 次): ${url}\n${lastError?.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标准浏览器请求头
|
||||
*/
|
||||
private getHeaders(url: string): Record<string, string> {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Origin': `${urlObj.protocol}//${urlObj.host}`,
|
||||
'Referer': `${urlObj.protocol}//${urlObj.host}/`,
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单个文件
|
||||
*/
|
||||
private async downloadFile(url: string, savePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
headers: this.getHeaders(url),
|
||||
};
|
||||
|
||||
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, () => {});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
fs.unlink(savePath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容(用于 M3U8 文件)
|
||||
*/
|
||||
private async fetchContent(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
headers: this.getHeaders(url),
|
||||
};
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本地播放列表(保持原始格式,只替换片段URL)
|
||||
*/
|
||||
private generateLocalPlaylist(
|
||||
originalM3u8Content: string,
|
||||
segments: SegmentInfo[],
|
||||
keyInfo: KeyInfo | null,
|
||||
downloadDir: string
|
||||
): void {
|
||||
const lines = originalM3u8Content.split('\n');
|
||||
const modifiedLines: string[] = [];
|
||||
let segmentIndex = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 替换 Key URI
|
||||
if (trimmedLine.startsWith('#EXT-X-KEY:')) {
|
||||
if (keyInfo && keyInfo.method !== 'NONE') {
|
||||
// 替换 URI 为本地 key.key
|
||||
const modifiedLine = line.replace(/URI="[^"]+"/g, 'URI="key.key"');
|
||||
modifiedLines.push(modifiedLine);
|
||||
} else {
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
// 替换视频片段 URL
|
||||
else if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
// 这是一个视频片段,替换为本地文件名
|
||||
if (segmentIndex < segments.length) {
|
||||
const indent = line.match(/^\s*/)?.[0] || '';
|
||||
modifiedLines.push(indent + segments[segmentIndex].filename);
|
||||
segmentIndex++;
|
||||
} else {
|
||||
// 如果超出了片段数量,保留原始行(不应该发生)
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
// 保持其他所有行不变
|
||||
else {
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
fs.writeFileSync(playlistPath, modifiedLines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证下载是否完整
|
||||
*/
|
||||
private async verifyDownload(downloadDir: string): Promise<boolean> {
|
||||
try {
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
if (!fs.existsSync(playlistPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(playlistPath, 'utf-8');
|
||||
const lines = content.split('\n').map((line) => line.trim());
|
||||
|
||||
// 检查所有 ts 文件是否存在
|
||||
for (const line of lines) {
|
||||
if (line && !line.startsWith('#')) {
|
||||
const segmentPath = path.join(downloadDir, line);
|
||||
if (!fs.existsSync(segmentPath) || fs.statSync(segmentPath).size === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析相对 URL
|
||||
*/
|
||||
private resolveUrl(targetUrl: string, baseUrl: string): string {
|
||||
if (targetUrl.startsWith('http://') || targetUrl.startsWith('https://')) {
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
if (targetUrl.startsWith('/')) {
|
||||
return `${base.protocol}//${base.host}${targetUrl}`;
|
||||
} else {
|
||||
const basePath = base.pathname.substring(0, base.pathname.lastIndexOf('/') + 1);
|
||||
return `${base.protocol}//${base.host}${basePath}${targetUrl}`;
|
||||
}
|
||||
} catch {
|
||||
return targetUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
private ensureDir(dir: string): void {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除下载任务的所有文件
|
||||
*/
|
||||
async deleteTask(task: OfflineDownloadTask): Promise<void> {
|
||||
if (fs.existsSync(task.downloadDir)) {
|
||||
fs.rmSync(task.downloadDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查视频是否已下载
|
||||
*/
|
||||
checkDownloaded(source: string, videoId: string, episodeIndex: number): boolean {
|
||||
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
return fs.existsSync(playlistPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地播放列表路径
|
||||
*/
|
||||
getLocalPlaylistPath(source: string, videoId: string, episodeIndex: number): string | null {
|
||||
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
|
||||
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
|
||||
return fs.existsSync(playlistPath) ? playlistPath : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user