视频源增加代理开关
This commit is contained in:
@@ -358,6 +358,7 @@ interface DataSource {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
from: 'config' | 'custom';
|
from: 'config' | 'custom';
|
||||||
|
proxyMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直播源数据类型
|
// 直播源数据类型
|
||||||
@@ -3490,6 +3491,53 @@ const VideoSourceConfig = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleProxyMode = (key: string) => {
|
||||||
|
const target = sources.find((s) => s.key === key);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
setSources((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.key === key ? { ...s, proxyMode: !s.proxyMode } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 调用API更新
|
||||||
|
withLoading(`toggleProxyMode_${key}`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/source', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'toggle_proxy_mode',
|
||||||
|
key,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `操作失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshConfig();
|
||||||
|
} catch (error) {
|
||||||
|
// 失败时回滚本地状态
|
||||||
|
setSources((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.key === key ? { ...s, proxyMode: !s.proxyMode } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
showError(
|
||||||
|
error instanceof Error ? error.message : '切换代理模式失败',
|
||||||
|
showAlert
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
console.error('操作失败', 'toggle_proxy_mode', key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddSource = () => {
|
const handleAddSource = () => {
|
||||||
if (!newSource.name || !newSource.key || !newSource.api) return;
|
if (!newSource.name || !newSource.key || !newSource.api) return;
|
||||||
withLoading('addSource', async () => {
|
withLoading('addSource', async () => {
|
||||||
@@ -3777,6 +3825,31 @@ const VideoSourceConfig = ({
|
|||||||
{!source.disabled ? '启用中' : '已禁用'}
|
{!source.disabled ? '启用中' : '已禁用'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap text-center'>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleProxyMode(source.key);
|
||||||
|
}}
|
||||||
|
disabled={isLoading(`toggleProxyMode_${source.key}`)}
|
||||||
|
className={`relative inline-flex items-center h-6 w-11 rounded-full transition-colors ${
|
||||||
|
source.proxyMode
|
||||||
|
? 'bg-blue-600 dark:bg-blue-500'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
} ${
|
||||||
|
isLoading(`toggleProxyMode_${source.key}`)
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
}`}
|
||||||
|
title={source.proxyMode ? '代理模式已启用' : '代理模式已禁用'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
source.proxyMode ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||||
{(() => {
|
{(() => {
|
||||||
const status = getValidationStatus(source.key);
|
const status = getValidationStatus(source.key);
|
||||||
@@ -4128,6 +4201,9 @@ const VideoSourceConfig = ({
|
|||||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
状态
|
状态
|
||||||
</th>
|
</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
代理模式
|
||||||
|
</th>
|
||||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
有效性
|
有效性
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { db } from '@/lib/db';
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
// 支持的操作类型
|
// 支持的操作类型
|
||||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete' | 'toggle_proxy_mode';
|
||||||
|
|
||||||
interface BaseBody {
|
interface BaseBody {
|
||||||
action?: Action;
|
action?: Action;
|
||||||
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const username = authInfo.username;
|
const username = authInfo.username;
|
||||||
|
|
||||||
// 基础校验
|
// 基础校验
|
||||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete', 'toggle_proxy_mode'];
|
||||||
if (!username || !action || !ACTIONS.includes(action)) {
|
if (!username || !action || !ACTIONS.includes(action)) {
|
||||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -226,6 +226,16 @@ export async function POST(request: NextRequest) {
|
|||||||
adminConfig.SourceConfig = newList;
|
adminConfig.SourceConfig = newList;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'toggle_proxy_mode': {
|
||||||
|
const { key } = body as { key?: string };
|
||||||
|
if (!key)
|
||||||
|
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||||
|
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||||
|
if (!entry)
|
||||||
|
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||||
|
entry.proxyMode = !entry.proxyMode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export async function GET(request: NextRequest) {
|
|||||||
desc: folderMeta?.overview || '',
|
desc: folderMeta?.overview || '',
|
||||||
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`),
|
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`),
|
||||||
episodes_titles: episodes.map((ep) => ep.title),
|
episodes_titles: episodes.map((ep) => ep.title),
|
||||||
|
proxyMode: false, // openlist 源不使用代理模式
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
@@ -167,9 +168,16 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await getDetailFromApi(apiSite, id);
|
const result = await getDetailFromApi(apiSite, id);
|
||||||
|
|
||||||
|
// 添加 proxyMode 到返回结果
|
||||||
|
const resultWithProxy = {
|
||||||
|
...result,
|
||||||
|
proxyMode: apiSite.proxyMode || false,
|
||||||
|
};
|
||||||
|
|
||||||
const cacheTime = await getCacheTime();
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
return NextResponse.json(result, {
|
return NextResponse.json(resultWithProxy, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
const source = searchParams.get('source');
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查该视频源是否启用了代理模式
|
||||||
|
const config = await getConfig();
|
||||||
|
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||||
|
|
||||||
|
if (!videoSource) {
|
||||||
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoSource.proxyMode) {
|
||||||
|
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
const response = await fetch(decodedUrl, {
|
||||||
|
headers: {
|
||||||
|
'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',
|
||||||
|
'Referer': decodedUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
|
||||||
|
headers.set('Access-Control-Allow-Origin', '*');
|
||||||
|
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||||
|
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||||
|
|
||||||
|
return new Response(response.body, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getConfig } from "@/lib/config";
|
||||||
|
import { getBaseUrl, resolveUrl } from "@/lib/live";
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
const source = searchParams.get('source'); // 视频源key
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查该视频源是否启用了代理模式
|
||||||
|
const config = await getConfig();
|
||||||
|
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||||
|
|
||||||
|
if (!videoSource) {
|
||||||
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoSource.proxyMode) {
|
||||||
|
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response | null = null;
|
||||||
|
let responseUsed = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
|
||||||
|
response = await fetch(decodedUrl, {
|
||||||
|
cache: 'no-cache',
|
||||||
|
redirect: 'follow',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'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',
|
||||||
|
'Referer': decodedUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('Content-Type') || '';
|
||||||
|
// rewrite m3u8
|
||||||
|
if (contentType.toLowerCase().includes('mpegurl') || contentType.toLowerCase().includes('octet-stream') || decodedUrl.includes('.m3u8')) {
|
||||||
|
// 获取最终的响应URL(处理重定向后的URL)
|
||||||
|
const finalUrl = response.url;
|
||||||
|
const m3u8Content = await response.text();
|
||||||
|
responseUsed = true; // 标记 response 已被使用
|
||||||
|
|
||||||
|
// 使用最终的响应URL作为baseUrl,而不是原始的请求URL
|
||||||
|
const baseUrl = getBaseUrl(finalUrl);
|
||||||
|
|
||||||
|
// 重写 M3U8 内容
|
||||||
|
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
|
||||||
|
headers.set('Access-Control-Allow-Origin', '*');
|
||||||
|
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||||
|
headers.set('Cache-Control', 'no-cache');
|
||||||
|
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||||
|
return new Response(modifiedContent, { headers });
|
||||||
|
}
|
||||||
|
// just proxy
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl');
|
||||||
|
headers.set('Access-Control-Allow-Origin', '*');
|
||||||
|
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||||
|
headers.set('Cache-Control', 'no-cache');
|
||||||
|
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||||
|
|
||||||
|
// 直接返回视频流
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
// 确保 response 被正确关闭以释放资源
|
||||||
|
if (response && !responseUsed) {
|
||||||
|
try {
|
||||||
|
response.body?.cancel();
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略关闭时的错误
|
||||||
|
console.warn('Failed to close response body:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, source: string) {
|
||||||
|
// 从 referer 头提取协议信息
|
||||||
|
const referer = req.headers.get('referer');
|
||||||
|
let protocol = 'http';
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
const refererUrl = new URL(referer);
|
||||||
|
protocol = refererUrl.protocol.replace(':', '');
|
||||||
|
} catch (error) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = req.headers.get('host');
|
||||||
|
const proxyBase = `${protocol}://${host}/api/proxy/vod`;
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const rewrittenLines: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i].trim();
|
||||||
|
|
||||||
|
// 处理 TS 片段 URL 和其他媒体文件
|
||||||
|
if (line && !line.startsWith('#')) {
|
||||||
|
const resolvedUrl = resolveUrl(baseUrl, line);
|
||||||
|
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
||||||
|
rewrittenLines.push(proxyUrl);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 EXT-X-MAP 标签中的 URI
|
||||||
|
if (line.startsWith('#EXT-X-MAP:')) {
|
||||||
|
line = rewriteMapUri(line, baseUrl, proxyBase, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 EXT-X-KEY 标签中的 URI
|
||||||
|
if (line.startsWith('#EXT-X-KEY:')) {
|
||||||
|
line = rewriteKeyUri(line, baseUrl, proxyBase, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
|
||||||
|
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||||
|
rewrittenLines.push(line);
|
||||||
|
// 下一行通常是 M3U8 URL
|
||||||
|
if (i + 1 < lines.length) {
|
||||||
|
i++;
|
||||||
|
const nextLine = lines[i].trim();
|
||||||
|
if (nextLine && !nextLine.startsWith('#')) {
|
||||||
|
const resolvedUrl = resolveUrl(baseUrl, nextLine);
|
||||||
|
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
||||||
|
rewrittenLines.push(proxyUrl);
|
||||||
|
} else {
|
||||||
|
rewrittenLines.push(nextLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rewrittenLines.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewrittenLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, source: string) {
|
||||||
|
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||||
|
if (uriMatch) {
|
||||||
|
const originalUri = uriMatch[1];
|
||||||
|
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||||
|
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
||||||
|
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, source: string) {
|
||||||
|
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||||
|
if (uriMatch) {
|
||||||
|
const originalUri = uriMatch[1];
|
||||||
|
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||||
|
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}&source=${source}`;
|
||||||
|
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
const source = searchParams.get('source');
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查该视频源是否启用了代理模式
|
||||||
|
const config = await getConfig();
|
||||||
|
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||||
|
|
||||||
|
if (!videoSource) {
|
||||||
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoSource.proxyMode) {
|
||||||
|
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response | null = null;
|
||||||
|
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
response = await fetch(decodedUrl, {
|
||||||
|
headers: {
|
||||||
|
'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',
|
||||||
|
'Referer': decodedUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t');
|
||||||
|
headers.set('Access-Control-Allow-Origin', '*');
|
||||||
|
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||||
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
|
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
if (contentLength) {
|
||||||
|
headers.set('Content-Length', contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用流式传输,避免占用内存
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
if (!response?.body) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reader = response.body.getReader();
|
||||||
|
const isCancelled = false;
|
||||||
|
|
||||||
|
function pump() {
|
||||||
|
if (isCancelled || !reader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.read().then(({ done, value }) => {
|
||||||
|
if (isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
controller.close();
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.enqueue(value);
|
||||||
|
pump();
|
||||||
|
}).catch((error) => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
controller.error(error);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (reader) {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch (e) {
|
||||||
|
// reader 可能已经被释放,忽略错误
|
||||||
|
}
|
||||||
|
reader = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pump();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
// 当流被取消时,确保释放所有资源
|
||||||
|
if (reader) {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch (e) {
|
||||||
|
// reader 可能已经被释放,忽略错误
|
||||||
|
}
|
||||||
|
reader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.body) {
|
||||||
|
try {
|
||||||
|
response.body.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略取消时的错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
// 确保在错误情况下也释放资源
|
||||||
|
if (reader) {
|
||||||
|
try {
|
||||||
|
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.body) {
|
||||||
|
try {
|
||||||
|
response.body.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -681,6 +681,9 @@ function PlayPageClient() {
|
|||||||
// 视频播放地址
|
// 视频播放地址
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
|
||||||
|
// 视频源代理模式状态
|
||||||
|
const [sourceProxyMode, setSourceProxyMode] = useState(false);
|
||||||
|
|
||||||
// 总集数
|
// 总集数
|
||||||
const totalEpisodes = detail?.episodes?.length || 0;
|
const totalEpisodes = detail?.episodes?.length || 0;
|
||||||
|
|
||||||
@@ -1048,9 +1051,13 @@ function PlayPageClient() {
|
|||||||
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
|
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
|
||||||
|
|
||||||
if (hasLocalFile) {
|
if (hasLocalFile) {
|
||||||
// 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别
|
// 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别
|
||||||
newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`;
|
newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`;
|
||||||
console.log('使用本地下载文件播放:', newUrl);
|
console.log('使用本地下载文件播放:', newUrl);
|
||||||
|
} else if (sourceProxyMode && newUrl) {
|
||||||
|
// 如果视频源启用了代理模式,且不是本地下载,则通过代理播放
|
||||||
|
newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`;
|
||||||
|
console.log('使用代理模式播放:', newUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newUrl !== videoUrl) {
|
if (newUrl !== videoUrl) {
|
||||||
@@ -2041,6 +2048,7 @@ function PlayPageClient() {
|
|||||||
setVideoCover(detailData.poster);
|
setVideoCover(detailData.poster);
|
||||||
setVideoDoubanId(detailData.douban_id || 0);
|
setVideoDoubanId(detailData.douban_id || 0);
|
||||||
setDetail(detailData);
|
setDetail(detailData);
|
||||||
|
setSourceProxyMode(detailData.proxyMode || false); // 从 detail 数据中读取代理模式
|
||||||
if (currentEpisodeIndex >= detailData.episodes.length) {
|
if (currentEpisodeIndex >= detailData.episodes.length) {
|
||||||
setCurrentEpisodeIndex(0);
|
setCurrentEpisodeIndex(0);
|
||||||
}
|
}
|
||||||
@@ -2145,6 +2153,7 @@ function PlayPageClient() {
|
|||||||
setVideoCover(targetSource.poster);
|
setVideoCover(targetSource.poster);
|
||||||
setVideoDoubanId(targetSource.douban_id || 0);
|
setVideoDoubanId(targetSource.douban_id || 0);
|
||||||
setDetail(targetSource);
|
setDetail(targetSource);
|
||||||
|
setSourceProxyMode(targetSource.proxyMode || false); // 从 detail 数据中读取代理模式
|
||||||
|
|
||||||
// 更新集数
|
// 更新集数
|
||||||
if (targetEpisode >= 0 && targetEpisode < targetSource.episodes.length) {
|
if (targetEpisode >= 0 && targetEpisode < targetSource.episodes.length) {
|
||||||
@@ -2274,6 +2283,7 @@ function PlayPageClient() {
|
|||||||
setCurrentSource(newSource);
|
setCurrentSource(newSource);
|
||||||
setCurrentId(newId);
|
setCurrentId(newId);
|
||||||
setDetail(newDetail);
|
setDetail(newDetail);
|
||||||
|
setSourceProxyMode(newDetail.proxyMode || false); // 从 detail 数据中读取代理模式
|
||||||
setCurrentEpisodeIndex(targetIndex);
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 隐藏换源加载状态
|
// 隐藏换源加载状态
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface AdminConfig {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
from: 'config' | 'custom';
|
from: 'config' | 'custom';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
proxyMode?: boolean; // 代理模式开关:启用后由服务器代理m3u8和ts分片
|
||||||
}[];
|
}[];
|
||||||
CustomCategories: {
|
CustomCategories: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface ApiSite {
|
|||||||
api: string;
|
api: string;
|
||||||
name: string;
|
name: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
proxyMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveCfg {
|
export interface LiveCfg {
|
||||||
@@ -522,6 +523,7 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
api: s.api,
|
api: s.api,
|
||||||
detail: s.detail,
|
detail: s.detail,
|
||||||
|
proxyMode: s.proxyMode,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +545,7 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
api: s.api,
|
api: s.api,
|
||||||
detail: s.detail,
|
detail: s.detail,
|
||||||
|
proxyMode: s.proxyMode,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ async function searchWithCache(
|
|||||||
douban_id: item.vod_douban_id,
|
douban_id: item.vod_douban_id,
|
||||||
vod_remarks: item.vod_remarks,
|
vod_remarks: item.vod_remarks,
|
||||||
vod_total: item.vod_total,
|
vod_total: item.vod_total,
|
||||||
|
proxyMode: apiSite.proxyMode || false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,6 +289,7 @@ export async function getDetailFromApi(
|
|||||||
douban_id: videoDetail.vod_douban_id,
|
douban_id: videoDetail.vod_douban_id,
|
||||||
vod_remarks: videoDetail.vod_remarks,
|
vod_remarks: videoDetail.vod_remarks,
|
||||||
vod_total: videoDetail.vod_total,
|
vod_total: videoDetail.vod_total,
|
||||||
|
proxyMode: apiSite.proxyMode || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,5 +372,6 @@ async function handleSpecialSourceDetail(
|
|||||||
douban_id: 0,
|
douban_id: 0,
|
||||||
vod_remarks: undefined,
|
vod_remarks: undefined,
|
||||||
vod_total: undefined,
|
vod_total: undefined,
|
||||||
|
proxyMode: apiSite.proxyMode || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export interface SearchResult {
|
|||||||
douban_id?: number;
|
douban_id?: number;
|
||||||
vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等)
|
vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等)
|
||||||
vod_total?: number; // 总集数
|
vod_total?: number; // 总集数
|
||||||
|
proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片
|
||||||
}
|
}
|
||||||
|
|
||||||
// 豆瓣数据结构
|
// 豆瓣数据结构
|
||||||
|
|||||||
Reference in New Issue
Block a user