增加离线下载功能
This commit is contained in:
@@ -4534,7 +4534,7 @@ const ThemeConfigComponent = ({
|
||||
)}
|
||||
</div>
|
||||
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
|
||||
启用后,用户浏览器会缓存CSS文件指定时间,减少服务器负载。修改主题后会自动更新缓存。
|
||||
启用后,用户浏览器会缓存CSS文件指定时间,减少服务器负载。启用该项可能会导致主题更新延迟。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+133
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+107
-9
@@ -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}
|
||||
/>
|
||||
|
||||
{/* 弹幕过滤设置对话框 */}
|
||||
|
||||
Reference in New Issue
Block a user