mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-28 07:44:42 +08:00
feat: implement iptv
This commit is contained in:
@@ -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='分类配置'
|
||||
|
||||
53
src/app/api/admin/live/refresh/route.ts
Normal file
53
src/app/api/admin/live/refresh/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
143
src/app/api/admin/live/route.ts
Normal file
143
src/app/api/admin/live/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
30
src/app/api/live/channels/route.ts
Normal file
30
src/app/api/live/channels/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
src/app/api/live/sources/route.ts
Normal file
30
src/app/api/live/sources/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/app/api/proxy/key/route.ts
Normal file
47
src/app/api/proxy/key/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
142
src/app/api/proxy/m3u8/route.ts
Normal file
142
src/app/api/proxy/m3u8/route.ts
Normal 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;
|
||||
}
|
||||
50
src/app/api/proxy/segment/route.ts
Normal file
50
src/app/api/proxy/segment/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
1070
src/app/live/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user