From 1149c0ef45eba147338c03a3c5212457bc1cc0ba Mon Sep 17 00:00:00 2001 From: shinya Date: Sun, 24 Aug 2025 17:23:27 +0800 Subject: [PATCH] feat: add epg info --- src/app/api/live/epg/route.ts | 52 +++++ src/app/api/proxy/m3u8/route.ts | 13 +- src/app/live/page.tsx | 214 ++++++++++++++++++-- src/components/EpgScrollableRow.tsx | 290 ++++++++++++++++++++++++++++ src/lib/live.ts | 158 +++++++++++++-- src/lib/time.ts | 56 ++++++ 6 files changed, 747 insertions(+), 36 deletions(-) create mode 100644 src/app/api/live/epg/route.ts create mode 100644 src/components/EpgScrollableRow.tsx create mode 100644 src/lib/time.ts diff --git a/src/app/api/live/epg/route.ts b/src/app/api/live/epg/route.ts new file mode 100644 index 0000000..4d6e3bc --- /dev/null +++ b/src/app/api/live/epg/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/proxy/m3u8/route.ts b/src/app/api/proxy/m3u8/route.ts index dc82cb9..02b48db 100644 --- a/src/app/api/proxy/m3u8/route.ts +++ b/src/app/api/proxy/m3u8/route.ts @@ -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`; diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx index 1b32165..05404c7 100644 --- a/src/app/live/page.tsx +++ b/src/app/live/page.tsx @@ -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([]); + // 节目单信息 + 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(null); const artRef = useRef(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 && ( -
-
-
- {currentChannel.logo ? ( - {currentChannel.name} - ) : ( - - )} -
-
-

- {currentChannel.name} -

-

- {currentSource?.name} • {currentChannel.group} -

+
+
+ {/* 频道图标+名称 - 在小屏幕上占100%,大屏幕占20% */} +
+
+
+ {currentChannel.logo ? ( + {currentChannel.name} + ) : ( + + )} +
+
+

+ {currentChannel.name} +

+

+ {currentSource?.name} {' > '} {currentChannel.group} +

+
+
+ + {/* EPG节目单 */} +
)}
diff --git a/src/components/EpgScrollableRow.tsx b/src/components/EpgScrollableRow.tsx new file mode 100644 index 0000000..681885e --- /dev/null +++ b/src/components/EpgScrollableRow.tsx @@ -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(null); + const [isHovered, setIsHovered] = useState(false); + const [currentPlayingIndex, setCurrentPlayingIndex] = useState(-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 ( +
+
+

+ + 今日节目单 +

+
+
+
+
+
+ 加载节目单... +
+
+
+ ); + } + + // 无节目单状态 + if (!programs || programs.length === 0) { + return ( +
+
+

+ + 今日节目单 +

+
+
+
+
+ + 暂无节目单数据 +
+
+
+ ); + } + + return ( +
+
+

+ + 今日节目单 +

+ {currentPlayingIndex !== -1 && ( + + )} +
+ +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {programs.map((program, index) => { + // 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新 + const isPlaying = index === currentPlayingIndex; + const isFinishedProgram = index < currentPlayingIndex; + const isUpcomingProgram = index > currentPlayingIndex; + + return ( +
+ {/* 时间显示在顶部 */} +
+ + {formatTime(program.start)} + + + {formatTime(program.end)} + +
+ + {/* 标题在中间,占据剩余空间 */} +
+ {program.title} +
+ + {/* 正在播放状态在底部 */} + {isPlaying && ( +
+
+ + 正在播放 + +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/lib/live.ts b/src/lib/live.ts index 08ab2c5..e95b465 100644 --- a/src/lib/live.ts +++ b/src/lib/live.ts @@ -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; + + // 解析 标签 + if (trimmedLine.startsWith(' 标签 - 只有在需要解析当前节目时才处理 + else if (trimmedLine.startsWith('远方的家2025-60 + const titleMatch = trimmedLine.match(/]*)?>(.*?)<\/title>/); + if (titleMatch && currentProgram) { + currentProgram.title = titleMatch[1]; + + // 保存节目信息(这里不需要再检查tvgs.has,因为shouldSkipCurrentProgram已经确保了相关性) + if (!result[currentTvgId]) { + result[currentTvgId] = []; + } + result[currentTvgId].push({ ...currentProgram }); + + currentProgram = null; + } + } + // 处理 标签 + else if (trimmedLine === '') { + 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 diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..5863a6e --- /dev/null +++ b/src/lib/time.ts @@ -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; + } +}