增加离线下载功能

This commit is contained in:
mtvpls
2025-12-14 00:22:38 +08:00
parent d467c5067b
commit e711d165eb
11 changed files with 2039 additions and 18 deletions

View File

@@ -7,7 +7,6 @@
> 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
<div align="center">
![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 或正整数 | 43203天 |
| 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 选项解释:

View File

@@ -4534,7 +4534,7 @@ const ThemeConfigComponent = ({
)}
</div>
<p className='mt-4 text-sm text-gray-600 dark:text-gray-400'>
CSS文件指定时间
CSS文件指定时间
</p>
</div>

View File

@@ -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 }
);
}
}

View File

@@ -0,0 +1,132 @@
/**
* 本地下载视频播放代理 API
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import * as fs from 'fs';
import * as path from 'path';
// 检查是否启用离线下载功能
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
const OFFLINE_DOWNLOAD_DIR = process.env.OFFLINE_DOWNLOAD_DIR || '/data';
/**
* 检查用户权限(仅管理员和站长)
*/
function checkPermission(request: NextRequest): boolean {
if (!OFFLINE_DOWNLOAD_ENABLED) {
return false;
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return false;
}
// 只有管理员和站长可以访问
return authInfo.role === 'owner' || authInfo.role === 'admin';
}
/**
* GET - 代理本地视频文件
*/
export async function GET(request: NextRequest) {
if (!checkPermission(request)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
try {
const { searchParams } = new URL(request.url);
const source = searchParams.get('source');
const videoId = searchParams.get('videoId');
const episodeIndex = searchParams.get('episodeIndex');
const file = searchParams.get('file'); // 'playlist.m3u8', 'segment_00001.ts', 'key.key' 等
if (!source || !videoId || episodeIndex === null || !file) {
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
}
// 构建文件路径
const downloadDir = path.join(
OFFLINE_DOWNLOAD_DIR,
source,
videoId,
`ep${parseInt(episodeIndex) + 1}`
);
const filePath = path.join(downloadDir, file);
// 安全检查:确保文件路径在下载目录内
const normalizedFilePath = path.normalize(filePath);
const normalizedDownloadDir = path.normalize(downloadDir);
if (!normalizedFilePath.startsWith(normalizedDownloadDir)) {
return NextResponse.json({ error: '非法路径' }, { status: 403 });
}
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
}
// 读取文件
const fileBuffer = fs.readFileSync(filePath);
// 如果是 m3u8 文件,需要修改内容使片段指向代理地址
if (file === 'playlist.m3u8') {
let content = fileBuffer.toString('utf-8');
const lines = content.split('\n');
const modifiedLines: string[] = [];
for (const line of lines) {
const trimmedLine = line.trim();
// 处理 Key URI
if (trimmedLine.startsWith('#EXT-X-KEY:')) {
const modifiedLine = trimmedLine.replace(
/URI="([^"]+)"/,
`URI="/api/offline-download/local?source=${source}&videoId=${videoId}&episodeIndex=${episodeIndex}&file=$1"`
);
modifiedLines.push(modifiedLine);
}
// 处理 ts 片段
else if (trimmedLine && !trimmedLine.startsWith('#')) {
modifiedLines.push(
`/api/offline-download/local?source=${source}&videoId=${videoId}&episodeIndex=${episodeIndex}&file=${trimmedLine}`
);
} else {
modifiedLines.push(line);
}
}
content = modifiedLines.join('\n');
return new NextResponse(content, {
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
},
});
}
// 其他文件ts、key 等)直接返回
const contentType = file.endsWith('.ts')
? 'video/mp2t'
: file.endsWith('.key')
? 'application/octet-stream'
: 'application/octet-stream';
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
'Content-Length': fileBuffer.length.toString(),
},
});
} catch (error) {
console.error('代理本地文件失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '代理失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,411 @@
/**
* 离线下载任务管理 API
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { OfflineDownloader, OfflineDownloadTask } from '@/lib/offline-downloader';
import * as fs from 'fs';
import * as path from 'path';
// 检查是否启用离线下载功能
const OFFLINE_DOWNLOAD_ENABLED = process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
const OFFLINE_DOWNLOAD_DIR = process.env.OFFLINE_DOWNLOAD_DIR || '/data';
// 全局下载器实例
let downloader: OfflineDownloader | null = null;
// 任务存储(内存中)
const tasks = new Map<string, OfflineDownloadTask>();
// 活跃的下载Promise
const activeDownloads = new Map<string, Promise<void>>();
// 任务持久化文件路径
const TASKS_FILE = path.join(OFFLINE_DOWNLOAD_DIR, 'tasks.json');
/**
* 保存任务到文件
*/
function saveTasks(): void {
try {
const tasksArray = Array.from(tasks.values()).map((task) => ({
...task,
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString(),
}));
// 确保目录存在
const dir = path.dirname(TASKS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(TASKS_FILE, JSON.stringify(tasksArray, null, 2), 'utf-8');
} catch (error) {
console.error('保存任务失败:', error);
}
}
/**
* 从文件加载任务
*/
function loadTasks(): void {
try {
console.log('尝试加载任务文件:', TASKS_FILE);
if (!fs.existsSync(TASKS_FILE)) {
console.log('任务文件不存在:', TASKS_FILE);
return;
}
const content = fs.readFileSync(TASKS_FILE, 'utf-8');
const tasksArray = JSON.parse(content);
console.log(`从文件读取到 ${tasksArray.length} 个任务`);
for (const taskData of tasksArray) {
const task: OfflineDownloadTask = {
...taskData,
createdAt: new Date(taskData.createdAt),
updatedAt: new Date(taskData.updatedAt),
};
// 如果任务在下载或等待中,说明服务器重启了,将状态改为暂停
if (task.status === 'downloading' || task.status === 'pending') {
task.status = 'paused';
task.errorMessage = '服务器重启,任务已暂停';
}
tasks.set(task.id, task);
}
console.log(`已加载 ${tasks.size} 个离线下载任务到内存`);
} catch (error) {
console.error('加载任务失败:', error);
}
}
function getDownloader(): OfflineDownloader {
if (!downloader) {
downloader = new OfflineDownloader(OFFLINE_DOWNLOAD_DIR);
// 首次初始化时加载已保存的任务
loadTasks();
}
return downloader;
}
/**
* 检查用户权限(仅管理员和站长)
*/
function checkPermission(request: NextRequest): boolean {
if (!OFFLINE_DOWNLOAD_ENABLED) {
return false;
}
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return false;
}
// 只有管理员和站长可以使用
return authInfo.role === 'owner' || authInfo.role === 'admin';
}
/**
* GET - 获取任务列表或检查下载状态
*/
export async function GET(request: NextRequest) {
if (!checkPermission(request)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
// 确保下载器已初始化(这会触发任务加载)
getDownloader();
const { searchParams } = new URL(request.url);
const action = searchParams.get('action');
// 检查视频是否已下载
if (action === 'check') {
const source = searchParams.get('source');
const videoId = searchParams.get('videoId');
const episodeIndex = searchParams.get('episodeIndex');
if (!source || !videoId || episodeIndex === null) {
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
}
const downloader = getDownloader();
const downloaded = downloader.checkDownloaded(source, videoId, parseInt(episodeIndex));
return NextResponse.json({ downloaded });
}
// 获取所有任务列表
const taskList = Array.from(tasks.values()).map((task) => ({
...task,
// 转换 Date 对象为 ISO 字符串
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString(),
}));
return NextResponse.json({ tasks: taskList });
}
/**
* POST - 创建离线下载任务
*/
export async function POST(request: NextRequest) {
if (!checkPermission(request)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
try {
const body = await request.json();
const { source, videoId, episodeIndex, title, m3u8Url, metadata } = body;
if (!source || !videoId || episodeIndex === undefined || !title || !m3u8Url) {
return NextResponse.json({ error: '参数不完整' }, { status: 400 });
}
const downloader = getDownloader();
// 1. 首先检查是否已经有相同的任务(任何状态)
const existingTask = Array.from(tasks.values()).find(
(t) =>
t.source === source &&
t.videoId === videoId &&
t.episodeIndex === episodeIndex
);
if (existingTask) {
// 如果任务正在下载或等待中,不允许重复创建
if (existingTask.status === 'downloading' || existingTask.status === 'pending') {
return NextResponse.json(
{
task: {
...existingTask,
createdAt: existingTask.createdAt.toISOString(),
updatedAt: existingTask.updatedAt.toISOString(),
},
message: '该任务正在下载中,请勿重复添加',
},
{ status: 400 }
);
}
// 如果任务已完成,不允许重复创建
if (existingTask.status === 'completed') {
return NextResponse.json(
{
task: {
...existingTask,
createdAt: existingTask.createdAt.toISOString(),
updatedAt: existingTask.updatedAt.toISOString(),
},
message: '该视频已下载完成,如需重新下载请先删除任务',
},
{ status: 400 }
);
}
// 如果任务处于错误或暂停状态,提示用户使用重试功能
if (existingTask.status === 'error' || existingTask.status === 'paused') {
return NextResponse.json(
{
task: {
...existingTask,
createdAt: existingTask.createdAt.toISOString(),
updatedAt: existingTask.updatedAt.toISOString(),
},
message: '该任务已存在但未完成,请使用重试功能继续下载',
},
{ status: 400 }
);
}
}
// 2. 检查文件系统中是否已下载完成(防止任务被删除但文件还在的情况)
const downloaded = downloader.checkDownloaded(source, videoId, episodeIndex);
if (downloaded) {
return NextResponse.json(
{
message: '该视频文件已存在,无需重复下载',
downloaded: true,
},
{ status: 400 }
);
}
// 创建新任务
const task = await downloader.createTask(source, videoId, episodeIndex, title, m3u8Url, metadata);
tasks.set(task.id, task);
saveTasks(); // 持久化任务
// 开始下载(异步)
const downloadPromise = downloader
.startDownload(task, (updatedTask) => {
// 更新任务状态
tasks.set(updatedTask.id, updatedTask);
saveTasks(); // 持久化任务
})
.catch((error) => {
console.error('下载失败:', error);
task.status = 'error';
task.errorMessage = error.message;
tasks.set(task.id, task);
saveTasks(); // 持久化任务
})
.finally(() => {
// 下载完成后,从活跃下载列表中移除
activeDownloads.delete(task.id);
});
activeDownloads.set(task.id, downloadPromise);
return NextResponse.json({
task: {
...task,
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString(),
},
message: '任务已创建',
});
} catch (error) {
console.error('创建任务失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '创建任务失败' },
{ status: 500 }
);
}
}
/**
* DELETE - 删除任务
*/
export async function DELETE(request: NextRequest) {
if (!checkPermission(request)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
try {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get('taskId');
if (!taskId) {
return NextResponse.json({ error: '缺少任务ID' }, { status: 400 });
}
const task = tasks.get(taskId);
if (!task) {
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
}
const downloader = getDownloader();
// 删除文件
await downloader.deleteTask(task);
// 从任务列表中移除
tasks.delete(taskId);
saveTasks(); // 持久化任务
// 如果正在下载,等待下载完成后再删除
const downloadPromise = activeDownloads.get(taskId);
if (downloadPromise) {
activeDownloads.delete(taskId);
}
return NextResponse.json({ message: '任务已删除' });
} catch (error) {
console.error('删除任务失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '删除任务失败' },
{ status: 500 }
);
}
}
/**
* PUT - 重试任务
*/
export async function PUT(request: NextRequest) {
if (!checkPermission(request)) {
return NextResponse.json({ error: '无权限' }, { status: 403 });
}
try {
const { searchParams } = new URL(request.url);
const taskId = searchParams.get('taskId');
const action = searchParams.get('action');
if (!taskId) {
return NextResponse.json({ error: '缺少任务ID' }, { status: 400 });
}
if (action !== 'retry') {
return NextResponse.json({ error: '无效的操作' }, { status: 400 });
}
const task = tasks.get(taskId);
if (!task) {
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
}
// 检查任务状态,只有错误、暂停或完成状态可以重试
if (task.status === 'downloading' || task.status === 'pending') {
return NextResponse.json({ error: '任务正在进行中,无法重试' }, { status: 400 });
}
// 检查是否已经在重试中
if (activeDownloads.has(taskId)) {
return NextResponse.json({ error: '任务已在重试中' }, { status: 400 });
}
const downloader = getDownloader();
// 重置任务状态(保留已下载的进度,只重试失败的片段)
task.status = 'pending';
// 不重置 progress 和 downloadedSegments让下载器自动跳过已下载的片段
task.errorMessage = undefined;
task.updatedAt = new Date();
tasks.set(taskId, task);
saveTasks(); // 持久化任务
// 开始重新下载(异步)
const downloadPromise = downloader
.startDownload(task, (updatedTask) => {
// 更新任务状态
tasks.set(updatedTask.id, updatedTask);
saveTasks(); // 持久化任务
})
.catch((error) => {
console.error('重试下载失败:', error);
task.status = 'error';
task.errorMessage = error.message;
tasks.set(task.id, task);
saveTasks(); // 持久化任务
})
.finally(() => {
// 下载完成后,从活跃下载列表中移除
activeDownloads.delete(task.id);
});
activeDownloads.set(task.id, downloadPromise);
return NextResponse.json({
task: {
...task,
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString(),
},
message: '任务已重新开始',
});
} catch (error) {
console.error('重试任务失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '重试任务失败' },
{ status: 500 }
);
}
}

View File

@@ -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}
/>
{/* 弹幕过滤设置对话框 */}

View File

@@ -16,7 +16,11 @@ interface DownloadEpisodeSelectorProps {
/** 当前集数索引0开始 */
currentEpisodeIndex: number;
/** 下载回调 - 支持批量下载 */
onDownload: (episodeIndexes: number[]) => void;
onDownload: (episodeIndexes: number[], offlineMode: boolean) => void;
/** 是否启用离线下载功能 */
enableOfflineDownload?: boolean;
/** 是否有离线下载权限(管理员或站长) */
hasOfflinePermission?: boolean;
}
/**
@@ -30,12 +34,17 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
videoTitle,
currentEpisodeIndex,
onDownload,
enableOfflineDownload = false,
hasOfflinePermission = false,
}) => {
// 多选状态 - 使用 Set 存储选中的集数索引
const [selectedEpisodes, setSelectedEpisodes] = useState<Set<number>>(
new Set([currentEpisodeIndex])
);
// 离线下载模式
const [offlineMode, setOfflineMode] = useState(false);
// 每页显示的集数
const episodesPerPage = 50;
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
@@ -105,7 +114,7 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
const handleDownload = () => {
const episodeIndexes = Array.from(selectedEpisodes).sort((a, b) => a - b);
onDownload(episodeIndexes);
onDownload(episodeIndexes, offlineMode);
onClose();
};
@@ -161,6 +170,50 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
</div>
</div>
{/* 离线下载开关 - 仅管理员和站长可见 */}
{enableOfflineDownload && hasOfflinePermission && (
<div className='flex items-center justify-between px-6 py-3 bg-blue-50 dark:bg-blue-900/10 border-b border-blue-100 dark:border-blue-900/30'>
<div className='flex items-center gap-3'>
{/* 服务器图标 */}
<div className='flex-shrink-0 w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01' />
</svg>
</div>
<div className='flex-1'>
<div className='flex items-center gap-2'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
线
</h3>
{offlineMode && (
<span className='px-2 py-0.5 text-xs font-medium bg-blue-500 text-white rounded'>
</span>
)}
</div>
<p className='text-xs text-gray-600 dark:text-gray-400 mt-0.5'>
</p>
</div>
</div>
{/* 开关 */}
<button
onClick={() => setOfflineMode(!offlineMode)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
offlineMode
? 'bg-blue-600'
: 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform ${
offlineMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
)}
{/* 分页标签 */}
{pageCount > 1 && (
<div className='flex items-center gap-4 px-6 py-3 border-b border-gray-200 dark:border-gray-700'>
@@ -283,9 +336,13 @@ const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
<button
onClick={handleDownload}
disabled={selectedEpisodes.size === 0}
className='px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 rounded-md transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-green-500'
className={`px-4 py-2 text-sm font-medium text-white rounded-md transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed ${
offlineMode && enableOfflineDownload && hasOfflinePermission
? 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
: 'bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 disabled:hover:bg-green-500'
}`}
>
{selectedEpisodes.size > 0 && `(${selectedEpisodes.size})`}
{offlineMode && enableOfflineDownload && hasOfflinePermission ? '离线' : ''} {selectedEpisodes.size > 0 && `(${selectedEpisodes.size})`}
</button>
</div>
</div>

View File

@@ -0,0 +1,466 @@
'use client';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface OfflineDownloadTask {
id: string;
source: string;
videoId: string;
episodeIndex: number;
title: string;
m3u8Url: string;
status: 'pending' | 'downloading' | 'completed' | 'error' | 'paused';
progress: number;
totalSegments: number;
downloadedSegments: number;
errorMessage?: string;
createdAt: string;
updatedAt: string;
downloadDir: string;
metadata?: {
videoTitle?: string;
cover?: string;
description?: string;
year?: string;
rating?: number;
totalEpisodes?: number;
};
}
interface OfflineDownloadPanelProps {
isOpen: boolean;
onClose: () => void;
}
export function OfflineDownloadPanel({ isOpen, onClose }: OfflineDownloadPanelProps) {
const [tasks, setTasks] = useState<OfflineDownloadTask[]>([]);
const [loading, setLoading] = useState(false);
const [mounted, setMounted] = useState(false);
const [viewMode, setViewMode] = useState<'tasks' | 'library'>('tasks'); // 视图模式:任务列表或视频库
// 确保只在客户端渲染
useEffect(() => {
setMounted(true);
}, []);
// 获取任务列表
const fetchTasks = async () => {
try {
const response = await fetch('/api/offline-download');
if (response.ok) {
const data = await response.json();
setTasks(data.tasks || []);
}
} catch (error) {
console.error('获取离线下载任务列表失败:', error);
}
};
// 删除任务
const handleDeleteTask = async (taskId: string) => {
try {
const response = await fetch(`/api/offline-download?taskId=${taskId}`, {
method: 'DELETE',
});
if (response.ok) {
// 从列表中移除
setTasks((prev) => prev.filter((t) => t.id !== taskId));
} else {
const data = await response.json();
alert(`删除失败: ${data.error}`);
}
} catch (error) {
console.error('删除任务失败:', error);
alert('删除任务失败');
}
};
// 重试任务
const handleRetryTask = async (taskId: string) => {
try {
const response = await fetch(`/api/offline-download?taskId=${taskId}&action=retry`, {
method: 'PUT',
});
if (response.ok) {
const data = await response.json();
// 更新任务状态(保留进度,只重试失败的片段)
setTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
status: 'pending',
errorMessage: undefined,
updatedAt: new Date().toISOString(),
}
: t
)
);
// 立即刷新以获取最新状态
fetchTasks();
} else {
const data = await response.json();
alert(`重试失败: ${data.error}`);
}
} catch (error) {
console.error('重试任务失败:', error);
alert('重试任务失败');
}
};
// 定期刷新任务列表
useEffect(() => {
if (isOpen) {
fetchTasks();
const interval = setInterval(fetchTasks, 3000); // 每3秒刷新一次
return () => clearInterval(interval);
}
}, [isOpen]);
if (!isOpen || !mounted) {
return null;
}
const getStatusText = (status: OfflineDownloadTask['status']) => {
switch (status) {
case 'pending':
return '等待中';
case 'downloading':
return '下载中';
case 'paused':
return '已暂停';
case 'completed':
return '已完成';
case 'error':
return '错误';
default:
return '未知';
}
};
const getStatusColor = (status: OfflineDownloadTask['status']) => {
switch (status) {
case 'pending':
return 'text-gray-500 dark:text-gray-400';
case 'downloading':
return 'text-blue-500 dark:text-blue-400';
case 'paused':
return 'text-yellow-500 dark:text-yellow-400';
case 'completed':
return 'text-green-500 dark:text-green-400';
case 'error':
return 'text-red-500 dark:text-red-400';
default:
return 'text-gray-500 dark:text-gray-400';
}
};
// 获取视频库中的视频按videoId分组包含已完成和有进度的任务
const getLibraryVideos = () => {
// 筛选已完成或有下载进度的任务
const libraryTasks = tasks.filter((t) => t.status === 'completed' || t.progress > 0);
const videoMap = new Map<string, { video: OfflineDownloadTask; episodes: OfflineDownloadTask[] }>();
libraryTasks.forEach((task) => {
const key = `${task.source}_${task.videoId}`;
if (!videoMap.has(key)) {
videoMap.set(key, { video: task, episodes: [] });
}
videoMap.get(key)!.episodes.push(task);
});
// 按集数排序
videoMap.forEach((value) => {
value.episodes.sort((a, b) => a.episodeIndex - b.episodeIndex);
});
return Array.from(videoMap.values());
};
const libraryVideos = getLibraryVideos();
const panelContent = (
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4'>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col'>
{/* 标题栏 */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h2 className='text-xl font-bold text-gray-900 dark:text-white'>
{viewMode === 'tasks' ? '离线下载任务列表' : '视频库'}
</h2>
<div className='flex items-center gap-3'>
{/* 视图切换按钮 */}
<div className='flex gap-2'>
<button
onClick={() => setViewMode('tasks')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
viewMode === 'tasks'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
</button>
<button
onClick={() => setViewMode('library')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
viewMode === 'library'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
({libraryVideos.length})
</button>
</div>
{/* 关闭按钮 */}
<button
onClick={onClose}
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
</div>
{/* 内容区域 */}
<div className='flex-1 overflow-y-auto p-4 space-y-3'>
{loading ? (
<div className='flex items-center justify-center h-full'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500'></div>
</div>
) : viewMode === 'library' ? (
// 视频库视图
libraryVideos.length === 0 ? (
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z'
/>
</svg>
<p className='text-lg'></p>
</div>
) : (
libraryVideos.map(({ video, episodes }) => (
<div
key={`${video.source}_${video.videoId}`}
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
>
<div className='flex gap-4'>
{/* 封面图 */}
{video.metadata?.cover && (
<div className='flex-shrink-0'>
<img
src={video.metadata.cover}
alt={video.metadata.videoTitle || video.title}
className='w-32 h-48 object-cover rounded'
/>
</div>
)}
{/* 视频信息 */}
<div className='flex-1 min-w-0'>
<h3 className='text-lg font-bold text-gray-900 dark:text-white mb-2'>
{video.metadata?.videoTitle || video.title}
</h3>
{video.metadata?.year && (
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>: {video.metadata.year}</p>
)}
{video.metadata?.rating && (
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>
: {video.metadata.rating.toFixed(1)}
</p>
)}
{video.metadata?.description && (
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2'>
{video.metadata.description}
</p>
)}
<div className='flex items-center gap-2 mb-3'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
{episodes.length}
</span>
{video.metadata?.totalEpisodes && (
<span className='text-sm text-gray-600 dark:text-gray-400'>
/ {video.metadata.totalEpisodes}
</span>
)}
</div>
{/* 集数列表 */}
<div className='flex flex-wrap gap-2 mb-3'>
{episodes.map((ep) => (
<div
key={ep.id}
className='flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 text-xs rounded group'
>
<span>{ep.episodeIndex + 1}</span>
<button
onClick={() => {
if (confirm(`确定要删除第${ep.episodeIndex + 1}集吗?`)) {
handleDeleteTask(ep.id);
}
}}
className='ml-1 opacity-0 group-hover:opacity-100 transition-opacity text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300'
title='删除此集'
>
<svg className='w-3 h-3' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
))}
</div>
{/* 删除全部按钮 */}
<button
onClick={() => {
if (confirm(`确定要删除《${video.metadata?.videoTitle || video.title}》的所有已下载集数吗?`)) {
episodes.forEach((ep) => handleDeleteTask(ep.id));
}
}}
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
/>
</svg>
</button>
</div>
</div>
</div>
))
)
) : tasks.length === 0 ? (
// 任务列表为空
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4'
/>
</svg>
<p className='text-lg'>线</p>
</div>
) : (
// 任务列表视图
tasks.map((task) => (
<div
key={task.id}
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
>
{/* 任务信息 */}
<div className='flex items-start justify-between mb-3'>
<div className='flex-1 min-w-0'>
<h3 className='text-sm font-medium text-gray-900 dark:text-white truncate mb-1'>
{task.title}
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
: {task.source} | ID: {task.videoId} | {task.episodeIndex + 1}
</p>
</div>
<div className='flex items-center gap-2 ml-4'>
<span className={`text-xs font-medium ${getStatusColor(task.status)}`}>
{getStatusText(task.status)}
</span>
</div>
</div>
{/* 进度条 */}
{task.totalSegments > 0 && (
<div className='mb-3'>
<div className='flex items-center justify-between text-xs text-gray-600 dark:text-gray-300 mb-1'>
<span>
{task.downloadedSegments} / {task.totalSegments}
</span>
<span>{task.progress.toFixed(1)}%</span>
</div>
<div className='w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden'>
<div
className={`h-full rounded-full transition-all duration-300 ${
task.status === 'downloading'
? 'bg-gradient-to-r from-blue-500 to-purple-600 animate-pulse'
: task.status === 'completed'
? 'bg-green-500'
: task.status === 'error'
? 'bg-red-500'
: 'bg-gray-400'
}`}
style={{ width: `${task.progress}%` }}
></div>
</div>
</div>
)}
{/* 错误信息 */}
{task.errorMessage && (
<div className='mb-3'>
<div className='text-xs text-red-500 dark:text-red-400'>{task.errorMessage}</div>
</div>
)}
{/* 时间信息 */}
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3'>
<span>: {new Date(task.createdAt).toLocaleString('zh-CN')}</span>
<span>: {new Date(task.updatedAt).toLocaleString('zh-CN')}</span>
</div>
{/* 操作按钮 */}
<div className='flex items-center gap-2'>
{/* 重试按钮 - 只在错误或暂停状态显示 */}
{(task.status === 'error' || task.status === 'paused') && (
<button
onClick={() => handleRetryTask(task.id)}
className='flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-medium rounded transition-colors'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
/>
</svg>
</button>
)}
<button
onClick={() => handleDeleteTask(task.id)}
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
/>
</svg>
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
);
return createPortal(panelContent, document.body);
}

View File

@@ -6,6 +6,7 @@ import {
Check,
ChevronDown,
Copy,
Download,
ExternalLink,
KeyRound,
LogOut,
@@ -26,6 +27,7 @@ import { UpdateStatus } from '@/lib/version_check';
import { useVersionCheck } from './VersionCheckProvider';
import { VersionPanel } from './VersionPanel';
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
interface AuthInfo {
username?: string;
@@ -40,6 +42,7 @@ export const UserMenu: React.FC = () => {
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
@@ -52,7 +55,7 @@ export const UserMenu: React.FC = () => {
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen) {
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen) {
const body = document.body;
const html = document.documentElement;
@@ -71,7 +74,7 @@ export const UserMenu: React.FC = () => {
html.style.overflow = originalHtmlOverflow;
};
}
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen]);
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen]);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
@@ -530,6 +533,12 @@ export const UserMenu: React.FC = () => {
const showAdminPanel =
authInfo?.role === 'owner' || authInfo?.role === 'admin';
// 检查是否显示离线下载按钮
const showOfflineDownload =
(authInfo?.role === 'owner' || authInfo?.role === 'admin') &&
typeof window !== 'undefined' &&
process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true';
// 检查是否显示修改密码按钮
const showChangePassword =
authInfo?.role !== 'owner' && storageType !== 'localstorage';
@@ -611,6 +620,20 @@ export const UserMenu: React.FC = () => {
</button>
)}
{/* 离线下载按钮 */}
{showOfflineDownload && (
<button
onClick={() => {
setIsOfflineDownloadPanelOpen(true);
setIsOpen(false);
}}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
>
<Download className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'>线</span>
</button>
)}
{/* 修改密码按钮 */}
{showChangePassword && (
<button
@@ -1361,6 +1384,12 @@ export const UserMenu: React.FC = () => {
isOpen={isVersionPanelOpen}
onClose={() => setIsVersionPanelOpen(false)}
/>
{/* 离线下载面板 */}
<OfflineDownloadPanel
isOpen={isOfflineDownloadPanelOpen}
onClose={() => setIsOfflineDownloadPanelOpen(false)}
/>
</>
);
};

View File

@@ -6,6 +6,7 @@ export function getAuthInfoFromCookie(request: NextRequest): {
username?: string;
signature?: string;
timestamp?: number;
role?: 'owner' | 'admin' | 'user';
} | null {
const authCookie = request.cookies.get('auth');

View File

@@ -0,0 +1,692 @@
/**
* 服务器端离线下载器
* 用于在服务器端下载 M3U8 视频到本地文件系统
*/
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import * as http from 'http';
import * as zlib from 'zlib';
import { URL } from 'url';
export interface OfflineDownloadTask {
id: string;
source: string;
videoId: string;
episodeIndex: number;
title: string;
m3u8Url: string;
status: 'pending' | 'downloading' | 'completed' | 'error' | 'paused';
progress: number;
totalSegments: number;
downloadedSegments: number;
errorMessage?: string;
createdAt: Date;
updatedAt: Date;
downloadDir: string;
// 视频元数据
metadata?: {
videoTitle?: string; // 视频总标题(如:某某动漫)
cover?: string; // 封面图片URL
description?: string; // 视频描述
year?: string; // 年份
rating?: number; // 评分
totalEpisodes?: number; // 总集数
};
}
interface SegmentInfo {
url: string;
filename: string;
duration: number;
}
interface KeyInfo {
method: string;
uri: string;
iv?: string;
}
export class OfflineDownloader {
private baseDir: string;
private maxRetries = 3;
private retryDelay = 1000; // ms
private concurrency = 6;
constructor(baseDir: string) {
this.baseDir = baseDir;
this.ensureDir(this.baseDir);
}
/**
* 创建下载任务
*/
async createTask(
source: string,
videoId: string,
episodeIndex: number,
title: string,
m3u8Url: string,
metadata?: {
videoTitle?: string;
cover?: string;
description?: string;
year?: string;
rating?: number;
totalEpisodes?: number;
}
): Promise<OfflineDownloadTask> {
const taskId = `${source}_${videoId}_${episodeIndex}_${Date.now()}`;
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
const task: OfflineDownloadTask = {
id: taskId,
source,
videoId,
episodeIndex,
title,
m3u8Url,
status: 'pending',
progress: 0,
totalSegments: 0,
downloadedSegments: 0,
createdAt: new Date(),
updatedAt: new Date(),
downloadDir,
metadata,
};
return task;
}
/**
* 开始下载任务
*/
async startDownload(
task: OfflineDownloadTask,
onProgress?: (task: OfflineDownloadTask) => void
): Promise<void> {
try {
task.status = 'downloading';
task.updatedAt = new Date();
onProgress?.(task);
// 确保下载目录存在
this.ensureDir(task.downloadDir);
// 检查是否已经下载过(避免重复下载)
const playlistPath = path.join(task.downloadDir, 'playlist.m3u8');
if (fs.existsSync(playlistPath)) {
// 检查所有文件是否完整
const isComplete = await this.verifyDownload(task.downloadDir);
if (isComplete) {
task.status = 'completed';
task.progress = 100;
task.updatedAt = new Date();
onProgress?.(task);
return;
}
}
// 下载 M3U8 文件
let m3u8Content = await this.fetchContent(task.m3u8Url);
let finalM3u8Url = task.m3u8Url;
// 检查是否为主播放列表(包含多个分辨率)
if (this.isMasterPlaylist(m3u8Content)) {
console.log('检测到主播放列表,正在选择最高分辨率...');
// 解析主播放列表获取最高分辨率的子播放列表URL
const bestVariantUrl = this.selectBestVariant(m3u8Content, task.m3u8Url);
if (bestVariantUrl) {
console.log('已选择最高分辨率流:', bestVariantUrl);
finalM3u8Url = bestVariantUrl;
// 下载子播放列表
m3u8Content = await this.fetchContent(bestVariantUrl);
} else {
console.warn('无法找到子播放列表使用原始URL');
}
}
// 解析 M3U8 文件
const { segments, keyInfo } = this.parseM3U8(m3u8Content, finalM3u8Url);
task.totalSegments = segments.length;
onProgress?.(task);
// 下载解密 Key如果有
if (keyInfo && keyInfo.uri) {
await this.downloadKey(keyInfo, task.downloadDir);
}
// 下载所有片段
await this.downloadSegments(segments, task, onProgress);
// 生成本地播放列表,保持原始格式
this.generateLocalPlaylist(m3u8Content, segments, keyInfo, task.downloadDir);
task.status = 'completed';
task.progress = 100;
task.updatedAt = new Date();
onProgress?.(task);
} catch (error) {
task.status = 'error';
task.errorMessage = error instanceof Error ? error.message : String(error);
task.updatedAt = new Date();
onProgress?.(task);
throw error;
}
}
/**
* 检查是否为主播放列表Master Playlist
*/
private isMasterPlaylist(content: string): boolean {
return content.includes('#EXT-X-STREAM-INF:');
}
/**
* 从主播放列表中选择最高分辨率的变体
*/
private selectBestVariant(content: string, baseUrl: string): string | null {
const lines = content.split('\n').map((line) => line.trim());
interface Variant {
url: string;
bandwidth: number;
resolution?: { width: number; height: number };
}
const variants: Variant[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXT-X-STREAM-INF:')) {
// 提取带宽信息
const bandwidthMatch = line.match(/BANDWIDTH=(\d+)/);
const bandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
// 提取分辨率信息
const resolutionMatch = line.match(/RESOLUTION=(\d+)x(\d+)/);
const resolution = resolutionMatch
? { width: parseInt(resolutionMatch[1], 10), height: parseInt(resolutionMatch[2], 10) }
: undefined;
// 下一行应该是子播放列表的URL
if (i + 1 < lines.length) {
const nextLine = lines[i + 1];
if (nextLine && !nextLine.startsWith('#')) {
const variantUrl = this.resolveUrl(nextLine, baseUrl);
variants.push({ url: variantUrl, bandwidth, resolution });
}
}
}
}
if (variants.length === 0) {
return null;
}
// 优先按分辨率排序(宽度 * 高度),如果没有分辨率信息则按带宽排序
variants.sort((a, b) => {
// 如果两者都有分辨率信息,按分辨率排序
if (a.resolution && b.resolution) {
const aPixels = a.resolution.width * a.resolution.height;
const bPixels = b.resolution.width * b.resolution.height;
if (aPixels !== bPixels) {
return bPixels - aPixels; // 降序
}
}
// 如果只有一个有分辨率信息,优先选择有分辨率的
if (a.resolution && !b.resolution) return -1;
if (!a.resolution && b.resolution) return 1;
// 都没有分辨率信息,或分辨率相同,则按带宽排序
return b.bandwidth - a.bandwidth; // 降序
});
console.log('可用的流变体:', variants.map(v => ({
url: v.url,
bandwidth: v.bandwidth,
resolution: v.resolution ? `${v.resolution.width}x${v.resolution.height}` : '未知',
})));
return variants[0].url;
}
/**
* 解析 M3U8 文件
*/
private parseM3U8(
content: string,
baseUrl: string
): { segments: SegmentInfo[]; keyInfo: KeyInfo | null } {
const lines = content.split('\n').map((line) => line.trim());
const segments: SegmentInfo[] = [];
let keyInfo: KeyInfo | null = null;
let currentDuration = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 解析时长
if (line.startsWith('#EXTINF:')) {
const match = line.match(/#EXTINF:([\d.]+)/);
if (match) {
currentDuration = parseFloat(match[1]);
}
}
// 解析加密信息
else if (line.startsWith('#EXT-X-KEY:')) {
const methodMatch = line.match(/METHOD=([^,]+)/);
const uriMatch = line.match(/URI="([^"]+)"/);
const ivMatch = line.match(/IV=([^,\s]+)/);
if (methodMatch && methodMatch[1] !== 'NONE') {
keyInfo = {
method: methodMatch[1],
uri: uriMatch ? this.resolveUrl(uriMatch[1], baseUrl) : '',
iv: ivMatch ? ivMatch[1] : undefined,
};
}
}
// 解析片段 URL
else if (line && !line.startsWith('#')) {
const segmentUrl = this.resolveUrl(line, baseUrl);
const filename = `segment_${segments.length.toString().padStart(5, '0')}.ts`;
segments.push({
url: segmentUrl,
filename,
duration: currentDuration,
});
currentDuration = 0;
}
}
return { segments, keyInfo };
}
/**
* 下载所有片段(带重试和并发控制)
*/
private async downloadSegments(
segments: SegmentInfo[],
task: OfflineDownloadTask,
onProgress?: (task: OfflineDownloadTask) => void
): Promise<void> {
const queue = [...segments];
const downloading: Promise<void>[] = [];
let downloadedCount = 0;
const downloadNext = async (): Promise<void> => {
if (queue.length === 0) return;
const segment = queue.shift()!;
const segmentPath = path.join(task.downloadDir, segment.filename);
// 如果文件已存在且大小 > 0跳过下载
if (fs.existsSync(segmentPath) && fs.statSync(segmentPath).size > 0) {
downloadedCount++;
task.downloadedSegments = downloadedCount;
task.progress = Math.round((downloadedCount / task.totalSegments) * 100);
task.updatedAt = new Date();
onProgress?.(task);
return downloadNext();
}
// 下载片段(带重试)
await this.downloadWithRetry(segment.url, segmentPath);
downloadedCount++;
task.downloadedSegments = downloadedCount;
task.progress = Math.round((downloadedCount / task.totalSegments) * 100);
task.updatedAt = new Date();
onProgress?.(task);
return downloadNext();
};
// 并发下载
for (let i = 0; i < Math.min(this.concurrency, segments.length); i++) {
downloading.push(downloadNext());
}
await Promise.all(downloading);
}
/**
* 下载解密 Key
*/
private async downloadKey(keyInfo: KeyInfo, downloadDir: string): Promise<void> {
if (!keyInfo.uri) return;
const keyPath = path.join(downloadDir, 'key.key');
// 如果 key 已存在,跳过下载
if (fs.existsSync(keyPath) && fs.statSync(keyPath).size > 0) {
return;
}
await this.downloadWithRetry(keyInfo.uri, keyPath);
}
/**
* 下载文件(带重试)
*/
private async downloadWithRetry(url: string, savePath: string): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
await this.downloadFile(url, savePath);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`下载失败 (尝试 ${attempt + 1}/${this.maxRetries}): ${url}`, error);
if (attempt < this.maxRetries - 1) {
await this.sleep(this.retryDelay * (attempt + 1));
}
}
}
throw new Error(`下载失败(已重试 ${this.maxRetries} 次): ${url}\n${lastError?.message}`);
}
/**
* 获取标准浏览器请求头
*/
private getHeaders(url: string): Record<string, string> {
const urlObj = new URL(url);
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Origin': `${urlObj.protocol}//${urlObj.host}`,
'Referer': `${urlObj.protocol}//${urlObj.host}/`,
'Connection': 'keep-alive',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
};
}
/**
* 下载单个文件
*/
private async downloadFile(url: string, savePath: string): Promise<void> {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = urlObj.protocol === 'https:' ? https : http;
const options = {
headers: this.getHeaders(url),
};
const request = client.get(url, options, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
// 处理重定向
const redirectUrl = response.headers.location;
if (redirectUrl) {
this.downloadFile(redirectUrl, savePath).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
// 检查内容编码,处理压缩
const encoding = response.headers['content-encoding'];
let stream: NodeJS.ReadableStream = response;
if (encoding === 'gzip') {
stream = response.pipe(zlib.createGunzip());
} else if (encoding === 'deflate') {
stream = response.pipe(zlib.createInflate());
} else if (encoding === 'br') {
stream = response.pipe(zlib.createBrotliDecompress());
}
const fileStream = fs.createWriteStream(savePath);
stream.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve();
});
fileStream.on('error', (err) => {
fs.unlink(savePath, () => {});
reject(err);
});
stream.on('error', (err) => {
fs.unlink(savePath, () => {});
reject(err);
});
});
request.on('error', (err) => {
reject(err);
});
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('Request timeout'));
});
});
}
/**
* 获取内容(用于 M3U8 文件)
*/
private async fetchContent(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const client = urlObj.protocol === 'https:' ? https : http;
const options = {
headers: this.getHeaders(url),
};
const request = client.get(url, options, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
this.fetchContent(redirectUrl).then(resolve).catch(reject);
return;
}
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
// 检查内容编码,处理压缩
const encoding = response.headers['content-encoding'];
let stream: NodeJS.ReadableStream = response;
if (encoding === 'gzip') {
stream = response.pipe(zlib.createGunzip());
} else if (encoding === 'deflate') {
stream = response.pipe(zlib.createInflate());
} else if (encoding === 'br') {
stream = response.pipe(zlib.createBrotliDecompress());
}
let data = '';
stream.on('data', (chunk) => {
data += chunk.toString('utf-8');
});
stream.on('end', () => {
resolve(data);
});
stream.on('error', (err) => {
reject(err);
});
});
request.on('error', reject);
request.setTimeout(10000, () => {
request.destroy();
reject(new Error('Request timeout'));
});
});
}
/**
* 生成本地播放列表保持原始格式只替换片段URL
*/
private generateLocalPlaylist(
originalM3u8Content: string,
segments: SegmentInfo[],
keyInfo: KeyInfo | null,
downloadDir: string
): void {
const lines = originalM3u8Content.split('\n');
const modifiedLines: string[] = [];
let segmentIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// 替换 Key URI
if (trimmedLine.startsWith('#EXT-X-KEY:')) {
if (keyInfo && keyInfo.method !== 'NONE') {
// 替换 URI 为本地 key.key
const modifiedLine = line.replace(/URI="[^"]+"/g, 'URI="key.key"');
modifiedLines.push(modifiedLine);
} else {
modifiedLines.push(line);
}
}
// 替换视频片段 URL
else if (trimmedLine && !trimmedLine.startsWith('#')) {
// 这是一个视频片段,替换为本地文件名
if (segmentIndex < segments.length) {
const indent = line.match(/^\s*/)?.[0] || '';
modifiedLines.push(indent + segments[segmentIndex].filename);
segmentIndex++;
} else {
// 如果超出了片段数量,保留原始行(不应该发生)
modifiedLines.push(line);
}
}
// 保持其他所有行不变
else {
modifiedLines.push(line);
}
}
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
fs.writeFileSync(playlistPath, modifiedLines.join('\n'), 'utf-8');
}
/**
* 验证下载是否完整
*/
private async verifyDownload(downloadDir: string): Promise<boolean> {
try {
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
if (!fs.existsSync(playlistPath)) {
return false;
}
const content = fs.readFileSync(playlistPath, 'utf-8');
const lines = content.split('\n').map((line) => line.trim());
// 检查所有 ts 文件是否存在
for (const line of lines) {
if (line && !line.startsWith('#')) {
const segmentPath = path.join(downloadDir, line);
if (!fs.existsSync(segmentPath) || fs.statSync(segmentPath).size === 0) {
return false;
}
}
}
return true;
} catch {
return false;
}
}
/**
* 解析相对 URL
*/
private resolveUrl(targetUrl: string, baseUrl: string): string {
if (targetUrl.startsWith('http://') || targetUrl.startsWith('https://')) {
return targetUrl;
}
try {
const base = new URL(baseUrl);
if (targetUrl.startsWith('/')) {
return `${base.protocol}//${base.host}${targetUrl}`;
} else {
const basePath = base.pathname.substring(0, base.pathname.lastIndexOf('/') + 1);
return `${base.protocol}//${base.host}${basePath}${targetUrl}`;
}
} catch {
return targetUrl;
}
}
/**
* 确保目录存在
*/
private ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* 延迟函数
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 删除下载任务的所有文件
*/
async deleteTask(task: OfflineDownloadTask): Promise<void> {
if (fs.existsSync(task.downloadDir)) {
fs.rmSync(task.downloadDir, { recursive: true, force: true });
}
}
/**
* 检查视频是否已下载
*/
checkDownloaded(source: string, videoId: string, episodeIndex: number): boolean {
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
return fs.existsSync(playlistPath);
}
/**
* 获取本地播放列表路径
*/
getLocalPlaylistPath(source: string, videoId: string, episodeIndex: number): string | null {
const downloadDir = path.join(this.baseDir, source, videoId, `ep${episodeIndex + 1}`);
const playlistPath = path.join(downloadDir, 'playlist.m3u8');
return fs.existsSync(playlistPath) ? playlistPath : null;
}
}