feat: add epg info

This commit is contained in:
shinya
2025-08-24 17:23:27 +08:00
parent c60681a92b
commit 1149c0ef45
6 changed files with 747 additions and 36 deletions

View File

@@ -0,0 +1,52 @@
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');
const tvgId = searchParams.get('tvgId');
if (!sourceKey) {
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
}
if (!tvgId) {
return NextResponse.json({ error: '缺少频道tvg-id参数' }, { status: 400 });
}
const channelData = await getCachedLiveChannels(sourceKey);
if (!channelData) {
// 频道信息未找到时返回空的节目单数据
return NextResponse.json({
success: true,
data: {
tvgId,
source: sourceKey,
epgUrl: '',
programs: []
}
});
}
// 从epgs字段中获取对应tvgId的节目单信息
const epgData = channelData.epgs[tvgId] || [];
return NextResponse.json({
success: true,
data: {
tvgId,
source: sourceKey,
epgUrl: channelData.epgUrl,
programs: epgData
}
});
} catch (error) {
return NextResponse.json(
{ error: '获取节目单信息失败' },
{ status: 500 }
);
}
}

View File

@@ -63,7 +63,18 @@ export async function GET(request: Request) {
}
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allowCORS: boolean) {
const protocol = req.headers.get('x-forwarded-proto') || 'http';
// 从 referer 头提取协议信息
const referer = req.headers.get('referer');
let protocol = 'http';
if (referer) {
try {
const refererUrl = new URL(referer);
protocol = refererUrl.protocol.replace(':', '');
} catch (error) {
// ignore
}
}
const host = req.headers.get('host');
const proxyBase = `${protocol}://${host}/api/proxy`;

View File

@@ -8,8 +8,10 @@ import { Radio, Tv } from 'lucide-react';
import { Suspense, useEffect, useRef, useState } from 'react';
import { processImageUrl } from '@/lib/utils';
import { parseCustomTimeFormat } from '@/lib/time';
import PageLayout from '@/components/PageLayout';
import EpgScrollableRow from '@/components/EpgScrollableRow';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
@@ -83,6 +85,133 @@ function LivePageClient() {
// 过滤后的频道列表
const [filteredChannels, setFilteredChannels] = useState<LiveChannel[]>([]);
// 节目单信息
const [epgData, setEpgData] = useState<{
tvgId: string;
source: string;
epgUrl: string;
programs: Array<{
start: string;
end: string;
title: string;
}>;
} | null>(null);
// EPG 数据加载状态
const [isEpgLoading, setIsEpgLoading] = useState(false);
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
if (!programs || programs.length === 0) return programs;
console.log(`开始清洗EPG数据原始节目数量: ${programs.length}`);
// 获取今日日期(只考虑年月日,忽略时间)
const today = new Date();
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const todayEnd = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
// 首先过滤出今日的节目(包括跨天节目)
const todayPrograms = programs.filter(program => {
const programStart = parseCustomTimeFormat(program.start);
const programEnd = parseCustomTimeFormat(program.end);
// 获取节目的日期范围
const programStartDate = new Date(programStart.getFullYear(), programStart.getMonth(), programStart.getDate());
const programEndDate = new Date(programEnd.getFullYear(), programEnd.getMonth(), programEnd.getDate());
// 如果节目的开始时间或结束时间在今天,或者节目跨越今天,都算作今天的节目
return (
(programStartDate >= todayStart && programStartDate < todayEnd) || // 开始时间在今天
(programEndDate >= todayStart && programEndDate < todayEnd) || // 结束时间在今天
(programStartDate < todayStart && programEndDate >= todayEnd) // 节目跨越今天(跨天节目)
);
});
console.log(`过滤今日节目后数量: ${todayPrograms.length}`);
// 按开始时间排序
const sortedPrograms = [...todayPrograms].sort((a, b) => {
const startA = parseCustomTimeFormat(a.start).getTime();
const startB = parseCustomTimeFormat(b.start).getTime();
return startA - startB;
});
const cleanedPrograms: Array<{ start: string; end: string; title: string }> = [];
let removedCount = 0;
let dateFilteredCount = programs.length - todayPrograms.length;
for (let i = 0; i < sortedPrograms.length; i++) {
const currentProgram = sortedPrograms[i];
const currentStart = parseCustomTimeFormat(currentProgram.start);
const currentEnd = parseCustomTimeFormat(currentProgram.end);
// 检查是否与已添加的节目重叠
let hasOverlap = false;
for (const existingProgram of cleanedPrograms) {
const existingStart = parseCustomTimeFormat(existingProgram.start);
const existingEnd = parseCustomTimeFormat(existingProgram.end);
// 检查时间重叠(只考虑时间部分,忽略日期)
const currentTime = currentStart.getHours() * 60 + currentStart.getMinutes();
const currentEndTime = currentEnd.getHours() * 60 + currentEnd.getMinutes();
const existingTime = existingStart.getHours() * 60 + existingStart.getMinutes();
const existingEndTime = existingEnd.getHours() * 60 + existingEnd.getMinutes();
if (
(currentTime >= existingTime && currentTime < existingEndTime) || // 当前节目开始时间在已存在节目时间段内
(currentEndTime > existingTime && currentEndTime <= existingEndTime) || // 当前节目结束时间在已存在节目时间段内
(currentTime <= existingTime && currentEndTime >= existingEndTime) // 当前节目完全包含已存在节目
) {
hasOverlap = true;
break;
}
}
// 如果没有重叠,则添加该节目
if (!hasOverlap) {
cleanedPrograms.push(currentProgram);
} else {
// 如果有重叠,检查是否需要替换已存在的节目
for (let j = 0; j < cleanedPrograms.length; j++) {
const existingProgram = cleanedPrograms[j];
const existingStart = parseCustomTimeFormat(existingProgram.start);
const existingEnd = parseCustomTimeFormat(existingProgram.end);
// 检查是否与当前节目重叠(只考虑时间部分)
const currentTime = currentStart.getHours() * 60 + currentStart.getMinutes();
const currentEndTime = currentEnd.getHours() * 60 + currentEnd.getMinutes();
const existingTime = existingStart.getHours() * 60 + existingStart.getMinutes();
const existingEndTime = existingEnd.getHours() * 60 + existingEnd.getMinutes();
if (
(currentTime >= existingTime && currentTime < existingEndTime) ||
(currentEndTime > existingTime && currentEndTime <= existingEndTime) ||
(currentTime <= existingTime && currentEndTime >= existingEndTime)
) {
// 计算节目时长
const currentDuration = currentEnd.getTime() - currentStart.getTime();
const existingDuration = existingEnd.getTime() - existingStart.getTime();
// 如果当前节目时间更短,则替换已存在的节目
if (currentDuration < existingDuration) {
console.log(`替换重叠节目: "${existingProgram.title}" (${existingDuration}ms) -> "${currentProgram.title}" (${currentDuration}ms)`);
cleanedPrograms[j] = currentProgram;
} else {
console.log(`跳过重叠节目: "${currentProgram.title}" (${currentDuration}ms),保留 "${existingProgram.title}" (${existingDuration}ms)`);
removedCount++;
}
break;
}
}
}
}
console.log(`EPG数据清洗完成清洗后节目数量: ${cleanedPrograms.length},移除重叠节目: ${removedCount}个,过滤非今日节目: ${dateFilteredCount}`);
return cleanedPrograms;
};
// 播放器引用
const artPlayerRef = useRef<any>(null);
const artRef = useRef<HTMLDivElement | null>(null);
@@ -237,6 +366,9 @@ function LivePageClient() {
// 设置切换状态,锁住频道切换器
setIsSwitchingSource(true);
// 清空节目单信息
setEpgData(null);
setCurrentSource(source);
await fetchChannels(source);
} catch (err) {
@@ -251,12 +383,40 @@ function LivePageClient() {
};
// 切换频道
const handleChannelChange = (channel: LiveChannel) => {
const handleChannelChange = async (channel: LiveChannel) => {
// 如果正在切换直播源,则禁用频道切换
if (isSwitchingSource) return;
setCurrentChannel(channel);
setVideoUrl(channel.url);
// 获取节目单信息
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) {
console.log('节目单信息:', result.data);
// 清洗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);
}
};
// 清理播放器资源的统一函数
@@ -1032,28 +1192,40 @@ function LivePageClient() {
{/* 当前频道信息 */}
{currentChannel && (
<div className='p-4'>
<div className='flex items-center gap-4'>
<div className='w-20 h-20 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{currentChannel.logo ? (
<img
src={processImageUrl(currentChannel.logo)}
alt={currentChannel.name}
className='w-full h-full rounded object-contain'
/>
) : (
<Tv className='w-10 h-10 text-gray-500' />
)}
</div>
<div className='flex-1'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{currentChannel.name}
</h3>
<p className='text-sm text-gray-500 dark:text-gray-400'>
{currentSource?.name} {currentChannel.group}
</p>
<div className='pt-4'>
<div className='flex flex-col lg:flex-row gap-4'>
{/* 频道图标+名称 - 在小屏幕上占100%大屏幕占20% */}
<div className='w-full flex-shrink-0'>
<div className='flex items-center gap-4'>
<div className='w-20 h-20 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
{currentChannel.logo ? (
<img
src={processImageUrl(currentChannel.logo)}
alt={currentChannel.name}
className='w-full h-full rounded object-contain'
/>
) : (
<Tv className='w-10 h-10 text-gray-500' />
)}
</div>
<div className='flex-1 min-w-0'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 truncate'>
{currentChannel.name}
</h3>
<p className='text-sm text-gray-500 dark:text-gray-400 truncate'>
{currentSource?.name} {' > '} {currentChannel.group}
</p>
</div>
</div>
</div>
</div>
{/* EPG节目单 */}
<EpgScrollableRow
programs={epgData?.programs || []}
currentTime={new Date()}
isLoading={isEpgLoading}
/>
</div>
)}
</div>

View File

@@ -0,0 +1,290 @@
import { Clock, Tv, Target } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { parseCustomTimeFormat, formatTimeToHHMM } from '@/lib/time';
interface EpgProgram {
start: string;
end: string;
title: string;
}
interface EpgScrollableRowProps {
programs: EpgProgram[];
currentTime?: Date;
isLoading?: boolean;
}
export default function EpgScrollableRow({
programs,
currentTime = new Date(),
isLoading = false,
}: EpgScrollableRowProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const [currentPlayingIndex, setCurrentPlayingIndex] = useState<number>(-1);
// 处理滚轮事件,实现横向滚动
const handleWheel = (e: WheelEvent) => {
if (isHovered && containerRef.current) {
e.preventDefault(); // 阻止默认的竖向滚动
const container = containerRef.current;
const scrollAmount = e.deltaY * 4; // 增加滚动速度
// 根据滚轮方向进行横向滚动
container.scrollBy({
left: scrollAmount,
behavior: 'smooth'
});
}
};
// 阻止页面竖向滚动
const preventPageScroll = (e: WheelEvent) => {
if (isHovered) {
e.preventDefault();
}
};
// 自动滚动到正在播放的节目
const scrollToCurrentProgram = () => {
if (containerRef.current) {
const currentProgramIndex = programs.findIndex(program => isCurrentlyPlaying(program));
if (currentProgramIndex !== -1) {
const programElement = containerRef.current.children[currentProgramIndex] as HTMLElement;
if (programElement) {
const container = containerRef.current;
const programLeft = programElement.offsetLeft;
const containerWidth = container.clientWidth;
const programWidth = programElement.offsetWidth;
// 计算滚动位置,使正在播放的节目居中显示
const scrollLeft = programLeft - (containerWidth / 2) + (programWidth / 2);
container.scrollTo({
left: Math.max(0, scrollLeft),
behavior: 'smooth'
});
}
}
}
};
useEffect(() => {
if (isHovered) {
// 鼠标悬停时阻止页面滚动
document.addEventListener('wheel', preventPageScroll, { passive: false });
document.addEventListener('wheel', handleWheel, { passive: false });
} else {
// 鼠标离开时恢复页面滚动
document.removeEventListener('wheel', preventPageScroll);
document.removeEventListener('wheel', handleWheel);
}
return () => {
document.removeEventListener('wheel', preventPageScroll);
document.removeEventListener('wheel', handleWheel);
};
}, [isHovered]);
// 组件加载后自动滚动到正在播放的节目
useEffect(() => {
// 延迟执行确保DOM完全渲染
const timer = setTimeout(() => {
// 初始化当前正在播放的节目索引
const initialPlayingIndex = programs.findIndex(program => isCurrentlyPlaying(program));
setCurrentPlayingIndex(initialPlayingIndex);
scrollToCurrentProgram();
}, 100);
return () => clearTimeout(timer);
}, [programs, currentTime]);
// 定时刷新正在播放状态
useEffect(() => {
// 每分钟刷新一次正在播放状态
const interval = setInterval(() => {
// 更新当前正在播放的节目索引
const newPlayingIndex = programs.findIndex(program => {
try {
const start = parseCustomTimeFormat(program.start);
const end = parseCustomTimeFormat(program.end);
return currentTime >= start && currentTime < end;
} catch {
return false;
}
});
if (newPlayingIndex !== currentPlayingIndex) {
setCurrentPlayingIndex(newPlayingIndex);
// 如果正在播放的节目发生变化,自动滚动到新位置
scrollToCurrentProgram();
}
}, 60000); // 60秒 = 1分钟
return () => clearInterval(interval);
}, [programs, currentTime, currentPlayingIndex]);
// 格式化时间显示
const formatTime = (timeString: string) => {
return formatTimeToHHMM(timeString);
};
// 判断节目是否正在播放
const isCurrentlyPlaying = (program: EpgProgram) => {
try {
const start = parseCustomTimeFormat(program.start);
const end = parseCustomTimeFormat(program.end);
return currentTime >= start && currentTime < end;
} catch {
return false;
}
};
// 加载中状态
if (isLoading) {
return (
<div className="pt-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
</h4>
<div className="w-16 sm:w-20"></div>
</div>
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
<div className="flex items-center gap-3 sm:gap-4 text-gray-500 dark:text-gray-400">
<div className="w-5 h-5 sm:w-6 sm:h-6 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
<span className="text-sm sm:text-base">...</span>
</div>
</div>
</div>
);
}
// 无节目单状态
if (!programs || programs.length === 0) {
return (
<div className="pt-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
</h4>
<div className="w-16 sm:w-20"></div>
</div>
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
<div className="flex items-center gap-2 sm:gap-3 text-gray-400 dark:text-gray-500">
<Tv className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="text-sm sm:text-base"></span>
</div>
</div>
</div>
);
}
return (
<div className="pt-4 mt-2">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
</h4>
{currentPlayingIndex !== -1 && (
<button
onClick={scrollToCurrentProgram}
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 bg-gray-300/50 dark:bg-gray-800 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700 transition-all duration-200"
title="滚动到当前播放位置"
>
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</button>
)}
</div>
<div
className='relative'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
ref={containerRef}
className='flex overflow-x-auto scrollbar-hide py-2 pb-4 px-2 sm:px-4 min-h-[100px] sm:min-h-[120px]'
>
{programs.map((program, index) => {
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
const isPlaying = index === currentPlayingIndex;
const isFinishedProgram = index < currentPlayingIndex;
const isUpcomingProgram = index > currentPlayingIndex;
return (
<div
key={index}
className={`flex-shrink-0 w-36 sm:w-48 p-2 sm:p-3 rounded-lg border transition-all duration-200 flex flex-col min-h-[100px] sm:min-h-[120px] ${isPlaying
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30'
: isFinishedProgram
? 'bg-gray-300/50 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
: isUpcomingProgram
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30'
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
{/* 时间显示在顶部 */}
<div className="flex items-center justify-between mb-2 sm:mb-3 flex-shrink-0">
<span className={`text-xs font-medium ${isPlaying
? 'text-green-600 dark:text-green-400'
: isFinishedProgram
? 'text-gray-500 dark:text-gray-400'
: isUpcomingProgram
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-300'
}`}>
{formatTime(program.start)}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
{formatTime(program.end)}
</span>
</div>
{/* 标题在中间,占据剩余空间 */}
<div
className={`text-xs sm:text-sm font-medium flex-1 ${isPlaying
? 'text-green-900 dark:text-green-100'
: isFinishedProgram
? 'text-gray-600 dark:text-gray-400'
: isUpcomingProgram
? 'text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: '1.4',
maxHeight: '2.8em'
}}
title={program.title}
>
{program.title}
</div>
{/* 正在播放状态在底部 */}
{isPlaying && (
<div className="mt-auto pt-1 sm:pt-2 flex items-center gap-1 sm:gap-1.5 flex-shrink-0">
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
</span>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { getConfig } from "@/lib/config";
import { db } from "@/lib/db";
const defaultUA = 'okHttp/Mod-1.1.0'
const defaultUA = 'AptvPlayer/1.4.10'
export interface LiveChannels {
channelNumber: number;
@@ -13,6 +13,14 @@ export interface LiveChannels {
group: string;
url: string;
}[];
epgUrl: string;
epgs: {
[key: string]: {
start: string;
end: string;
title: string;
}[];
};
}
const cachedLiveChannels: { [key: string]: LiveChannels } = {};
@@ -58,12 +66,123 @@ export async function refreshLiveChannels(liveInfo: {
},
});
const data = await response.text();
const channels = parseM3U(liveInfo.key, data);
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));
cachedLiveChannels[liveInfo.key] = {
channelNumber: channels.length,
channels: channels,
channelNumber: result.channels.length,
channels: result.channels,
epgUrl: epgUrl,
epgs: epgs,
};
return channels.length;
return result.channels.length;
}
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 }[] } = {};
try {
const response = await fetch(epgUrl, {
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
console.warn(`Failed to fetch EPG from ${epgUrl}: ${response.status}`);
return {};
}
// 使用 ReadableStream 逐行处理,避免将整个文件加载到内存
const reader = response.body?.getReader();
if (!reader) {
console.warn('Response body is not readable');
return {};
}
const decoder = new TextDecoder();
let buffer = '';
let currentTvgId = '';
let currentProgram: { start: string; end: string; title: string } | null = null;
let shouldSkipCurrentProgram = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// 保留最后一行(可能不完整)
buffer = lines.pop() || '';
// 处理完整的行
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// 解析 <programme> 标签
if (trimmedLine.startsWith('<programme')) {
// 提取 tvg-id
const tvgIdMatch = trimmedLine.match(/channel="([^"]*)"/);
currentTvgId = tvgIdMatch ? tvgIdMatch[1] : '';
// 提取开始时间
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> 标签 - 只有在需要解析当前节目时才处理
else if (trimmedLine.startsWith('<title') && currentProgram && !shouldSkipCurrentProgram) {
// 处理带有语言属性的title标签如 <title lang="zh">远方的家2025-60</title>
const titleMatch = trimmedLine.match(/<title(?:\s+[^>]*)?>(.*?)<\/title>/);
if (titleMatch && currentProgram) {
currentProgram.title = titleMatch[1];
// 保存节目信息这里不需要再检查tvgs.has因为shouldSkipCurrentProgram已经确保了相关性
if (!result[currentTvgId]) {
result[currentTvgId] = [];
}
result[currentTvgId].push({ ...currentProgram });
currentProgram = null;
}
}
// 处理 </programme> 标签
else if (trimmedLine === '</programme>') {
currentProgram = null;
currentTvgId = '';
shouldSkipCurrentProgram = false; // 重置跳过标志
}
}
}
// ReadableStream 会自动关闭,不需要手动调用 close
} catch (error) {
console.error('Error parsing EPG:', error);
}
return result;
}
/**
@@ -71,14 +190,17 @@ export async function refreshLiveChannels(liveInfo: {
* @param m3uContent M3U文件的内容字符串
* @returns 频道信息数组
*/
export function parseM3U(sourceKey: string, m3uContent: string): {
id: string;
tvgId: string;
name: string;
logo: string;
group: string;
url: string;
}[] {
function parseM3U(sourceKey: string, m3uContent: string): {
tvgUrl: string;
channels: {
id: string;
tvgId: string;
name: string;
logo: string;
group: string;
url: string;
}[];
} {
const channels: {
id: string;
tvgId: string;
@@ -90,10 +212,18 @@ export function parseM3U(sourceKey: string, m3uContent: string): {
const lines = m3uContent.split('\n').map(line => line.trim()).filter(line => line.length > 0);
let tvgUrl = '';
let channelIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 检查是否是 #EXTM3U 行,提取 tvg-url
if (line.startsWith('#EXTM3U')) {
const tvgUrlMatch = line.match(/x-tvg-url="([^"]*)"/);
tvgUrl = tvgUrlMatch ? tvgUrlMatch[1] : '';
continue;
}
// 检查是否是 #EXTINF 行
if (line.startsWith('#EXTINF:')) {
// 提取 tvg-id
@@ -142,7 +272,7 @@ export function parseM3U(sourceKey: string, m3uContent: string): {
}
}
return channels;
return { tvgUrl, channels };
}
// utils/urlResolver.js

56
src/lib/time.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* 时间格式转换函数
* 处理形如 "20250824000000 +0800" 的时间格式
*/
export function parseCustomTimeFormat(timeStr: string): Date {
// 如果已经是标准格式,直接返回
if (timeStr.includes('T') || timeStr.includes('-')) {
return new Date(timeStr);
}
// 处理 "20250824000000 +0800" 格式
// 格式说明YYYYMMDDHHMMSS +ZZZZ
const match = timeStr.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{4})$/);
if (match) {
const [, year, month, day, hour, minute, second, timezone] = match;
// 创建ISO格式的时间字符串
const isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}${timezone}`;
return new Date(isoString);
}
// 如果格式不匹配,尝试其他常见格式
return new Date(timeStr);
}
/**
* 格式化时间为 HH:MM 格式
*/
export function formatTimeToHHMM(timeString: string): string {
try {
const date = parseCustomTimeFormat(timeString);
if (isNaN(date.getTime())) {
return timeString; // 如果解析失败,返回原始字符串
}
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch {
return timeString;
}
}
/**
* 判断时间是否有效
*/
export function isValidTime(timeString: string): boolean {
try {
const date = parseCustomTimeFormat(timeString);
return !isNaN(date.getTime());
} catch {
return false;
}
}