feat: implement iptv

This commit is contained in:
shinya
2025-08-24 00:26:48 +08:00
parent eadd93fde6
commit 0a8c255279
22 changed files with 2399 additions and 12 deletions

View File

@@ -0,0 +1,47 @@
/* 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('moontv-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
console.log(decodedUrl);
const response = await fetch(decodedUrl, {
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
}
const keyData = await response.arrayBuffer();
return new Response(keyData, {
headers: {
'Content-Type': 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Cache-Control': 'public, max-age=3600'
},
});
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
}
}

View File

@@ -0,0 +1,142 @@
/* 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 allowCORS = searchParams.get('allowCORS') === 'true';
const source = searchParams.get('moontv-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
const response = await fetch(decodedUrl, {
cache: 'no-cache',
redirect: 'follow',
credentials: 'same-origin',
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
}
// 获取最终的响应URL处理重定向后的URL
const finalUrl = response.url;
const m3u8Content = await response.text();
// 使用最终的响应URL作为baseUrl而不是原始的请求URL
const baseUrl = getBaseUrl(finalUrl);
// 重写 M3U8 内容
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, allowCORS);
const headers = new Headers();
headers.set('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(modifiedContent, { headers });
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
}
}
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allowCORS: boolean) {
const protocol = req.headers.get('x-forwarded-proto') || 'http';
const host = req.headers.get('host');
const proxyBase = `${protocol}://${host}/api/proxy`;
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);
// 检查是否为 mp4 格式
const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4');
const proxyUrl = (isMp4 || allowCORS) ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
rewrittenLines.push(proxyUrl);
continue;
}
// 处理 EXT-X-MAP 标签中的 URI
if (line.startsWith('#EXT-X-MAP:')) {
line = rewriteMapUri(line, baseUrl, proxyBase);
}
// 处理 EXT-X-KEY 标签中的 URI
if (line.startsWith('#EXT-X-KEY:')) {
line = rewriteKeyUri(line, baseUrl, proxyBase);
}
// 处理嵌套的 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)}`;
rewrittenLines.push(proxyUrl);
} else {
rewrittenLines.push(nextLine);
}
}
continue;
}
rewrittenLines.push(line);
}
return rewrittenLines.join('\n');
}
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
// 检查是否为 mp4 格式,如果是则走 proxyBase
const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4');
const proxyUrl = isMp4 ? `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}` : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;
}
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;
}

View File

@@ -0,0 +1,50 @@
/* 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('moontv-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
const response = await fetch(decodedUrl, {
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
const headers = new Headers();
headers.set('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);
}
return new Response(response.body, { headers });
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
}