修复压缩节目单无法加载
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
"WebFetch(domain:m.douban.com)",
|
"WebFetch(domain:m.douban.com)",
|
||||||
"WebFetch(domain:movie.douban.com)",
|
"WebFetch(domain:movie.douban.com)",
|
||||||
"Bash(cat:*)",
|
"Bash(cat:*)",
|
||||||
"Bash(pnpm typecheck)"
|
"Bash(pnpm typecheck)",
|
||||||
|
"Bash(gunzip:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -49,4 +49,7 @@ public/manifest.json
|
|||||||
public/sw.js
|
public/sw.js
|
||||||
public/sw.js.map
|
public/sw.js.map
|
||||||
public/workbox-*.js
|
public/workbox-*.js
|
||||||
public/workbox-*.js.map
|
public/workbox-*.js.map
|
||||||
|
|
||||||
|
/.claude
|
||||||
|
/.vscode
|
||||||
88
src/app/api/live/epg/download/route.ts
Normal file
88
src/app/api/live/epg/download/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const epgUrl = searchParams.get('url');
|
||||||
|
|
||||||
|
if (!epgUrl) {
|
||||||
|
return NextResponse.json({ error: '缺少EPG URL参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[EPG Download] Fetching:', epgUrl);
|
||||||
|
|
||||||
|
// 获取EPG文件
|
||||||
|
const response = await fetch(epgUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: 'EPG文件下载失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是gzip压缩
|
||||||
|
const isGzip = epgUrl.endsWith('.gz') || response.headers.get('content-encoding') === 'gzip';
|
||||||
|
|
||||||
|
if (isGzip) {
|
||||||
|
console.log('[EPG Download] Decompressing gzip...');
|
||||||
|
|
||||||
|
// 读取所有数据
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
return NextResponse.json({ error: '无法读取响应' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并所有chunks
|
||||||
|
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||||
|
const allChunks = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
allChunks.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[EPG Download] Compressed size:', totalLength, 'bytes');
|
||||||
|
|
||||||
|
// 解压
|
||||||
|
const zlib = await import('zlib');
|
||||||
|
const decompressed = zlib.gunzipSync(Buffer.from(allChunks));
|
||||||
|
const decompressedText = decompressed.toString('utf-8');
|
||||||
|
|
||||||
|
console.log('[EPG Download] Decompressed size:', decompressedText.length, 'bytes');
|
||||||
|
|
||||||
|
// 返回解压后的XML
|
||||||
|
return new NextResponse(decompressedText, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="epg.xml"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 非压缩文件,直接返回
|
||||||
|
const text = await response.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'Content-Disposition': 'attachment; filename="epg.xml"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EPG Download] Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '下载EPG文件失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,9 +360,12 @@ function LivePageClient() {
|
|||||||
|
|
||||||
// 默认选中第一个频道
|
// 默认选中第一个频道
|
||||||
if (channels.length > 0) {
|
if (channels.length > 0) {
|
||||||
|
let selectedChannel: LiveChannel | null = null;
|
||||||
|
|
||||||
if (needLoadChannel) {
|
if (needLoadChannel) {
|
||||||
const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel);
|
const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel);
|
||||||
if (foundChannel) {
|
if (foundChannel) {
|
||||||
|
selectedChannel = foundChannel;
|
||||||
setCurrentChannel(foundChannel);
|
setCurrentChannel(foundChannel);
|
||||||
setVideoUrl(foundChannel.url);
|
setVideoUrl(foundChannel.url);
|
||||||
// 延迟滚动到选中的频道
|
// 延迟滚动到选中的频道
|
||||||
@@ -370,13 +373,20 @@ function LivePageClient() {
|
|||||||
scrollToChannel(foundChannel);
|
scrollToChannel(foundChannel);
|
||||||
}, 200);
|
}, 200);
|
||||||
} else {
|
} else {
|
||||||
|
selectedChannel = channels[0];
|
||||||
setCurrentChannel(channels[0]);
|
setCurrentChannel(channels[0]);
|
||||||
setVideoUrl(channels[0].url);
|
setVideoUrl(channels[0].url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
selectedChannel = channels[0];
|
||||||
setCurrentChannel(channels[0]);
|
setCurrentChannel(channels[0]);
|
||||||
setVideoUrl(channels[0].url);
|
setVideoUrl(channels[0].url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取初始频道的节目单
|
||||||
|
if (selectedChannel) {
|
||||||
|
await fetchEpgData(selectedChannel, source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按分组组织频道
|
// 按分组组织频道
|
||||||
@@ -466,6 +476,35 @@ function LivePageClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取节目单信息的辅助函数
|
||||||
|
const fetchEpgData = async (channel: LiveChannel, source: LiveSource) => {
|
||||||
|
if (channel.tvgId && source) {
|
||||||
|
try {
|
||||||
|
setIsEpgLoading(true); // 开始加载 EPG 数据
|
||||||
|
const response = await fetch(`/api/live/epg?source=${source.key}&tvgId=${channel.tvgId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
// 清洗EPG数据,去除重叠的节目
|
||||||
|
const cleanedData = {
|
||||||
|
...result.data,
|
||||||
|
programs: cleanEpgData(result.data.programs)
|
||||||
|
};
|
||||||
|
setEpgData(cleanedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取节目单信息失败:', error);
|
||||||
|
} finally {
|
||||||
|
setIsEpgLoading(false); // 无论成功失败都结束加载状态
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没有 tvgId 或 source,清空 EPG 数据
|
||||||
|
setEpgData(null);
|
||||||
|
setIsEpgLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 切换频道
|
// 切换频道
|
||||||
const handleChannelChange = async (channel: LiveChannel) => {
|
const handleChannelChange = async (channel: LiveChannel) => {
|
||||||
// 如果正在切换直播源,则禁用频道切换
|
// 如果正在切换直播源,则禁用频道切换
|
||||||
@@ -486,30 +525,8 @@ function LivePageClient() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// 获取节目单信息
|
// 获取节目单信息
|
||||||
if (channel.tvgId && currentSource) {
|
if (currentSource) {
|
||||||
try {
|
await fetchEpgData(channel, currentSource);
|
||||||
setIsEpgLoading(true); // 开始加载 EPG 数据
|
|
||||||
const response = await fetch(`/api/live/epg?source=${currentSource.key}&tvgId=${channel.tvgId}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.success) {
|
|
||||||
// 清洗EPG数据,去除重叠的节目
|
|
||||||
const cleanedData = {
|
|
||||||
...result.data,
|
|
||||||
programs: cleanEpgData(result.data.programs)
|
|
||||||
};
|
|
||||||
setEpgData(cleanedData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取节目单信息失败:', error);
|
|
||||||
} finally {
|
|
||||||
setIsEpgLoading(false); // 无论成功失败都结束加载状态
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果没有 tvgId 或 currentSource,清空 EPG 数据
|
|
||||||
setEpgData(null);
|
|
||||||
setIsEpgLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
279
src/lib/live.ts
279
src/lib/live.ts
@@ -1,9 +1,9 @@
|
|||||||
/* eslint-disable no-constant-condition */
|
/* eslint-disable no-constant-condition */
|
||||||
|
|
||||||
import { getConfig } from "@/lib/config";
|
import { getConfig } from '@/lib/config';
|
||||||
import { db } from "@/lib/db";
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
const defaultUA = 'AptvPlayer/1.4.10'
|
const defaultUA = 'AptvPlayer/1.4.10';
|
||||||
|
|
||||||
export interface LiveChannels {
|
export interface LiveChannels {
|
||||||
channelNumber: number;
|
channelNumber: number;
|
||||||
@@ -31,10 +31,12 @@ export function deleteCachedLiveChannels(key: string) {
|
|||||||
delete cachedLiveChannels[key];
|
delete cachedLiveChannels[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedLiveChannels(key: string): Promise<LiveChannels | null> {
|
export async function getCachedLiveChannels(
|
||||||
|
key: string
|
||||||
|
): Promise<LiveChannels | null> {
|
||||||
if (!cachedLiveChannels[key]) {
|
if (!cachedLiveChannels[key]) {
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
const liveInfo = config.LiveConfig?.find(live => live.key === key);
|
const liveInfo = config.LiveConfig?.find((live) => live.key === key);
|
||||||
if (!liveInfo) {
|
if (!liveInfo) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,10 @@ export async function refreshLiveChannels(liveInfo: {
|
|||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
const result = parseM3U(liveInfo.key, data);
|
const result = parseM3U(liveInfo.key, data);
|
||||||
const epgUrl = liveInfo.epg || result.tvgUrl;
|
const epgUrl = liveInfo.epg || result.tvgUrl;
|
||||||
const epgs = await parseEpg(epgUrl, liveInfo.ua || defaultUA, result.channels.map(channel => channel.tvgId).filter(tvgId => tvgId));
|
const tvgIds = result.channels
|
||||||
|
.map((channel) => channel.tvgId)
|
||||||
|
.filter((tvgId) => tvgId);
|
||||||
|
const epgs = await parseEpg(epgUrl, liveInfo.ua || defaultUA, tvgIds);
|
||||||
cachedLiveChannels[liveInfo.key] = {
|
cachedLiveChannels[liveInfo.key] = {
|
||||||
channelNumber: result.channels.length,
|
channelNumber: result.channels.length,
|
||||||
channels: result.channels,
|
channels: result.channels,
|
||||||
@@ -80,19 +85,25 @@ export async function refreshLiveChannels(liveInfo: {
|
|||||||
return result.channels.length;
|
return result.channels.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
|
async function parseEpg(
|
||||||
|
epgUrl: string,
|
||||||
|
ua: string,
|
||||||
|
tvgIds: string[]
|
||||||
|
): Promise<{
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
title: string;
|
title: string;
|
||||||
}[]
|
}[];
|
||||||
}> {
|
}> {
|
||||||
if (!epgUrl) {
|
if (!epgUrl) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tvgs = new Set(tvgIds);
|
const tvgs = new Set(tvgIds);
|
||||||
const result: { [key: string]: { start: string; end: string; title: string }[] } = {};
|
const result: {
|
||||||
|
[key: string]: { start: string; end: string; title: string }[];
|
||||||
|
} = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(epgUrl, {
|
const response = await fetch(epgUrl, {
|
||||||
@@ -104,16 +115,73 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否是 gzip 压缩文件
|
||||||
|
const isGzip =
|
||||||
|
epgUrl.endsWith('.gz') ||
|
||||||
|
response.headers.get('content-encoding') === 'gzip';
|
||||||
|
|
||||||
// 使用 ReadableStream 逐行处理,避免将整个文件加载到内存
|
// 使用 ReadableStream 逐行处理,避免将整个文件加载到内存
|
||||||
const reader = response.body?.getReader();
|
let reader;
|
||||||
if (!reader) {
|
|
||||||
return {};
|
// 如果是 gzip 压缩,需要先解压
|
||||||
|
if (isGzip && typeof DecompressionStream !== 'undefined') {
|
||||||
|
// 浏览器环境或支持 DecompressionStream 的环境
|
||||||
|
if (!response.body) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const decompressedStream = response.body.pipeThrough(
|
||||||
|
new DecompressionStream('gzip')
|
||||||
|
);
|
||||||
|
reader = decompressedStream.getReader();
|
||||||
|
} else if (isGzip) {
|
||||||
|
// Node.js 环境,使用 zlib
|
||||||
|
reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// 需要将整个响应读取后再解压(因为 zlib 不支持流式 ReadableStream)
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并所有 chunks
|
||||||
|
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||||
|
const allChunks = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
allChunks.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 zlib 解压
|
||||||
|
const zlib = await import('zlib');
|
||||||
|
const decompressed = zlib.gunzipSync(Buffer.from(allChunks));
|
||||||
|
|
||||||
|
// 创建一个新的 ReadableStream 从解压后的数据
|
||||||
|
const decompressedText = decompressed.toString('utf-8');
|
||||||
|
const lines = decompressedText.split('\n');
|
||||||
|
|
||||||
|
// 直接处理解压后的文本
|
||||||
|
return parseEpgLines(lines, tvgs);
|
||||||
|
} else {
|
||||||
|
// 非压缩文件
|
||||||
|
reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
// 频道ID映射:数字ID -> 频道名称
|
||||||
|
const channelIdMap: { [key: string]: string } = {};
|
||||||
|
let currentChannelId = '';
|
||||||
let currentTvgId = '';
|
let currentTvgId = '';
|
||||||
let currentProgram: { start: string; end: string; title: string } | null = null;
|
let currentProgram: { start: string; end: string; title: string } | null =
|
||||||
|
null;
|
||||||
let shouldSkipCurrentProgram = false;
|
let shouldSkipCurrentProgram = false;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -131,11 +199,33 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
|
|||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
if (!trimmedLine) continue;
|
if (!trimmedLine) continue;
|
||||||
|
|
||||||
// 解析 <programme> 标签
|
// 解析 <channel> 标签,建立ID映射
|
||||||
if (trimmedLine.startsWith('<programme')) {
|
if (trimmedLine.startsWith('<channel id=')) {
|
||||||
// 提取 tvg-id
|
const channelIdMatch = trimmedLine.match(/id="([^"]*)"/);
|
||||||
const tvgIdMatch = trimmedLine.match(/channel="([^"]*)"/);
|
currentChannelId = channelIdMatch ? channelIdMatch[1] : '';
|
||||||
currentTvgId = tvgIdMatch ? tvgIdMatch[1] : '';
|
}
|
||||||
|
// 解析 <display-name> 标签,获取频道名称
|
||||||
|
if (trimmedLine.includes('<display-name') && currentChannelId) {
|
||||||
|
const displayNameMatch = trimmedLine.match(
|
||||||
|
/<display-name(?:\s+[^>]*)?>(.*?)<\/display-name>/
|
||||||
|
);
|
||||||
|
if (displayNameMatch) {
|
||||||
|
const displayName = displayNameMatch[1];
|
||||||
|
channelIdMap[currentChannelId] = displayName;
|
||||||
|
currentChannelId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 解析 <programme> 标签(注意:不使用 else if,因为可能和 </programme> 在同一行)
|
||||||
|
if (trimmedLine.includes('<programme')) {
|
||||||
|
// 提取频道ID
|
||||||
|
const channelIdMatch = trimmedLine.match(/channel="([^"]*)"/);
|
||||||
|
const channelId = channelIdMatch ? channelIdMatch[1] : '';
|
||||||
|
|
||||||
|
// 通过映射获取频道名称,如果映射不存在则直接使用channelId
|
||||||
|
// 这样可以同时支持两种格式:
|
||||||
|
// 1. channel="1" 需要映射到 "CCTV1"
|
||||||
|
// 2. channel="CCTV1" 直接使用
|
||||||
|
currentTvgId = channelIdMap[channelId] || channelId;
|
||||||
|
|
||||||
// 提取开始时间
|
// 提取开始时间
|
||||||
const startMatch = trimmedLine.match(/start="([^"]*)"/);
|
const startMatch = trimmedLine.match(/start="([^"]*)"/);
|
||||||
@@ -152,9 +242,15 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 解析 <title> 标签 - 只有在需要解析当前节目时才处理
|
// 解析 <title> 标签 - 只有在需要解析当前节目时才处理
|
||||||
else if (trimmedLine.startsWith('<title') && currentProgram && !shouldSkipCurrentProgram) {
|
if (
|
||||||
|
trimmedLine.includes('<title') &&
|
||||||
|
currentProgram &&
|
||||||
|
!shouldSkipCurrentProgram
|
||||||
|
) {
|
||||||
// 处理带有语言属性的title标签,如 <title lang="zh">远方的家2025-60</title>
|
// 处理带有语言属性的title标签,如 <title lang="zh">远方的家2025-60</title>
|
||||||
const titleMatch = trimmedLine.match(/<title(?:\s+[^>]*)?>(.*?)<\/title>/);
|
const titleMatch = trimmedLine.match(
|
||||||
|
/<title(?:\s+[^>]*)?>(.*?)<\/title>/
|
||||||
|
);
|
||||||
if (titleMatch && currentProgram) {
|
if (titleMatch && currentProgram) {
|
||||||
currentProgram.title = titleMatch[1];
|
currentProgram.title = titleMatch[1];
|
||||||
|
|
||||||
@@ -167,12 +263,6 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
|
|||||||
currentProgram = null;
|
currentProgram = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 处理 </programme> 标签
|
|
||||||
else if (trimmedLine === '</programme>') {
|
|
||||||
currentProgram = null;
|
|
||||||
currentTvgId = '';
|
|
||||||
shouldSkipCurrentProgram = false; // 重置跳过标志
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,12 +272,110 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 辅助函数:解析 EPG 行
|
||||||
|
function parseEpgLines(
|
||||||
|
lines: string[],
|
||||||
|
tvgs: Set<string>
|
||||||
|
): {
|
||||||
|
[key: string]: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
title: string;
|
||||||
|
}[];
|
||||||
|
} {
|
||||||
|
const result: {
|
||||||
|
[key: string]: { start: string; end: string; title: string }[];
|
||||||
|
} = {};
|
||||||
|
// 频道ID映射:数字ID -> 频道名称
|
||||||
|
const channelIdMap: { [key: string]: string } = {};
|
||||||
|
let currentChannelId = '';
|
||||||
|
let currentTvgId = '';
|
||||||
|
let currentProgram: { start: string; end: string; title: string } | null =
|
||||||
|
null;
|
||||||
|
let shouldSkipCurrentProgram = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (!trimmedLine) continue;
|
||||||
|
|
||||||
|
// 解析 <channel> 标签,建立ID映射
|
||||||
|
if (trimmedLine.startsWith('<channel id=')) {
|
||||||
|
const channelIdMatch = trimmedLine.match(/id="([^"]*)"/);
|
||||||
|
currentChannelId = channelIdMatch ? channelIdMatch[1] : '';
|
||||||
|
}
|
||||||
|
// 解析 <display-name> 标签,获取频道名称
|
||||||
|
if (trimmedLine.includes('<display-name') && currentChannelId) {
|
||||||
|
const displayNameMatch = trimmedLine.match(
|
||||||
|
/<display-name(?:\s+[^>]*)?>(.*?)<\/display-name>/
|
||||||
|
);
|
||||||
|
if (displayNameMatch) {
|
||||||
|
const displayName = displayNameMatch[1];
|
||||||
|
channelIdMap[currentChannelId] = displayName;
|
||||||
|
currentChannelId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 解析 <programme> 标签(注意:不使用 else if,因为可能和 </programme> 在同一行)
|
||||||
|
if (trimmedLine.includes('<programme')) {
|
||||||
|
// 提取频道ID
|
||||||
|
const channelIdMatch = trimmedLine.match(/channel="([^"]*)"/);
|
||||||
|
const channelId = channelIdMatch ? channelIdMatch[1] : '';
|
||||||
|
|
||||||
|
// 通过映射获取频道名称,如果映射不存在则直接使用channelId
|
||||||
|
// 这样可以同时支持两种格式:
|
||||||
|
// 1. channel="1" 需要映射到 "CCTV1"
|
||||||
|
// 2. channel="CCTV1" 直接使用
|
||||||
|
currentTvgId = channelIdMap[channelId] || channelId;
|
||||||
|
|
||||||
|
// 提取开始时间
|
||||||
|
const startMatch = trimmedLine.match(/start="([^"]*)"/);
|
||||||
|
const start = startMatch ? startMatch[1] : '';
|
||||||
|
|
||||||
|
// 提取结束时间
|
||||||
|
const endMatch = trimmedLine.match(/stop="([^"]*)"/);
|
||||||
|
const end = endMatch ? endMatch[1] : '';
|
||||||
|
|
||||||
|
if (currentTvgId && start && end) {
|
||||||
|
currentProgram = { start, end, title: '' };
|
||||||
|
// 优化:如果当前频道不在我们关注的列表中,标记为跳过
|
||||||
|
shouldSkipCurrentProgram = !tvgs.has(currentTvgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 解析 <title> 标签 - 只有在需要解析当前节目时才处理
|
||||||
|
if (
|
||||||
|
trimmedLine.includes('<title') &&
|
||||||
|
currentProgram &&
|
||||||
|
!shouldSkipCurrentProgram
|
||||||
|
) {
|
||||||
|
// 处理带有语言属性的title标签,如 <title lang="zh">远方的家2025-60</title>
|
||||||
|
const titleMatch = trimmedLine.match(
|
||||||
|
/<title(?:\s+[^>]*)?>(.*?)<\/title>/
|
||||||
|
);
|
||||||
|
if (titleMatch && currentProgram) {
|
||||||
|
currentProgram.title = titleMatch[1];
|
||||||
|
|
||||||
|
// 保存节目信息
|
||||||
|
if (!result[currentTvgId]) {
|
||||||
|
result[currentTvgId] = [];
|
||||||
|
}
|
||||||
|
result[currentTvgId].push({ ...currentProgram });
|
||||||
|
|
||||||
|
currentProgram = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析M3U文件内容,提取频道信息
|
* 解析M3U文件内容,提取频道信息
|
||||||
* @param m3uContent M3U文件的内容字符串
|
* @param m3uContent M3U文件的内容字符串
|
||||||
* @returns 频道信息数组
|
* @returns 频道信息数组
|
||||||
*/
|
*/
|
||||||
function parseM3U(sourceKey: string, m3uContent: string): {
|
function parseM3U(
|
||||||
|
sourceKey: string,
|
||||||
|
m3uContent: string
|
||||||
|
): {
|
||||||
tvgUrl: string;
|
tvgUrl: string;
|
||||||
channels: {
|
channels: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -207,7 +395,10 @@ function parseM3U(sourceKey: string, m3uContent: string): {
|
|||||||
url: string;
|
url: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
const lines = m3uContent.split('\n').map(line => line.trim()).filter(line => line.length > 0);
|
const lines = m3uContent
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
let tvgUrl = '';
|
let tvgUrl = '';
|
||||||
let channelIndex = 0;
|
let channelIndex = 0;
|
||||||
@@ -226,7 +417,7 @@ function parseM3U(sourceKey: string, m3uContent: string): {
|
|||||||
if (line.startsWith('#EXTINF:')) {
|
if (line.startsWith('#EXTINF:')) {
|
||||||
// 提取 tvg-id
|
// 提取 tvg-id
|
||||||
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/);
|
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/);
|
||||||
const tvgId = tvgIdMatch ? tvgIdMatch[1] : '';
|
let tvgId = tvgIdMatch ? tvgIdMatch[1] : '';
|
||||||
|
|
||||||
// 提取 tvg-name
|
// 提取 tvg-name
|
||||||
const tvgNameMatch = line.match(/tvg-name="([^"]*)"/);
|
const tvgNameMatch = line.match(/tvg-name="([^"]*)"/);
|
||||||
@@ -247,6 +438,12 @@ function parseM3U(sourceKey: string, m3uContent: string): {
|
|||||||
// 优先使用 tvg-name,如果没有则使用标题
|
// 优先使用 tvg-name,如果没有则使用标题
|
||||||
const name = title || tvgName || '';
|
const name = title || tvgName || '';
|
||||||
|
|
||||||
|
// 如果 tvg-id 为空,使用 tvg-name 或频道名称作为备用
|
||||||
|
// 这样可以支持没有 tvg-id 的M3U文件
|
||||||
|
if (!tvgId) {
|
||||||
|
tvgId = tvgName || name;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查下一行是否是URL
|
// 检查下一行是否是URL
|
||||||
if (i + 1 < lines.length && !lines[i + 1].startsWith('#')) {
|
if (i + 1 < lines.length && !lines[i + 1].startsWith('#')) {
|
||||||
const url = lines[i + 1];
|
const url = lines[i + 1];
|
||||||
@@ -259,7 +456,7 @@ function parseM3U(sourceKey: string, m3uContent: string): {
|
|||||||
name,
|
name,
|
||||||
logo,
|
logo,
|
||||||
group,
|
group,
|
||||||
url
|
url,
|
||||||
});
|
});
|
||||||
channelIndex++;
|
channelIndex++;
|
||||||
}
|
}
|
||||||
@@ -277,7 +474,10 @@ function parseM3U(sourceKey: string, m3uContent: string): {
|
|||||||
export function resolveUrl(baseUrl: string, relativePath: string) {
|
export function resolveUrl(baseUrl: string, relativePath: string) {
|
||||||
try {
|
try {
|
||||||
// 如果已经是完整的 URL,直接返回
|
// 如果已经是完整的 URL,直接返回
|
||||||
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
|
if (
|
||||||
|
relativePath.startsWith('http://') ||
|
||||||
|
relativePath.startsWith('https://')
|
||||||
|
) {
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,8 +511,8 @@ function fallbackUrlResolve(baseUrl: string, relativePath: string) {
|
|||||||
return `${urlObj.protocol}//${urlObj.host}${relativePath}`;
|
return `${urlObj.protocol}//${urlObj.host}${relativePath}`;
|
||||||
} else if (relativePath.startsWith('../')) {
|
} else if (relativePath.startsWith('../')) {
|
||||||
// 上级目录相对路径 (../path/to/file)
|
// 上级目录相对路径 (../path/to/file)
|
||||||
const segments = base.split('/').filter(s => s);
|
const segments = base.split('/').filter((s) => s);
|
||||||
const relativeSegments = relativePath.split('/').filter(s => s);
|
const relativeSegments = relativePath.split('/').filter((s) => s);
|
||||||
|
|
||||||
for (const segment of relativeSegments) {
|
for (const segment of relativeSegments) {
|
||||||
if (segment === '..') {
|
if (segment === '..') {
|
||||||
@@ -326,7 +526,9 @@ function fallbackUrlResolve(baseUrl: string, relativePath: string) {
|
|||||||
return `${urlObj.protocol}//${urlObj.host}/${segments.join('/')}`;
|
return `${urlObj.protocol}//${urlObj.host}/${segments.join('/')}`;
|
||||||
} else {
|
} else {
|
||||||
// 当前目录相对路径 (file.ts 或 ./file.ts)
|
// 当前目录相对路径 (file.ts 或 ./file.ts)
|
||||||
const cleanRelative = relativePath.startsWith('./') ? relativePath.slice(2) : relativePath;
|
const cleanRelative = relativePath.startsWith('./')
|
||||||
|
? relativePath.slice(2)
|
||||||
|
: relativePath;
|
||||||
return base + cleanRelative;
|
return base + cleanRelative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,12 +539,15 @@ export function getBaseUrl(m3u8Url: string) {
|
|||||||
const url = new URL(m3u8Url);
|
const url = new URL(m3u8Url);
|
||||||
// 如果 URL 以 .m3u8 结尾,移除文件名
|
// 如果 URL 以 .m3u8 结尾,移除文件名
|
||||||
if (url.pathname.endsWith('.m3u8')) {
|
if (url.pathname.endsWith('.m3u8')) {
|
||||||
url.pathname = url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1);
|
url.pathname = url.pathname.substring(
|
||||||
|
0,
|
||||||
|
url.pathname.lastIndexOf('/') + 1
|
||||||
|
);
|
||||||
} else if (!url.pathname.endsWith('/')) {
|
} else if (!url.pathname.endsWith('/')) {
|
||||||
url.pathname += '/';
|
url.pathname += '/';
|
||||||
}
|
}
|
||||||
return url.protocol + "//" + url.host + url.pathname;
|
return url.protocol + '//' + url.host + url.pathname;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return m3u8Url.endsWith('/') ? m3u8Url : m3u8Url + '/';
|
return m3u8Url.endsWith('/') ? m3u8Url : m3u8Url + '/';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user