增加离线下载功能
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user