mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-01 00:16:52 +08:00
feat: implement iptv
This commit is contained in:
53
src/app/api/admin/live/refresh/route.ts
Normal file
53
src/app/api/admin/live/refresh/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
const config = await getConfig();
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const liveInfo of config.LiveConfig || []) {
|
||||
if (liveInfo.disabled) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
liveInfo.channelNumber = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '直播源刷新成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('直播源刷新失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '刷新失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
143
src/app/api/admin/live/route.ts
Normal file
143
src/app/api/admin/live/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable no-console,no-case-declarations */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
const config = await getConfig();
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, key, name, url, ua, epg } = body;
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 确保 LiveConfig 存在
|
||||
if (!config.LiveConfig) {
|
||||
config.LiveConfig = [];
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add':
|
||||
// 检查是否已存在相同的 key
|
||||
if (config.LiveConfig.some((l) => l.key === key)) {
|
||||
return NextResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
const liveInfo = {
|
||||
key: key as string,
|
||||
name: name as string,
|
||||
url: url as string,
|
||||
ua: ua || '',
|
||||
epg: epg || '',
|
||||
from: 'custom' as 'custom' | 'config',
|
||||
channelNumber: 0,
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
liveInfo.channelNumber = 0;
|
||||
}
|
||||
|
||||
// 添加新的直播源
|
||||
config.LiveConfig.push(liveInfo);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
// 删除直播源
|
||||
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
|
||||
if (deleteIndex === -1) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const liveSource = config.LiveConfig[deleteIndex];
|
||||
if (liveSource.from === 'config') {
|
||||
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
|
||||
}
|
||||
|
||||
deleteCachedLiveChannels(key);
|
||||
|
||||
config.LiveConfig.splice(deleteIndex, 1);
|
||||
break;
|
||||
|
||||
case 'enable':
|
||||
// 启用直播源
|
||||
const enableSource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!enableSource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
enableSource.disabled = false;
|
||||
break;
|
||||
|
||||
case 'disable':
|
||||
// 禁用直播源
|
||||
const disableSource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!disableSource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
disableSource.disabled = true;
|
||||
break;
|
||||
|
||||
case 'sort':
|
||||
// 排序直播源
|
||||
const { order } = body;
|
||||
if (!Array.isArray(order)) {
|
||||
return NextResponse.json({ error: '排序数据格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 创建新的排序后的数组
|
||||
const sortedLiveConfig: typeof config.LiveConfig = [];
|
||||
order.forEach((key) => {
|
||||
const source = config.LiveConfig?.find((l) => l.key === key);
|
||||
if (source) {
|
||||
sortedLiveConfig.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加未在排序列表中的直播源(保持原有顺序)
|
||||
config.LiveConfig.forEach((source) => {
|
||||
if (!order.includes(source.key)) {
|
||||
sortedLiveConfig.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
config.LiveConfig = sortedLiveConfig;
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfig, refineConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -358,9 +359,25 @@ async function cronJob() {
|
||||
|
||||
// 执行其他定时任务
|
||||
await refreshConfig();
|
||||
await refreshAllLiveChannels();
|
||||
await refreshRecordAndFavorites();
|
||||
}
|
||||
|
||||
async function refreshAllLiveChannels() {
|
||||
const config = await getConfig();
|
||||
for (const liveInfo of config.LiveConfig || []) {
|
||||
if (liveInfo.disabled) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConfig() {
|
||||
let config = await getConfig();
|
||||
if (config && config.ConfigSubscribtion && config.ConfigSubscribtion.URL && config.ConfigSubscribtion.AutoUpdate) {
|
||||
|
||||
30
src/app/api/live/channels/route.ts
Normal file
30
src/app/api/live/channels/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCachedLiveChannels } from '@/lib/live';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceKey = searchParams.get('source');
|
||||
|
||||
if (!sourceKey) {
|
||||
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const channelData = await getCachedLiveChannels(sourceKey);
|
||||
|
||||
if (!channelData) {
|
||||
return NextResponse.json({ error: '频道信息未找到' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: channelData.channels
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取频道信息失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
src/app/api/live/sources/route.ts
Normal file
30
src/app/api/live/sources/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log(request.url)
|
||||
try {
|
||||
const config = await getConfig();
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置未找到' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 过滤出所有非 disabled 的直播源
|
||||
const liveSources = (config.LiveConfig || []).filter(source => !source.disabled);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: liveSources
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取直播源失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取直播源失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/app/api/proxy/key/route.ts
Normal file
47
src/app/api/proxy/key/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
142
src/app/api/proxy/m3u8/route.ts
Normal file
142
src/app/api/proxy/m3u8/route.ts
Normal 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;
|
||||
}
|
||||
50
src/app/api/proxy/segment/route.ts
Normal file
50
src/app/api/proxy/segment/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
Reference in New Issue
Block a user