diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2e105bb..89328ea 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/.gitignore b/.gitignore index 41e2415..6977ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,7 @@ public/manifest.json public/sw.js public/sw.js.map public/workbox-*.js -public/workbox-*.js.map \ No newline at end of file +public/workbox-*.js.map + +/.claude +/.vscode \ No newline at end of file diff --git a/src/app/api/live/epg/download/route.ts b/src/app/api/live/epg/download/route.ts new file mode 100644 index 0000000..3fa0f51 --- /dev/null +++ b/src/app/api/live/epg/download/route.ts @@ -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 } + ); + } +} diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx index 2bf5678..5cad145 100644 --- a/src/app/live/page.tsx +++ b/src/app/live/page.tsx @@ -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); } }; diff --git a/src/lib/live.ts b/src/lib/live.ts index fa4a5e9..9c5e968 100644 --- a/src/lib/live.ts +++ b/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 { +export async function getCachedLiveChannels( + key: string +): Promise { 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; - // 解析 标签 - if (trimmedLine.startsWith(' 标签,建立ID映射 + if (trimmedLine.startsWith(' 标签,获取频道名称 + if (trimmedLine.includes(']*)?>(.*?)<\/display-name>/ + ); + if (displayNameMatch) { + const displayName = displayNameMatch[1]; + channelIdMap[currentChannelId] = displayName; + currentChannelId = ''; + } + } + // 解析 标签(注意:不使用 else if,因为可能和 在同一行) + if (trimmedLine.includes(' 标签 - 只有在需要解析当前节目时才处理 - else if (trimmedLine.startsWith('远方的家2025-60 - const titleMatch = trimmedLine.match(/]*)?>(.*?)<\/title>/); + const titleMatch = trimmedLine.match( + /]*)?>(.*?)<\/title>/ + ); if (titleMatch && currentProgram) { currentProgram.title = titleMatch[1]; @@ -167,12 +263,6 @@ async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): Promise<{ currentProgram = null; } } - // 处理 标签 - else if (trimmedLine === '') { - 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 +): { + [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; + + // 解析 标签,建立ID映射 + if (trimmedLine.startsWith(' 标签,获取频道名称 + if (trimmedLine.includes(']*)?>(.*?)<\/display-name>/ + ); + if (displayNameMatch) { + const displayName = displayNameMatch[1]; + channelIdMap[currentChannelId] = displayName; + currentChannelId = ''; + } + } + // 解析 标签(注意:不使用 else if,因为可能和 在同一行) + if (trimmedLine.includes(' 标签 - 只有在需要解析当前节目时才处理 + if ( + trimmedLine.includes('远方的家2025-60 + const titleMatch = trimmedLine.match( + /]*)?>(.*?)<\/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,12 +539,15 @@ 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 + '/'; } -} \ No newline at end of file +}