/* eslint-disable no-constant-condition */ import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; const defaultUA = 'AptvPlayer/1.4.10'; export interface LiveChannels { channelNumber: number; channels: { id: string; tvgId: string; name: string; logo: string; group: string; url: string; }[]; epgUrl: string; epgs: { [key: string]: { start: string; end: string; title: string; }[]; }; } const cachedLiveChannels: { [key: string]: LiveChannels } = {}; export function deleteCachedLiveChannels(key: string) { delete cachedLiveChannels[key]; } export async function getCachedLiveChannels( key: string ): Promise { if (!cachedLiveChannels[key]) { const config = await getConfig(); const liveInfo = config.LiveConfig?.find((live) => live.key === key); if (!liveInfo) { return null; } const channelNum = await refreshLiveChannels(liveInfo); if (channelNum === 0) { return null; } liveInfo.channelNumber = channelNum; await db.saveAdminConfig(config); } return cachedLiveChannels[key] || null; } export async function refreshLiveChannels(liveInfo: { key: string; name: string; url: string; ua?: string; epg?: string; from: 'config' | 'custom'; channelNumber?: number; disabled?: boolean; }): Promise { if (cachedLiveChannels[liveInfo.key]) { delete cachedLiveChannels[liveInfo.key]; } const ua = liveInfo.ua || defaultUA; const response = await fetch(liveInfo.url, { headers: { 'User-Agent': ua, }, }); const data = await response.text(); const result = parseM3U(liveInfo.key, data); const epgUrl = liveInfo.epg || result.tvgUrl; 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, epgUrl: epgUrl, epgs: epgs, }; 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) { return {}; } // 检查是否是 gzip 压缩文件 const isGzip = epgUrl.endsWith('.gz') || response.headers.get('content-encoding') === 'gzip'; // 使用 ReadableStream 逐行处理,避免将整个文件加载到内存 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 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; // 解析 标签,建立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]; // 保存节目信息(这里不需要再检查tvgs.has,因为shouldSkipCurrentProgram已经确保了相关性) if (!result[currentTvgId]) { result[currentTvgId] = []; } result[currentTvgId].push({ ...currentProgram }); currentProgram = null; } } } } } catch (error) { // ignore } 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 ): { tvgUrl: string; channels: { id: string; tvgId: string; name: string; logo: string; group: string; url: string; }[]; } { const channels: { id: string; tvgId: string; name: string; logo: string; group: string; url: 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')) { // 支持两种格式:x-tvg-url 和 url-tvg const tvgUrlMatch = line.match(/(?:x-tvg-url|url-tvg)="([^"]*)"/); tvgUrl = tvgUrlMatch ? tvgUrlMatch[1].split(',')[0].trim() : ''; continue; } // 检查是否是 #EXTINF 行 if (line.startsWith('#EXTINF:')) { // 提取 tvg-id const tvgIdMatch = line.match(/tvg-id="([^"]*)"/); let tvgId = tvgIdMatch ? tvgIdMatch[1] : ''; // 提取 tvg-name const tvgNameMatch = line.match(/tvg-name="([^"]*)"/); const tvgName = tvgNameMatch ? tvgNameMatch[1] : ''; // 提取 tvg-logo const tvgLogoMatch = line.match(/tvg-logo="([^"]*)"/); const logo = tvgLogoMatch ? tvgLogoMatch[1] : ''; // 提取 group-title const groupTitleMatch = line.match(/group-title="([^"]*)"/); const group = groupTitleMatch ? groupTitleMatch[1] : '无分组'; // 提取标题(#EXTINF 行最后的逗号后面的内容) const titleMatch = line.match(/,([^,]*)$/); const title = titleMatch ? titleMatch[1].trim() : ''; // 优先使用 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]; // 只有当有名称和URL时才添加到结果中 if (name && url) { channels.push({ id: `${sourceKey}-${channelIndex}`, tvgId, name, logo, group, url, }); channelIndex++; } // 跳过下一行,因为已经处理了 i++; } } } return { tvgUrl, channels }; } // utils/urlResolver.js export function resolveUrl(baseUrl: string, relativePath: string) { try { // 如果已经是完整的 URL,直接返回 if ( relativePath.startsWith('http://') || relativePath.startsWith('https://') ) { return relativePath; } // 如果是协议相对路径 (//example.com/path) if (relativePath.startsWith('//')) { const baseUrlObj = new URL(baseUrl); return `${baseUrlObj.protocol}${relativePath}`; } // 使用 URL 构造函数处理相对路径 const baseUrlObj = new URL(baseUrl); const resolvedUrl = new URL(relativePath, baseUrlObj); return resolvedUrl.href; } catch (error) { // 降级处理 return fallbackUrlResolve(baseUrl, relativePath); } } function fallbackUrlResolve(baseUrl: string, relativePath: string) { // 移除 baseUrl 末尾的文件名,保留目录路径 let base = baseUrl; if (!base.endsWith('/')) { base = base.substring(0, base.lastIndexOf('/') + 1); } // 处理不同类型的相对路径 if (relativePath.startsWith('/')) { // 绝对路径 (/path/to/file) const urlObj = new URL(base); 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); for (const segment of relativeSegments) { if (segment === '..') { segments.pop(); } else if (segment !== '.') { segments.push(segment); } } const urlObj = new URL(base); return `${urlObj.protocol}//${urlObj.host}/${segments.join('/')}`; } else { // 当前目录相对路径 (file.ts 或 ./file.ts) const cleanRelative = relativePath.startsWith('./') ? relativePath.slice(2) : relativePath; return base + cleanRelative; } } // 获取 M3U8 的基础 URL export function getBaseUrl(m3u8Url: string) { try { const url = new URL(m3u8Url); // 如果 URL 以 .m3u8 结尾,移除文件名 if (url.pathname.endsWith('.m3u8')) { url.pathname = url.pathname.substring( 0, url.pathname.lastIndexOf('/') + 1 ); } else if (!url.pathname.endsWith('/')) { url.pathname += '/'; } return url.protocol + '//' + url.host + url.pathname; } catch (error) { return m3u8Url.endsWith('/') ? m3u8Url : m3u8Url + '/'; } }