feat: implement iptv

This commit is contained in:
shinya
2025-08-24 00:26:48 +08:00
parent 179e74bf45
commit b4e81d94eb
22 changed files with 2399 additions and 12 deletions

View File

@@ -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 {

View File

@@ -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
View 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 + '/';
}
}