mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-12 08:47:28 +08:00
feat: add epg info
This commit is contained in:
158
src/lib/live.ts
158
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;
|
||||
|
||||
// 解析 <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
56
src/lib/time.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user