From e711d165eba1b1f906810d0d719358b4e695f8b2 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 14 Dec 2025 00:22:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A6=BB=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- src/app/admin/page.tsx | 2 +- .../[episodeIndex]/[...file]/route.ts | 133 ++++ src/app/api/offline-download/local/route.ts | 132 ++++ src/app/api/offline-download/route.ts | 411 +++++++++++ src/app/play/page.tsx | 116 ++- src/components/DownloadEpisodeSelector.tsx | 65 +- src/components/OfflineDownloadPanel.tsx | 466 ++++++++++++ src/components/UserMenu.tsx | 33 +- src/lib/auth.ts | 1 + src/lib/offline-downloader.ts | 692 ++++++++++++++++++ 11 files changed, 2039 insertions(+), 18 deletions(-) create mode 100644 src/app/api/offline-download/local/[source]/[videoId]/[episodeIndex]/[...file]/route.ts create mode 100644 src/app/api/offline-download/local/route.ts create mode 100644 src/app/api/offline-download/route.ts create mode 100644 src/components/OfflineDownloadPanel.tsx create mode 100644 src/lib/offline-downloader.ts 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) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
- ![Next.js](https://img.shields.io/badge/Next.js-14-000?logo=nextdotjs) ![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss) ![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript) @@ -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.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 && ( +
+
{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 && (