视频源增加代理开关
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user