133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
/**
|
|
* 本地下载视频播放代理 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 }
|
|
);
|
|
}
|
|
}
|