mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-05 19:17:30 +08:00
feat: implement iptv
This commit is contained in:
@@ -45,6 +45,16 @@ export interface AdminConfig {
|
||||
from: 'config' | 'custom';
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
LiveConfig?: {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string; // m3u 地址
|
||||
ua?: string;
|
||||
epg?: string; // 节目单
|
||||
from: 'config' | 'custom';
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AdminConfigResult {
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface ApiSite {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface LiveCfg {
|
||||
name: string;
|
||||
url: string;
|
||||
ua?: string;
|
||||
epg?: string; // 节目单
|
||||
}
|
||||
|
||||
interface ConfigFileStruct {
|
||||
cache_time?: number;
|
||||
api_site?: {
|
||||
@@ -21,6 +28,9 @@ interface ConfigFileStruct {
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
}[];
|
||||
lives?: {
|
||||
[key: string]: LiveCfg;
|
||||
}
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
@@ -46,6 +56,7 @@ export const API_CONFIG = {
|
||||
// 在模块加载时根据环境决定配置来源
|
||||
let cachedConfig: AdminConfig;
|
||||
|
||||
|
||||
// 从配置文件补充管理员配置
|
||||
export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||
let fileConfig: ConfigFileStruct;
|
||||
@@ -131,6 +142,43 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.CustomCategories = Array.from(currentCustomCategories.values());
|
||||
|
||||
const livesFromFile = Object.entries(fileConfig.lives || []);
|
||||
const currentLives = new Map(
|
||||
(adminConfig.LiveConfig || []).map((l) => [l.key, l])
|
||||
);
|
||||
livesFromFile.forEach(([key, site]) => {
|
||||
const existingLive = currentLives.get(key);
|
||||
if (existingLive) {
|
||||
existingLive.name = site.name;
|
||||
existingLive.url = site.url;
|
||||
existingLive.ua = site.ua;
|
||||
existingLive.epg = site.epg;
|
||||
} else {
|
||||
// 如果不存在,创建新条目
|
||||
currentLives.set(key, {
|
||||
key,
|
||||
name: site.name,
|
||||
url: site.url,
|
||||
ua: site.ua,
|
||||
epg: site.epg,
|
||||
channelNumber: 0,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 检查现有 LiveConfig 是否在 fileConfig.lives 中,如果不在则标记为 custom
|
||||
const livesFromFileKeys = new Set(livesFromFile.map(([key]) => key));
|
||||
currentLives.forEach((live) => {
|
||||
if (!livesFromFileKeys.has(live.key)) {
|
||||
live.from = 'custom';
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.LiveConfig = Array.from(currentLives.values());
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
@@ -176,6 +224,7 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
},
|
||||
SourceConfig: [],
|
||||
CustomCategories: [],
|
||||
LiveConfig: [],
|
||||
};
|
||||
|
||||
// 补充用户信息
|
||||
@@ -220,6 +269,23 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
});
|
||||
});
|
||||
|
||||
// 从配置文件中补充直播源信息
|
||||
Object.entries(cfgFile.lives || []).forEach(([key, live]) => {
|
||||
if (!adminConfig.LiveConfig) {
|
||||
adminConfig.LiveConfig = [];
|
||||
}
|
||||
adminConfig.LiveConfig.push({
|
||||
key,
|
||||
name: live.name,
|
||||
url: live.url,
|
||||
ua: live.ua,
|
||||
epg: live.epg,
|
||||
channelNumber: 0,
|
||||
from: 'config',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
@@ -261,6 +327,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (!adminConfig.CustomCategories || !Array.isArray(adminConfig.CustomCategories)) {
|
||||
adminConfig.CustomCategories = [];
|
||||
}
|
||||
if (!adminConfig.LiveConfig || !Array.isArray(adminConfig.LiveConfig)) {
|
||||
adminConfig.LiveConfig = [];
|
||||
}
|
||||
|
||||
// 站长变更自检
|
||||
const ownerUser = process.env.USERNAME;
|
||||
@@ -311,6 +380,17 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
seenCustomCategoryKeys.add(category.query + category.type);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 直播源去重
|
||||
const seenLiveKeys = new Set<string>();
|
||||
adminConfig.LiveConfig = adminConfig.LiveConfig.filter((live) => {
|
||||
if (seenLiveKeys.has(live.key)) {
|
||||
return false;
|
||||
}
|
||||
seenLiveKeys.add(live.key);
|
||||
return true;
|
||||
});
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
|
||||
220
src/lib/live.ts
Normal file
220
src/lib/live.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
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 + '/';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user