修复压缩节目单无法加载
This commit is contained in:
@@ -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
3
.gitignore
vendored
@@ -50,3 +50,6 @@ public/sw.js
|
||||
public/sw.js.map
|
||||
public/workbox-*.js
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
277
src/lib/live.ts
277
src/lib/live.ts
@@ -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();
|
||||
if (!reader) {
|
||||
return {};
|
||||
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 + '/';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user