feat: implement iptv

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

View File

@@ -34,7 +34,7 @@
"crypto-js": "^4.2.0",
"framer-motion": "^12.18.1",
"he": "^1.2.0",
"hls.js": "^1.6.6",
"hls.js": "^1.6.10",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"next": "^14.2.23",

10
pnpm-lock.yaml generated
View File

@@ -54,8 +54,8 @@ importers:
specifier: ^1.2.0
version: 1.2.0
hls.js:
specifier: ^1.6.6
version: 1.6.6
specifier: ^1.6.10
version: 1.6.10
lucide-react:
specifier: ^0.438.0
version: 0.438.0(react@18.3.1)
@@ -3008,8 +3008,8 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hls.js@1.6.6:
resolution: {integrity: sha512-S4uTCwTHOtImW+/jxMjzG7udbHy5z682YQRbm/4f7VXuVNEoGBRjPJnD3Fxrufomdhzdtv24KnxRhPMXSvL6Fw==}
hls.js@1.6.10:
resolution: {integrity: sha512-16XHorwFNh+hYazYxDNXBLEm5aRoU+oxMX6qVnkbGH3hJil4xLav3/M6NH92VkD1qSOGKXeSm+5unuawPXK6OQ==}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -8526,7 +8526,7 @@ snapshots:
he@1.2.0: {}
hls.js@1.6.6: {}
hls.js@1.6.10: {}
hosted-git-info@2.8.9: {}

View File

