修复压缩节目单无法加载

This commit is contained in:
mtvpls
2025-12-16 23:01:48 +08:00
parent 4bbc015e45
commit ece16b38ee
5 changed files with 377 additions and 63 deletions

View File

@@ -10,7 +10,8 @@
"WebFetch(domain:m.douban.com)",
"WebFetch(domain:movie.douban.com)",
"Bash(cat:*)",
"Bash(pnpm typecheck)"
"Bash(pnpm typecheck)",
"Bash(gunzip:*)"
],
"deny": [],
"ask": []

3
.gitignore vendored
View File

@@ -50,3 +50,6 @@ public/sw.js
public/sw.js.map
public/workbox-*.js
public/workbox-*.js.map
/.claude
/.vscode

View 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 }
);
}
}

View File

@@ -360,9 +360,12 @@ function LivePageClient() {
// 默认选中第一个频道
if (channels.length > 0) {
let selectedChannel: LiveChannel | null = null;
if (needLoadChannel) {
const foundChannel = channels.find((c: LiveChannel) => c.id === needLoadChannel);
if (foundChannel) {
selectedChannel = foundChannel;
setCurrentChannel(foundChannel);
setVideoUrl(foundChannel.url);
// 延迟滚动到选中的频道
@@ -370,13 +373,20 @@ function LivePageClient() {
scrollToChannel(foundChannel);
}, 200);
} else {
selectedChannel = channels[0];
setCurrentChannel(channels[0]);
setVideoUrl(channels[0].url);
}
} else {
selectedChannel = channels[0];
setCurrentChannel(channels[0]);
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) => {
// 如果正在切换直播源,则禁用频道切换
@@ -486,30 +525,8 @@ function LivePageClient() {
}, 100);
// 获取节目单信息
if (channel.tvgId && currentSource) {
try {
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);
if (currentSource) {
await fetchEpgData(channel, currentSource);
}
};

View File

@@ -1,9 +1,9 @@
/* eslint-disable no-constant-condition */
import { getConfig } from "@/lib/config";
import { db } from "@/lib/db";
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
const defaultUA = 'AptvPlayer/1.4.10'
const defaultUA = 'AptvPlayer/1.4.10';
export interface LiveChannels {
channelNumber: number;
@@ -31,10 +31,12 @@ export function deleteCachedLiveChannels(key: string) {
delete cachedLiveChannels[key];
}
export async function getCachedLiveChannels(key: string): Promise<LiveChannels | null> {
export async function getCachedLiveChannels(
key: string
): Promise<LiveChannels | null> {
if (!cachedLiveChannels[key]) {
const config = await getConfig();
const liveInfo = config.LiveConfig?.find(live => live.key === key);
const liveInfo = config.LiveConfig?.find((live) => live.key === key);
if (!liveInfo) {
return null;
}
@@ -70,7 +72,10 @@ export async function refreshLiveChannels(liveInfo: {
const data = await response.text();
const result = parseM3U(liveInfo.key, data);
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] = {
channelNumber: result.channels.length,
channels: result.channels,
@@ -80,19 +85,25 @@ export async function refreshLiveChannels(liveInfo: {
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]: {
start: string;
end: string;
title: string;
}[]
}[];
}> {
if (!epgUrl) {
return {};
}
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 {
const response = await fetch(epgUrl, {
@@ -104,16 +115,73 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
return {};
}
// 检查是否是 gzip 压缩文件
const isGzip =
epgUrl.endsWith('.gz') ||
response.headers.get('content-encoding') === 'gzip';
// 使用 ReadableStream 逐行处理,避免将整个文件加载到内存
const reader = response.body?.getReader();
let reader;
// 如果是 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();
let buffer = '';
// 频道ID映射数字ID -> 频道名称
const channelIdMap: { [key: string]: string } = {};
let currentChannelId = '';
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;
while (true) {
@@ -131,11 +199,33 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// 解析 <programme> 标签
if (trimmedLine.startsWith('<programme')) {
// 提取 tvg-id
const tvgIdMatch = trimmedLine.match(/channel="([^"]*)"/);
currentTvgId = tvgIdMatch ? tvgIdMatch[1] : '';
// 解析 <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="([^"]*)"/);
@@ -152,9 +242,15 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
}
}
// 解析 <title> 标签 - 只有在需要解析当前节目时才处理
else if (trimmedLine.startsWith('<title') && currentProgram && !shouldSkipCurrentProgram) {
if (
trimmedLine.includes('<title') &&
currentProgram &&
!shouldSkipCurrentProgram
) {
// 处理带有语言属性的title标签如 <title lang="zh">远方的家2025-60</title>
const titleMatch = trimmedLine.match(/<title(?:\s+[^>]*)?>(.*?)<\/title>/);
const titleMatch = trimmedLine.match(
/<title(?:\s+[^>]*)?>(.*?)<\/title>/
);
if (titleMatch && currentProgram) {
currentProgram.title = titleMatch[1];
@@ -167,12 +263,6 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
currentProgram = null;
}
}
// 处理 </programme> 标签
else if (trimmedLine === '</programme>') {
currentProgram = null;
currentTvgId = '';
shouldSkipCurrentProgram = false; // 重置跳过标志
}
}
}
} catch (error) {
@@ -182,12 +272,110 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{
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文件内容提取频道信息
* @param m3uContent M3U文件的内容字符串
* @returns 频道信息数组
*/
function parseM3U(sourceKey: string, m3uContent: string): {
function parseM3U(
sourceKey: string,
m3uContent: string
): {
tvgUrl: string;
channels: {
id: string;
@@ -207,7 +395,10 @@ function parseM3U(sourceKey: string, m3uContent: 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 channelIndex = 0;
@@ -226,7 +417,7 @@ function parseM3U(sourceKey: string, m3uContent: string): {
if (line.startsWith('#EXTINF:')) {
// 提取 tvg-id
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/);
const tvgId = tvgIdMatch ? tvgIdMatch[1] : '';
let tvgId = tvgIdMatch ? tvgIdMatch[1] : '';
// 提取 tvg-name
const tvgNameMatch = line.match(/tvg-name="([^"]*)"/);
@@ -247,6 +438,12 @@ function parseM3U(sourceKey: string, m3uContent: string): {
// 优先使用 tvg-name如果没有则使用标题
const name = title || tvgName || '';
// 如果 tvg-id 为空,使用 tvg-name 或频道名称作为备用
// 这样可以支持没有 tvg-id 的M3U文件
if (!tvgId) {
tvgId = tvgName || name;
}
// 检查下一行是否是URL
if (i + 1 < lines.length && !lines[i + 1].startsWith('#')) {
const url = lines[i + 1];
@@ -259,7 +456,7 @@ function parseM3U(sourceKey: string, m3uContent: string): {
name,
logo,
group,
url
url,
});
channelIndex++;
}
@@ -277,7 +474,10 @@ function parseM3U(sourceKey: string, m3uContent: string): {
export function resolveUrl(baseUrl: string, relativePath: string) {
try {
// 如果已经是完整的 URL直接返回
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
@@ -311,8 +511,8 @@ function fallbackUrlResolve(baseUrl: string, relativePath: string) {
return `${urlObj.protocol}//${urlObj.host}${relativePath}`;
} else if (relativePath.startsWith('../')) {
// 上级目录相对路径 (../path/to/file)
const segments = base.split('/').filter(s => s);
const relativeSegments = relativePath.split('/').filter(s => s);
const segments = base.split('/').filter((s) => s);
const relativeSegments = relativePath.split('/').filter((s) => s);
for (const segment of relativeSegments) {
if (segment === '..') {
@@ -326,7 +526,9 @@ function fallbackUrlResolve(baseUrl: string, relativePath: string) {
return `${urlObj.protocol}//${urlObj.host}/${segments.join('/')}`;
} else {
// 当前目录相对路径 (file.ts 或 ./file.ts)
const cleanRelative = relativePath.startsWith('./') ? relativePath.slice(2) : relativePath;
const cleanRelative = relativePath.startsWith('./')
? relativePath.slice(2)
: relativePath;
return base + cleanRelative;
}
}
@@ -337,11 +539,14 @@ export function getBaseUrl(m3u8Url: string) {
const url = new URL(m3u8Url);
// 如果 URL 以 .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('/')) {
url.pathname += '/';
}
return url.protocol + "//" + url.host + url.pathname;
return url.protocol + '//' + url.host + url.pathname;
} catch (error) {
return m3u8Url.endsWith('/') ? m3u8Url : m3u8Url + '/';
}