diff --git a/README.md b/README.md
index 3e146b1..81a03ae 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,6 @@
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
-



@@ -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 选项解释:
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index ec1a82b..036ec71 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -4534,7 +4534,7 @@ const ThemeConfigComponent = ({
)}
- 启用后,用户浏览器会缓存CSS文件指定时间,减少服务器负载。修改主题后会自动更新缓存。
+ 启用后,用户浏览器会缓存CSS文件指定时间,减少服务器负载。启用该项可能会导致主题更新延迟。
diff --git a/src/app/api/offline-download/local/[source]/[videoId]/[episodeIndex]/[...file]/route.ts b/src/app/api/offline-download/local/[source]/[videoId]/[episodeIndex]/[...file]/route.ts
new file mode 100644
index 0000000..010ba2c
--- /dev/null
+++ b/src/app/api/offline-download/local/[source]/[videoId]/[episodeIndex]/[...file]/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/offline-download/local/route.ts b/src/app/api/offline-download/local/route.ts
new file mode 100644
index 0000000..14a1d55
--- /dev/null
+++ b/src/app/api/offline-download/local/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/offline-download/route.ts b/src/app/api/offline-download/route.ts
new file mode 100644
index 0000000..875b10b
--- /dev/null
+++ b/src/app/api/offline-download/route.ts
@@ -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();
+
+// 活跃的下载Promise
+const activeDownloads = new Map>();
+
+// 任务持久化文件路径
+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 }
+ );
+ }
+}
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx
index 3754903..9787c2b 100644
--- a/src/app/play/page.tsx
+++ b/src/app/play/page.tsx
@@ -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 => {
+ 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}
/>
{/* 弹幕过滤设置对话框 */}
diff --git a/src/components/DownloadEpisodeSelector.tsx b/src/components/DownloadEpisodeSelector.tsx
index de298e9..c7579c5 100644
--- a/src/components/DownloadEpisodeSelector.tsx
+++ b/src/components/DownloadEpisodeSelector.tsx
@@ -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 = ({
videoTitle,
currentEpisodeIndex,
onDownload,
+ enableOfflineDownload = false,
+ hasOfflinePermission = false,
}) => {
// 多选状态 - 使用 Set 存储选中的集数索引
const [selectedEpisodes, setSelectedEpisodes] = useState>(
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 = ({
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 = ({
+ {/* 离线下载开关 - 仅管理员和站长可见 */}
+ {enableOfflineDownload && hasOfflinePermission && (
+
+
+ {/* 服务器图标 */}
+
+
+
+
+ 服务器离线下载
+
+ {offlineMode && (
+
+ 已启用
+
+ )}
+
+
+ 开启后将在服务器端下载视频文件,支持断点续传和后台下载
+
+
+
+ {/* 开关 */}
+
+
+ )}
+
{/* 分页标签 */}
{pageCount > 1 && (
@@ -283,9 +336,13 @@ const DownloadEpisodeSelector: React.FC = ({
diff --git a/src/components/OfflineDownloadPanel.tsx b/src/components/OfflineDownloadPanel.tsx
new file mode 100644
index 0000000..71388aa
--- /dev/null
+++ b/src/components/OfflineDownloadPanel.tsx
@@ -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([]);
+ 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();
+
+ 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 = (
+
+
+ {/* 标题栏 */}
+
+
+ {viewMode === 'tasks' ? '离线下载任务列表' : '视频库'}
+
+
+ {/* 视图切换按钮 */}
+
+
+
+
+ {/* 关闭按钮 */}
+
+
+
+
+ {/* 内容区域 */}
+
+ {loading ? (
+
+ ) : viewMode === 'library' ? (
+ // 视频库视图
+ libraryVideos.length === 0 ? (
+
+ ) : (
+ libraryVideos.map(({ video, episodes }) => (
+
+
+ {/* 封面图 */}
+ {video.metadata?.cover && (
+
+

+
+ )}
+ {/* 视频信息 */}
+
+
+ {video.metadata?.videoTitle || video.title}
+
+ {video.metadata?.year && (
+
年份: {video.metadata.year}
+ )}
+ {video.metadata?.rating && (
+
+ 评分: {video.metadata.rating.toFixed(1)}
+
+ )}
+ {video.metadata?.description && (
+
+ {video.metadata.description}
+
+ )}
+
+
+ 已下载 {episodes.length} 集
+
+ {video.metadata?.totalEpisodes && (
+
+ / 共 {video.metadata.totalEpisodes} 集
+
+ )}
+
+ {/* 集数列表 */}
+
+ {episodes.map((ep) => (
+
+
第{ep.episodeIndex + 1}集
+
+
+ ))}
+
+ {/* 删除全部按钮 */}
+
+
+
+
+ ))
+ )
+ ) : tasks.length === 0 ? (
+ // 任务列表为空
+
+ ) : (
+ // 任务列表视图
+ tasks.map((task) => (
+
+ {/* 任务信息 */}
+
+
+
+ {task.title}
+
+
+ 来源: {task.source} | 视频ID: {task.videoId} | 第{task.episodeIndex + 1}集
+
+
+
+
+ {getStatusText(task.status)}
+
+
+
+
+ {/* 进度条 */}
+ {task.totalSegments > 0 && (
+
+
+
+ {task.downloadedSegments} / {task.totalSegments} 片段
+
+ {task.progress.toFixed(1)}%
+
+
+
+ )}
+
+ {/* 错误信息 */}
+ {task.errorMessage && (
+
+ )}
+
+ {/* 时间信息 */}
+
+ 创建: {new Date(task.createdAt).toLocaleString('zh-CN')}
+ 更新: {new Date(task.updatedAt).toLocaleString('zh-CN')}
+
+
+ {/* 操作按钮 */}
+
+ {/* 重试按钮 - 只在错误或暂停状态显示 */}
+ {(task.status === 'error' || task.status === 'paused') && (
+
+ )}
+
+
+
+ ))
+ )}
+
+
+
+ );
+
+ return createPortal(panelContent, document.body);
+}
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx
index 408698c..27d7cf6 100644
--- a/src/components/UserMenu.tsx
+++ b/src/components/UserMenu.tsx
@@ -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(null);
const [storageType, setStorageType] = useState('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 = () => {
)}
+ {/* 离线下载按钮 */}
+ {showOfflineDownload && (
+
+ )}
+
{/* 修改密码按钮 */}
{showChangePassword && (