@@ -33,6 +33,7 @@ import {
FileText,
FolderOpen,
Settings,
Tv,
Users,
Video,
} from 'lucide-react';
@@ -251,6 +252,18 @@ interface DataSource {
from: 'config' | 'custom';
}
// 直播源数据类型
interface LiveDataSource {
name: string;
key: string;
url: string;
ua?: string;
epg?: string;
channelNumber?: number;
disabled?: boolean;
from: 'config' | 'custom';
}
// 自定义分类数据类型
interface CustomCategory {
name?: string;
@@ -3858,6 +3871,425 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
);
};
// 直播源配置组件
const LiveSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const [liveSources, setLiveSources] = useState<LiveDataSource[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [newLiveSource, setNewLiveSource] = useState<LiveDataSource>({
name: '',
key: '',
url: '',
ua: '',
epg: '',
disabled: false,
from: 'custom',
});
// dnd-kit 传感器
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 轻微位移即可触发
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
tolerance: 5,
},
})
);
// 初始化
useEffect(() => {
if (config?.LiveConfig) {
setLiveSources(config.LiveConfig);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callLiveSourceApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/live', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${resp.status}`);
}
// 成功后刷新配置
await refreshConfig();
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (key: string) => {
const target = liveSources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
callLiveSourceApi({ action, key }).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
callLiveSourceApi({ action: 'delete', key }).catch(() => {
console.error('操作失败', 'delete', key);
});
};
// 刷新直播源
const handleRefreshLiveSources = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
try {
const response = await fetch('/api/admin/live/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `刷新失败: ${response.status}`);
}
// 刷新成功后重新获取配置
await refreshConfig();
showAlert({ type: 'success', title: '刷新成功', message: '直播源已刷新', timer: 2000 });
} catch (err) {
showError(err instanceof Error ? err.message : '刷新失败', showAlert);
} finally {
setIsRefreshing(false);
}
};
const handleAddLiveSource = () => {
if (!newLiveSource.name || !newLiveSource.key || !newLiveSource.url) return;
callLiveSourceApi({
action: 'add',
key: newLiveSource.key,
name: newLiveSource.name,
url: newLiveSource.url,
ua: newLiveSource.ua,
epg: newLiveSource.epg,
})
.then(() => {
setNewLiveSource({
name: '',
key: '',
url: '',
epg: '',
ua: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
})
.catch(() => {
console.error('操作失败', 'add', newLiveSource);
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = liveSources.findIndex((s) => s.key === active.id);
const newIndex = liveSources.findIndex((s) => s.key === over.id);
setLiveSources((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = liveSources.map((s) => s.key);
callLiveSourceApi({ action: 'sort', order })
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 可拖拽行封装 (dnd-kit)
const DraggableRow = ({ liveSource }: { liveSource: LiveDataSource }) => {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: liveSource.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
>
<td
className='px-2 py-4 cursor-grab text-gray-400'
style={{ touchAction: 'none' }}
{...attributes}
{...listeners}
>
<GripVertical size={16} />
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{liveSource.name}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
{liveSource.key}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
title={liveSource.url}
>
{liveSource.url}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={liveSource.epg || '-'}
>
{liveSource.epg || '-'}
</td>
<td
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
title={liveSource.ua || '-'}
>
{liveSource.ua || '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-center'>
{liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'}
</td>
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
<span
className={`px-2 py-1 text-xs rounded-full ${!liveSource.disabled
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}
>
{!liveSource.disabled ? '启用中' : '已禁用'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleToggleEnable(liveSource.key)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!liveSource.disabled
? buttonStyles.roundedDanger
: buttonStyles.roundedSuccess
} transition-colors`}
>
{!liveSource.disabled ? '禁用' : '启用'}
</button>
{liveSource.from !== 'config' && (
<button
onClick={() => handleDelete(liveSource.key)}
className={buttonStyles.roundedSecondary}
>
</button>
)}
</td>
</tr>
);
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加直播源表单 */}
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<div className='flex items-center space-x-2'>
<button
onClick={handleRefreshLiveSources}
disabled={isRefreshing}
className={`px-3 py-1.5 text-sm font-medium flex items-center space-x-2 ${isRefreshing
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors'
}`}
>
<span>{isRefreshing ? '刷新中...' : '刷新直播源'}</span>
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddForm ? '取消' : '添加直播源'}
</button>
</div>
</div>
{showAddForm && (
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='名称'
value={newLiveSource.name}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, name: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='Key'
value={newLiveSource.key}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, key: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='M3U 地址'
value={newLiveSource.url}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, url: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='节目单地址(选填)'
value={newLiveSource.epg}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, epg: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
<input
type='text'
placeholder='自定义 UA选填'
value={newLiveSource.ua}
onChange={(e) =>
setNewLiveSource((prev) => ({ ...prev, ua: e.target.value }))
}
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddLiveSource}
disabled={!newLiveSource.name || !newLiveSource.key || !newLiveSource.url}
className={`w-full sm:w-auto px-4 py-2 ${!newLiveSource.name || !newLiveSource.key || !newLiveSource.url ? buttonStyles.disabled : buttonStyles.success}`}
>
</button>
</div>
</div>
)}
{/* 直播源表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="live-source-list">
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-8' />
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
Key
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
M3U
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
UA
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
</tr>
</thead>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
autoScroll={false}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={liveSources.map((s) => s.key)}
strategy={verticalListSortingStrategy}
>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{liveSources.map((liveSource) => (
<DraggableRow key={liveSource.key} liveSource={liveSource} />
))}
</tbody>
</SortableContext>
</DndContext>
</table>
</div>
{/* 保存排序按钮 */}
{orderChanged && (
<div className='flex justify-end'>
<button
onClick={handleSaveOrder}
className={`px-3 py-1.5 text-sm ${buttonStyles.primary}`}
>
</button>
</div>
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
function AdminPageClient() {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const [config, setConfig] = useState<AdminConfig | null>(null);
@@ -3868,6 +4300,7 @@ function AdminPageClient() {
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
userConfig: false,
videoSource: false,
liveSource: false,
siteConfig: false,
categoryConfig: false,
configFile: false,
@@ -4042,6 +4475,18 @@ function AdminPageClient() {
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 直播源配置标签 */}
<CollapsibleTab
title='直播源配置'
icon={
<Tv size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.liveSource}
onToggle={() => toggleTab('liveSource')}
>
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 分类配置标签 */}
<CollapsibleTab
title='分类配置'

View File

@@ -0,0 +1,53 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { refreshLiveChannels } from '@/lib/live';
export async function POST(request: NextRequest) {
try {
// 权限检查
const authInfo = getAuthInfoFromCookie(request);
const username = authInfo?.username;
const config = await getConfig();
if (username !== process.env.USERNAME) {
// 管理员
const user = config.UserConfig.Users.find(
(u) => u.username === username
);
if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
for (const liveInfo of config.LiveConfig || []) {
if (liveInfo.disabled) {
continue;
}
try {
const nums = await refreshLiveChannels(liveInfo);
liveInfo.channelNumber = nums;
} catch (error) {
console.error('刷新直播源失败:', error);
liveInfo.channelNumber = 0;
}
}
// 保存配置
await db.saveAdminConfig(config);
return NextResponse.json({
success: true,
message: '直播源刷新成功',
});
} catch (error) {
console.error('直播源刷新失败:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : '刷新失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,143 @@
/* eslint-disable no-console,no-case-declarations */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
export async function POST(request: NextRequest) {
try {
// 权限检查
const authInfo = getAuthInfoFromCookie(request);
const username = authInfo?.username;
const config = await getConfig();
if (username !== process.env.USERNAME) {
// 管理员
const user = config.UserConfig.Users.find(
(u) => u.username === username
);
if (!user || user.role !== 'admin' || user.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
const body = await request.json();
const { action, key, name, url, ua, epg } = body;
if (!config) {
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
}
// 确保 LiveConfig 存在
if (!config.LiveConfig) {
config.LiveConfig = [];
}
switch (action) {
case 'add':
// 检查是否已存在相同的 key
if (config.LiveConfig.some((l) => l.key === key)) {
return NextResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
}
const liveInfo = {
key: key as string,
name: name as string,
url: url as string,
ua: ua || '',
epg: epg || '',
from: 'custom' as 'custom' | 'config',
channelNumber: 0,
disabled: false,
}
try {
const nums = await refreshLiveChannels(liveInfo);
liveInfo.channelNumber = nums;
} catch (error) {
console.error('刷新直播源失败:', error);
liveInfo.channelNumber = 0;
}
// 添加新的直播源
config.LiveConfig.push(liveInfo);
break;
case 'delete':
// 删除直播源
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
if (deleteIndex === -1) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
const liveSource = config.LiveConfig[deleteIndex];
if (liveSource.from === 'config') {
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
}
deleteCachedLiveChannels(key);
config.LiveConfig.splice(deleteIndex, 1);
break;
case 'enable':
// 启用直播源
const enableSource = config.LiveConfig.find((l) => l.key === key);
if (!enableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
enableSource.disabled = false;
break;
case 'disable':
// 禁用直播源
const disableSource = config.LiveConfig.find((l) => l.key === key);
if (!disableSource) {
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
}
disableSource.disabled = true;
break;
case 'sort':
// 排序直播源
const { order } = body;
if (!Array.isArray(order)) {
return NextResponse.json({ error: '排序数据格式错误' }, { status: 400 });
}
// 创建新的排序后的数组
const sortedLiveConfig: typeof config.LiveConfig = [];
order.forEach((key) => {
const source = config.LiveConfig?.find((l) => l.key === key);
if (source) {
sortedLiveConfig.push(source);
}
});
// 添加未在排序列表中的直播源(保持原有顺序)
config.LiveConfig.forEach((source) => {
if (!order.includes(source.key)) {
sortedLiveConfig.push(source);
}
});
config.LiveConfig = sortedLiveConfig;
break;
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}
// 保存配置
await db.saveAdminConfig(config);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '操作失败' },
{ status: 500 }
);
}
}

View File

@@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getConfig, refineConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
import { refreshLiveChannels } from '@/lib/live';
import { SearchResult } from '@/lib/types';
export const runtime = 'nodejs';
@@ -358,9 +359,25 @@ async function cronJob() {
// 执行其他定时任务
await refreshConfig();
await refreshAllLiveChannels();
await refreshRecordAndFavorites();
}
async function refreshAllLiveChannels() {
const config = await getConfig();
for (const liveInfo of config.LiveConfig || []) {
if (liveInfo.disabled) {
continue;
}
try {
const nums = await refreshLiveChannels(liveInfo);
liveInfo.channelNumber = nums;
} catch (error) {
console.error('刷新直播源失败:', error);
}
}
}
async function refreshConfig() {
let config = await getConfig();
if (config && config.ConfigSubscribtion && config.ConfigSubscribtion.URL && config.ConfigSubscribtion.AutoUpdate) {

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedLiveChannels } from '@/lib/live';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sourceKey = searchParams.get('source');
if (!sourceKey) {
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
}
const channelData = await getCachedLiveChannels(sourceKey);
if (!channelData) {
return NextResponse.json({ error: '频道信息未找到' }, { status: 404 });
}
return NextResponse.json({
success: true,
data: channelData.channels
});
} catch (error) {
return NextResponse.json(
{ error: '获取频道信息失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,30 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
export async function GET(request: NextRequest) {
console.log(request.url)
try {
const config = await getConfig();
if (!config) {
return NextResponse.json({ error: '配置未找到' }, { status: 404 });
}
// 过滤出所有非 disabled 的直播源
const liveSources = (config.LiveConfig || []).filter(source => !source.disabled);
return NextResponse.json({
success: true,
data: liveSources
});
} catch (error) {
console.error('获取直播源失败:', error);
return NextResponse.json(
{ error: '获取直播源失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,47 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const source = searchParams.get('moontv-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
console.log(decodedUrl);
const response = await fetch(decodedUrl, {
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
}
const keyData = await response.arrayBuffer();
return new Response(keyData, {
headers: {
'Content-Type': 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Cache-Control': 'public, max-age=3600'
},
});
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
}
}

View File

@@ -0,0 +1,142 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
import { getBaseUrl, resolveUrl } from "@/lib/live";
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const allowCORS = searchParams.get('allowCORS') === 'true';
const source = searchParams.get('moontv-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
const response = await fetch(decodedUrl, {
cache: 'no-cache',
redirect: 'follow',
credentials: 'same-origin',
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
}
// 获取最终的响应URL处理重定向后的URL
const finalUrl = response.url;
const m3u8Content = await response.text();
// 使用最终的响应URL作为baseUrl而不是原始的请求URL
const baseUrl = getBaseUrl(finalUrl);
// 重写 M3U8 内容
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, allowCORS);
const headers = new Headers();
headers.set('Content-Type', 'application/vnd.apple.mpegurl');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Cache-Control', 'no-cache');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
return new Response(modifiedContent, { headers });
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
}
}
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allowCORS: boolean) {
const protocol = req.headers.get('x-forwarded-proto') || 'http';
const host = req.headers.get('host');
const proxyBase = `${protocol}://${host}/api/proxy`;
const lines = content.split('\n');
const rewrittenLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
// 处理 TS 片段 URL 和其他媒体文件
if (line && !line.startsWith('#')) {
const resolvedUrl = resolveUrl(baseUrl, line);
// 检查是否为 mp4 格式
const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4');
const proxyUrl = (isMp4 || allowCORS) ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
rewrittenLines.push(proxyUrl);
continue;
}
// 处理 EXT-X-MAP 标签中的 URI
if (line.startsWith('#EXT-X-MAP:')) {
line = rewriteMapUri(line, baseUrl, proxyBase);
}
// 处理 EXT-X-KEY 标签中的 URI
if (line.startsWith('#EXT-X-KEY:')) {
line = rewriteKeyUri(line, baseUrl, proxyBase);
}
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
if (line.startsWith('#EXT-X-STREAM-INF:')) {
rewrittenLines.push(line);
// 下一行通常是 M3U8 URL
if (i + 1 < lines.length) {
i++;
const nextLine = lines[i].trim();
if (nextLine && !nextLine.startsWith('#')) {
const resolvedUrl = resolveUrl(baseUrl, nextLine);
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`;
rewrittenLines.push(proxyUrl);
} else {
rewrittenLines.push(nextLine);
}
}
continue;
}
rewrittenLines.push(line);
}
return rewrittenLines.join('\n');
}
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
// 检查是否为 mp4 格式,如果是则走 proxyBase
const isMp4 = resolvedUrl.toLowerCase().includes('.mp4') || resolvedUrl.toLowerCase().includes('mp4');
const proxyUrl = isMp4 ? `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}` : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;
}
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
const originalUri = uriMatch[1];
const resolvedUrl = resolveUrl(baseUrl, originalUri);
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
}
return line;
}

View File

@@ -0,0 +1,50 @@
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
import { NextResponse } from "next/server";
import { getConfig } from "@/lib/config";
export const runtime = 'nodejs';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
const source = searchParams.get('moontv-source');
if (!url) {
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
}
const config = await getConfig();
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
if (!liveSource) {
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
}
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
try {
const decodedUrl = decodeURIComponent(url);
const response = await fetch(decodedUrl, {
headers: {
'User-Agent': ua,
},
});
if (!response.ok) {
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
const headers = new Headers();
headers.set('Content-Type', 'video/mp2t');
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
headers.set('Accept-Ranges', 'bytes');
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
const contentLength = response.headers.get('content-length');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
return new Response(response.body, { headers });
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
}
}

View File

@@ -2,10 +2,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { AdminConfig } from '@/lib/admin.types';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { AdminConfig } from '@/lib/admin.types';
import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';

1070
src/app/live/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -652,6 +652,7 @@ function SearchPageClient() {
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder='搜索电影、电视剧...'
autoComplete="off"
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-12 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
/>

View File

@@ -2,7 +2,7 @@
'use client';
import { Cat, Clover, Film, Home, Star, Tv } from 'lucide-react';
import { Cat, Clover, Film, Home, Radio, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -42,6 +42,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
label: '综艺',
href: '/douban?type=show',
},
{
icon: Radio,
label: '直播',
href: '/live',
},
]);
useEffect(() => {

View File

@@ -14,7 +14,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
return (
<div className='w-full min-h-screen'>
{/* 移动端头部 */}
<MobileHeader showBackButton={['/play'].includes(activePath)} />
<MobileHeader showBackButton={['/play', '/live'].includes(activePath)} />
{/* 主要布局容器 */}
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'>
@@ -26,7 +26,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
{/* 主内容区域 */}
<div className='relative min-w-0 flex-1 transition-all duration-300'>
{/* 桌面端左上角返回按钮 */}
{['/play'].includes(activePath) && (
{['/play', '/live'].includes(activePath) && (
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
<BackButton />
</div>

View File

@@ -2,7 +2,7 @@
'use client';
import { Cat, Clover, Film, Home, Menu, Search, Star, Tv } from 'lucide-react';
import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
@@ -145,6 +145,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
label: '综艺',
href: '/douban?type=show',
},
{
icon: Radio,
label: '直播',
href: '/live',
},
]);
useEffect(() => {

View File

@@ -66,6 +66,7 @@ export const UserMenu: React.FC = () => {
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [enableOptimization, setEnableOptimization] = useState(true);
const [fluidSearch, setFluidSearch] = useState(true);
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState('melody-cdn-sharon');
const [doubanImageProxyType, setDoubanImageProxyType] = useState('melody-cdn-sharon');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
@@ -191,6 +192,11 @@ export const UserMenu: React.FC = () => {
} else if (defaultFluidSearch !== undefined) {
setFluidSearch(defaultFluidSearch);
}
const savedLiveDirectConnect = localStorage.getItem('liveDirectConnect');
if (savedLiveDirectConnect !== null) {
setLiveDirectConnect(JSON.parse(savedLiveDirectConnect));
}
}
}, []);
@@ -366,6 +372,13 @@ export const UserMenu: React.FC = () => {
}
};
const handleLiveDirectConnectToggle = (value: boolean) => {
setLiveDirectConnect(value);
if (typeof window !== 'undefined') {
localStorage.setItem('liveDirectConnect', JSON.stringify(value));
}
};
const handleDoubanDataSourceChange = (value: string) => {
setDoubanDataSource(value);
if (typeof window !== 'undefined') {
@@ -426,6 +439,7 @@ export const UserMenu: React.FC = () => {
setDefaultAggregateSearch(true);
setEnableOptimization(true);
setFluidSearch(defaultFluidSearch);
setLiveDirectConnect(false);
setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType);
setDoubanImageProxyType(defaultDoubanImageProxyType);
@@ -435,6 +449,7 @@ export const UserMenu: React.FC = () => {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
@@ -922,6 +937,30 @@ export const UserMenu: React.FC = () => {
</div>
</label>
</div>
{/* 直播视频浏览器直连 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
IPTV
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
IPTV Allow CORS
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={liveDirectConnect}
onChange={(e) => handleLiveDirectConnectToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
</div>
{/* 底部说明 */}

View File

@@ -672,7 +672,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 年份徽章 */}
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
<div
className={`absolute top-2 bg-black/50 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90 left-2`}
className="absolute top-2 bg-black/50 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90 left-2"
style={{
WebkitUserSelect: 'none',
userSelect: 'none',

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