mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2025-09-09 01:22:41 +08:00
220 lines
6.1 KiB
TypeScript
220 lines
6.1 KiB
TypeScript
import { getConfig } from "@/lib/config";
|
|
import { db } from "@/lib/db";
|
|
|
|
const defaultUA = 'okHttp/Mod-1.1.0'
|
|
|
|
export interface LiveChannels {
|
|
channelNumber: number;
|
|
channels: {
|
|
id: string;
|
|
tvgId: string;
|
|
name: string;
|
|
logo: string;
|
|
group: string;
|
|
url: string;
|
|
}[];
|
|
}
|
|
|
|
const cachedLiveChannels: { [key: string]: LiveChannels } = {};
|
|
|
|
export function deleteCachedLiveChannels(key: string) {
|
|
delete cachedLiveChannels[key];
|
|
}
|
|
|
|
export async function getCachedLiveChannels(key: string): Promise<LiveChannels | null> {
|
|
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<number> {
|
|
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 channels = parseM3U(liveInfo.key, data);
|
|
cachedLiveChannels[liveInfo.key] = {
|
|
channelNumber: channels.length,
|
|
channels: channels,
|
|
};
|
|
return channels.length;
|
|
}
|
|
|
|
/**
|
|
* 解析M3U文件内容,提取频道信息
|
|
* @param m3uContent M3U文件的内容字符串
|
|
* @returns 频道信息数组
|
|
*/
|
|
export function parseM3U(sourceKey: string, m3uContent: string): {
|
|
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 channelIndex = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
// 检查是否是 #EXTINF 行
|
|
if (line.startsWith('#EXTINF:')) {
|
|
// 提取 tvg-id
|
|
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/);
|
|
const 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 || '';
|
|
|
|
// 检查下一行是否是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 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 + '/';
|
|
}
|
|
} |