From 0a8c255279d499f21c8df12fdce0dbb902c35e0f Mon Sep 17 00:00:00 2001 From: shinya Date: Sun, 24 Aug 2025 00:26:48 +0800 Subject: [PATCH] feat: implement iptv --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/app/admin/page.tsx | 445 ++++++++++ src/app/api/admin/live/refresh/route.ts | 53 ++ src/app/api/admin/live/route.ts | 143 +++ src/app/api/cron/route.ts | 17 + src/app/api/live/channels/route.ts | 30 + src/app/api/live/sources/route.ts | 30 + src/app/api/proxy/key/route.ts | 47 + src/app/api/proxy/m3u8/route.ts | 142 +++ src/app/api/proxy/segment/route.ts | 50 ++ src/app/api/search/suggestions/route.ts | 2 +- src/app/live/page.tsx | 1070 +++++++++++++++++++++++ src/app/search/page.tsx | 1 + src/components/MobileBottomNav.tsx | 7 +- src/components/PageLayout.tsx | 4 +- src/components/Sidebar.tsx | 7 +- src/components/UserMenu.tsx | 39 + src/components/VideoCard.tsx | 2 +- src/lib/admin.types.ts | 10 + src/lib/config.ts | 80 ++ src/lib/live.ts | 220 +++++ 22 files changed, 2399 insertions(+), 12 deletions(-) create mode 100644 src/app/api/admin/live/refresh/route.ts create mode 100644 src/app/api/admin/live/route.ts create mode 100644 src/app/api/live/channels/route.ts create mode 100644 src/app/api/live/sources/route.ts create mode 100644 src/app/api/proxy/key/route.ts create mode 100644 src/app/api/proxy/m3u8/route.ts create mode 100644 src/app/api/proxy/segment/route.ts create mode 100644 src/app/live/page.tsx create mode 100644 src/lib/live.ts diff --git a/package.json b/package.json index c93ad26..8b6db56 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 846452f..9f97cab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ebb3fbe..ee0df2a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const [liveSources, setLiveSources] = useState([]); + const [showAddForm, setShowAddForm] = useState(false); + const [orderChanged, setOrderChanged] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [newLiveSource, setNewLiveSource] = useState({ + 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) => { + 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 ( + + + + + + {liveSource.name} + + + {liveSource.key} + + + {liveSource.url} + + + {liveSource.epg || '-'} + + + {liveSource.ua || '-'} + + + {liveSource.channelNumber && liveSource.channelNumber > 0 ? liveSource.channelNumber : '-'} + + + + {!liveSource.disabled ? '启用中' : '已禁用'} + + + + + {liveSource.from !== 'config' && ( + + )} + + + ); + }; + + if (!config) { + return ( +
+ 加载中... +
+ ); + } + + return ( +
+ {/* 添加直播源表单 */} +
+

+ 直播源列表 +

+
+ + +
+
+ + {showAddForm && ( +
+
+ + 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' + /> + + 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' + /> + + 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' + /> + + 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' + /> + + 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' + /> + +
+
+ +
+
+ )} + + {/* 直播源表格 */} +
+ + + + + + + + + + + + + + + s.key)} + strategy={verticalListSortingStrategy} + > + + {liveSources.map((liveSource) => ( + + ))} + + + +
+ + 名称 + + Key + + M3U 地址 + + 节目单地址 + + 自定义 UA + + 频道数 + + 状态 + + 操作 +
+
+ + {/* 保存排序按钮 */} + {orderChanged && ( +
+ +
+ )} + + {/* 通用弹窗组件 */} + + + +
+ ); +}; + function AdminPageClient() { const { alertModal, showAlert, hideAlert } = useAlertModal(); const [config, setConfig] = useState(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() { + {/* 直播源配置标签 */} + + } + isExpanded={expandedTabs.liveSource} + onToggle={() => toggleTab('liveSource')} + > + + + {/* 分类配置标签 */} 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 } + ); + } +} diff --git a/src/app/api/admin/live/route.ts b/src/app/api/admin/live/route.ts new file mode 100644 index 0000000..9c06e03 --- /dev/null +++ b/src/app/api/admin/live/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index 3654fa1..9abcfab 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -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) { diff --git a/src/app/api/live/channels/route.ts b/src/app/api/live/channels/route.ts new file mode 100644 index 0000000..9dd3c7b --- /dev/null +++ b/src/app/api/live/channels/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/live/sources/route.ts b/src/app/api/live/sources/route.ts new file mode 100644 index 0000000..399eb23 --- /dev/null +++ b/src/app/api/live/sources/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/proxy/key/route.ts b/src/app/api/proxy/key/route.ts new file mode 100644 index 0000000..3972554 --- /dev/null +++ b/src/app/api/proxy/key/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/proxy/m3u8/route.ts b/src/app/api/proxy/m3u8/route.ts new file mode 100644 index 0000000..dc82cb9 --- /dev/null +++ b/src/app/api/proxy/m3u8/route.ts @@ -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; +} \ No newline at end of file diff --git a/src/app/api/proxy/segment/route.ts b/src/app/api/proxy/segment/route.ts new file mode 100644 index 0000000..76d3075 --- /dev/null +++ b/src/app/api/proxy/segment/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts index debc450..88e97b4 100644 --- a/src/app/api/search/suggestions/route.ts +++ b/src/app/api/search/suggestions/route.ts @@ -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'; diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx new file mode 100644 index 0000000..1b32165 --- /dev/null +++ b/src/app/live/page.tsx @@ -0,0 +1,1070 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */ + +'use client'; + +import Artplayer from 'artplayer'; +import Hls from 'hls.js'; +import { Radio, Tv } from 'lucide-react'; +import { Suspense, useEffect, useRef, useState } from 'react'; + +import { processImageUrl } from '@/lib/utils'; + +import PageLayout from '@/components/PageLayout'; + +// 扩展 HTMLVideoElement 类型以支持 hls 属性 +declare global { + interface HTMLVideoElement { + hls?: any; + } +} + +// 直播频道接口 +interface LiveChannel { + id: string; + tvgId: string; + name: string; + logo: string; + group: string; + url: string; +} + +// 直播源接口 +interface LiveSource { + key: string; + name: string; + url: string; // m3u 地址 + ua?: string; + epg?: string; // 节目单 + from: 'config' | 'custom'; + channelNumber?: number; + disabled?: boolean; +} + +function LivePageClient() { + // ----------------------------------------------------------------------------- + // 状态变量(State) + // ----------------------------------------------------------------------------- + const [loading, setLoading] = useState(true); + const [loadingStage, setLoadingStage] = useState< + 'loading' | 'fetching' | 'ready' + >('loading'); + const [loadingMessage, setLoadingMessage] = useState('正在加载直播源...'); + const [error, setError] = useState(null); + + // 直播源相关 + const [liveSources, setLiveSources] = useState([]); + const [currentSource, setCurrentSource] = useState(null); + const currentSourceRef = useRef(null); + useEffect(() => { + currentSourceRef.current = currentSource; + }, [currentSource]); + + // 频道相关 + const [currentChannels, setCurrentChannels] = useState([]); + const [currentChannel, setCurrentChannel] = useState(null); + + // 播放器相关 + const [videoUrl, setVideoUrl] = useState(''); + const [isVideoLoading, setIsVideoLoading] = useState(false); + + // 切换直播源状态 + const [isSwitchingSource, setIsSwitchingSource] = useState(false); + + // 分组相关 + const [groupedChannels, setGroupedChannels] = useState<{ [key: string]: LiveChannel[] }>({}); + const [selectedGroup, setSelectedGroup] = useState(''); + + // Tab 切换 + const [activeTab, setActiveTab] = useState<'channels' | 'sources'>('channels'); + + // 频道列表收起状态 + const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false); + + // 过滤后的频道列表 + const [filteredChannels, setFilteredChannels] = useState([]); + + // 播放器引用 + const artPlayerRef = useRef(null); + const artRef = useRef(null); + + // 分组标签滚动相关 + const groupContainerRef = useRef(null); + const groupButtonRefs = useRef<(HTMLButtonElement | null)[]>([]); + + // ----------------------------------------------------------------------------- + // 工具函数(Utils) + // ----------------------------------------------------------------------------- + + // 获取直播源列表 + const fetchLiveSources = async () => { + try { + setLoadingStage('fetching'); + setLoadingMessage('正在获取直播源...'); + + // 获取 AdminConfig 中的直播源信息 + const response = await fetch('/api/live/sources'); + if (!response.ok) { + throw new Error('获取直播源失败'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || '获取直播源失败'); + } + + const sources = result.data; + setLiveSources(sources); + + if (sources.length > 0) { + // 默认选中第一个源 + const firstSource = sources[0]; + setCurrentSource(firstSource); + await fetchChannels(firstSource); + } + + setLoadingStage('ready'); + setLoadingMessage('✨ 准备就绪...'); + + setTimeout(() => { + setLoading(false); + }, 1000); + } catch (err) { + console.error('获取直播源失败:', err); + // 不设置错误,而是显示空状态 + setLiveSources([]); + setLoading(false); + } + }; + + // 获取频道列表 + const fetchChannels = async (source: LiveSource) => { + try { + setIsVideoLoading(true); + + // 从 cachedLiveChannels 获取频道信息 + const response = await fetch(`/api/live/channels?source=${source.key}`); + if (!response.ok) { + throw new Error('获取频道列表失败'); + } + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || '获取频道列表失败'); + } + + const channelsData = result.data; + if (!channelsData || channelsData.length === 0) { + // 不抛出错误,而是设置空频道列表 + setCurrentChannels([]); + setGroupedChannels({}); + setFilteredChannels([]); + + // 更新直播源的频道数为 0 + setLiveSources(prevSources => + prevSources.map(s => + s.key === source.key ? { ...s, channelNumber: 0 } : s + ) + ); + + setIsVideoLoading(false); + return; + } + + // 转换频道数据格式 + const channels: LiveChannel[] = channelsData.map((channel: any) => ({ + id: channel.id, + tvgId: channel.tvgId || channel.name, + name: channel.name, + logo: channel.logo, + group: channel.group || '其他', + url: channel.url + })); + + setCurrentChannels(channels); + + // 更新直播源的频道数 + setLiveSources(prevSources => + prevSources.map(s => + s.key === source.key ? { ...s, channelNumber: channels.length } : s + ) + ); + + // 默认选中第一个频道 + if (channels.length > 0) { + setCurrentChannel(channels[0]); + setVideoUrl(channels[0].url); + } + + // 按分组组织频道 + const grouped = channels.reduce((acc, channel) => { + const group = channel.group || '其他'; + if (!acc[group]) { + acc[group] = []; + } + acc[group].push(channel); + return acc; + }, {} as { [key: string]: LiveChannel[] }); + + setGroupedChannels(grouped); + + // 默认选中第一个分组 + const firstGroup = Object.keys(grouped)[0] || ''; + setSelectedGroup(firstGroup); + setFilteredChannels(firstGroup ? grouped[firstGroup] : channels); + + setIsVideoLoading(false); + } catch (err) { + console.error('获取频道列表失败:', err); + // 不设置错误,而是设置空频道列表 + setCurrentChannels([]); + setGroupedChannels({}); + setFilteredChannels([]); + + // 更新直播源的频道数为 0 + setLiveSources(prevSources => + prevSources.map(s => + s.key === source.key ? { ...s, channelNumber: 0 } : s + ) + ); + + setIsVideoLoading(false); + } + }; + + // 切换直播源 + const handleSourceChange = async (source: LiveSource) => { + try { + // 设置切换状态,锁住频道切换器 + setIsSwitchingSource(true); + + setCurrentSource(source); + await fetchChannels(source); + } catch (err) { + console.error('切换直播源失败:', err); + // 不设置错误,保持当前状态 + } finally { + // 切换完成,解锁频道切换器 + setIsSwitchingSource(false); + // 自动切换到频道 tab + setActiveTab('channels'); + } + }; + + // 切换频道 + const handleChannelChange = (channel: LiveChannel) => { + // 如果正在切换直播源,则禁用频道切换 + if (isSwitchingSource) return; + + setCurrentChannel(channel); + setVideoUrl(channel.url); + }; + + // 清理播放器资源的统一函数 + const cleanupPlayer = () => { + if (artPlayerRef.current) { + try { + // 销毁 HLS 实例 + if (artPlayerRef.current.video && artPlayerRef.current.video.hls) { + artPlayerRef.current.video.hls.destroy(); + } + + // 销毁 ArtPlayer 实例 + artPlayerRef.current.destroy(); + artPlayerRef.current = null; + } catch (err) { + console.warn('清理播放器资源时出错:', err); + artPlayerRef.current = null; + } + } + }; + + // 确保视频源正确设置 + const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => { + if (!video || !url) return; + const sources = Array.from(video.getElementsByTagName('source')); + const existed = sources.some((s) => s.src === url); + if (!existed) { + // 移除旧的 source,保持唯一 + sources.forEach((s) => s.remove()); + const sourceEl = document.createElement('source'); + sourceEl.src = url; + video.appendChild(sourceEl); + } + + // 始终允许远程播放(AirPlay / Cast) + video.disableRemotePlayback = false; + // 如果曾经有禁用属性,移除之 + if (video.hasAttribute('disableRemotePlayback')) { + video.removeAttribute('disableRemotePlayback'); + } + }; + + // 切换分组 + const handleGroupChange = (group: string) => { + // 如果正在切换直播源,则禁用分组切换 + if (isSwitchingSource) return; + + setSelectedGroup(group); + const filtered = currentChannels.filter(channel => channel.group === group); + setFilteredChannels(filtered); + }; + + // 初始化 + useEffect(() => { + fetchLiveSources(); + }, []); + + // 当分组切换时,将激活的分组标签滚动到视口中间 + useEffect(() => { + if (!selectedGroup || !groupContainerRef.current) return; + + const groupKeys = Object.keys(groupedChannels); + const groupIndex = groupKeys.indexOf(selectedGroup); + if (groupIndex === -1) return; + + const btn = groupButtonRefs.current[groupIndex]; + const container = groupContainerRef.current; + if (btn && container) { + // 手动计算滚动位置,只滚动分组标签容器 + const containerRect = container.getBoundingClientRect(); + const btnRect = btn.getBoundingClientRect(); + const scrollLeft = container.scrollLeft; + + // 计算按钮相对于容器的位置 + const btnLeft = btnRect.left - containerRect.left + scrollLeft; + const btnWidth = btnRect.width; + const containerWidth = containerRect.width; + + // 计算目标滚动位置,使按钮居中 + const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2; + + // 平滑滚动到目标位置 + container.scrollTo({ + left: targetScrollLeft, + behavior: 'smooth', + }); + } + }, [selectedGroup, groupedChannels]); + + class CustomHlsJsLoader extends Hls.DefaultConfig.loader { + constructor(config: any) { + super(config); + const load = this.load.bind(this); + this.load = function (context: any, config: any, callbacks: any) { + // 所有的请求都带一个 source 参数 + try { + const url = new URL(context.url); + url.searchParams.set('moontv-source', currentSourceRef.current?.key || ''); + context.url = url.toString(); + } catch (error) { + // ignore + } + // 拦截manifest和level请求 + if ( + (context as any).type === 'manifest' || + (context as any).type === 'level' + ) { + // 判断是否浏览器直连 + const isLiveDirectConnectStr = localStorage.getItem('liveDirectConnect'); + const isLiveDirectConnect = isLiveDirectConnectStr === 'true'; + if (isLiveDirectConnect) { + // 浏览器直连,使用 URL 对象处理参数 + try { + const url = new URL(context.url); + url.searchParams.set('allowCORS', 'true'); + context.url = url.toString(); + } catch (error) { + // 如果 URL 解析失败,回退到字符串拼接 + context.url = context.url + '&allowCORS=true'; + } + } + } + // 执行原始load方法 + load(context, config, callbacks); + }; + } + } + + // 播放器初始化 + useEffect(() => { + if ( + !Artplayer || + !Hls || + !videoUrl || + !artRef.current || + !currentChannel + ) { + return; + } + + console.log('视频URL:', videoUrl); + + // 销毁之前的播放器实例并创建新的 + if (artPlayerRef.current) { + cleanupPlayer(); + } + + try { + // 创建新的播放器实例 + Artplayer.USE_RAF = true; + + artPlayerRef.current = new Artplayer({ + container: artRef.current, + url: videoUrl.toLowerCase().endsWith('.mp4') ? videoUrl : `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`, + poster: currentChannel.logo, + volume: 0.7, + isLive: true, // 设置为直播模式 + muted: false, + autoplay: true, + pip: true, + autoSize: false, + autoMini: false, + screenshot: false, + setting: false, + loop: false, + flip: false, + playbackRate: false, + aspectRatio: false, + fullscreen: true, + fullscreenWeb: true, + subtitleOffset: false, + miniProgressBar: false, + mutex: true, + playsInline: true, + autoPlayback: false, + airplay: true, + theme: '#22c55e', + lang: 'zh-cn', + hotkey: false, + fastForward: false, // 直播不需要快进 + autoOrientation: true, + lock: true, + moreVideoAttr: { + crossOrigin: 'anonymous', + preload: 'metadata', + }, + type: videoUrl.toLowerCase().endsWith('.mp4') ? 'mp4' : 'm3u8', + // HLS 支持配置 + customType: { + m3u8: function (video: HTMLVideoElement, url: string) { + if (!Hls) { + console.error('HLS.js 未加载'); + return; + } + + if (video.hls) { + video.hls.destroy(); + } + const hls = new Hls({ + debug: false, + enableWorker: true, + lowLatencyMode: true, + maxBufferLength: 30, + backBufferLength: 30, + maxBufferSize: 60 * 1000 * 1000, + loader: CustomHlsJsLoader, + }); + + hls.loadSource(url); + hls.attachMedia(video); + video.hls = hls; + + hls.on(Hls.Events.ERROR, function (event: any, data: any) { + console.error('HLS Error:', event, data); + + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + hls.recoverMediaError(); + break; + default: + hls.destroy(); + break; + } + } + }); + }, + }, + icons: { + loading: + '', + }, + }); + + // 监听播放器事件 + artPlayerRef.current.on('ready', () => { + setError(null); + setIsVideoLoading(false); + + }); + + artPlayerRef.current.on('loadstart', () => { + setIsVideoLoading(true); + }); + + artPlayerRef.current.on('loadeddata', () => { + setIsVideoLoading(false); + }); + + artPlayerRef.current.on('canplay', () => { + setIsVideoLoading(false); + }); + + artPlayerRef.current.on('waiting', () => { + setIsVideoLoading(true); + }); + + artPlayerRef.current.on('error', (err: any) => { + console.error('播放器错误:', err); + }); + + if (artPlayerRef.current?.video) { + const finalUrl = videoUrl.toLowerCase().endsWith('.mp4') ? videoUrl : `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}`; + ensureVideoSource( + artPlayerRef.current.video as HTMLVideoElement, + finalUrl + ); + } + + } catch (err) { + console.error('创建播放器失败:', err); + // 不设置错误,只记录日志 + } + }, [Artplayer, Hls, videoUrl, currentChannel, loading]); + + // 清理播放器资源 + useEffect(() => { + return () => { + cleanupPlayer(); + }; + }, []); + + // 全局快捷键处理 + useEffect(() => { + const handleKeyboardShortcuts = (e: KeyboardEvent) => { + // 忽略输入框中的按键事件 + if ( + (e.target as HTMLElement).tagName === 'INPUT' || + (e.target as HTMLElement).tagName === 'TEXTAREA' + ) + return; + + // 上箭头 = 音量+ + if (e.key === 'ArrowUp') { + if (artPlayerRef.current && artPlayerRef.current.volume < 1) { + artPlayerRef.current.volume = + Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10; + artPlayerRef.current.notice.show = `音量: ${Math.round( + artPlayerRef.current.volume * 100 + )}`; + e.preventDefault(); + } + } + + // 下箭头 = 音量- + if (e.key === 'ArrowDown') { + if (artPlayerRef.current && artPlayerRef.current.volume > 0) { + artPlayerRef.current.volume = + Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10; + artPlayerRef.current.notice.show = `音量: ${Math.round( + artPlayerRef.current.volume * 100 + )}`; + e.preventDefault(); + } + } + + // 空格 = 播放/暂停 + if (e.key === ' ') { + if (artPlayerRef.current) { + artPlayerRef.current.toggle(); + e.preventDefault(); + } + } + + // f 键 = 切换全屏 + if (e.key === 'f' || e.key === 'F') { + if (artPlayerRef.current) { + artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen; + e.preventDefault(); + } + } + }; + + document.addEventListener('keydown', handleKeyboardShortcuts); + return () => { + document.removeEventListener('keydown', handleKeyboardShortcuts); + }; + }, []); + + if (loading) { + return ( + +
+
+ {/* 动画直播图标 */} +
+
+
📺
+ {/* 旋转光环 */} +
+
+ + {/* 浮动粒子效果 */} +
+
+
+
+
+
+ + {/* 进度指示器 */} +
+
+
+
+
+
+ + {/* 进度条 */} +
+
+
+
+ + {/* 加载消息 */} +
+

+ {loadingMessage} +

+
+
+
+
+ ); + } + + if (error) { + return ( + +
+
+ {/* 错误图标 */} +
+
+
😵
+ {/* 脉冲效果 */} +
+
+
+ + {/* 错误信息 */} +
+

+ 哎呀,出现了一些问题 +

+
+

+ {error} +

+
+

+ 请检查网络连接或尝试刷新页面 +

+
+ + {/* 操作按钮 */} +
+ +
+
+
+
+ ); + } + + return ( + +
+ {/* 第一行:页面标题 */} +
+

+ +
+
+ {currentSource?.name} + {currentSource && currentChannel && ( + + {` > ${currentChannel.name}`} + + )} + {currentSource && !currentChannel && ( + + {` > ${currentSource.name}`} + + )} +
+
+

+
+ + {/* 第二行:播放器和频道列表 */} +
+ {/* 折叠控制 - 仅在 lg 及以上屏幕显示 */} +
+ +
+ +
+ {/* 播放器 */} +
+
+
+ + {/* 视频加载蒙层 */} + {isVideoLoading && ( +
+
+
+
+
📺
+
+
+
+
+

+ 🔄 IPTV 加载中... +

+
+
+
+ )} +
+
+ + {/* 频道列表 */} +
+
+ {/* 主要的 Tab 切换 */} +
+
setActiveTab('channels')} + className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium + ${activeTab === 'channels' + ? 'text-green-600 dark:text-green-400' + : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' + } + `.trim()} + > + 频道 +
+
setActiveTab('sources')} + className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium + ${activeTab === 'sources' + ? 'text-green-600 dark:text-green-400' + : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' + } + `.trim()} + > + 直播源 +
+
+ + {/* 频道 Tab 内容 */} + {activeTab === 'channels' && ( + <> + {/* 分组标签 */} +
+ {/* 切换状态提示 */} + {isSwitchingSource && ( +
+
+ 切换直播源中... +
+ )} + +
{ + // 鼠标进入分组标签区域时,添加滚轮事件监听 + const container = groupContainerRef.current; + if (container) { + const handleWheel = (e: WheelEvent) => { + if (container.scrollWidth > container.clientWidth) { + e.preventDefault(); + container.scrollLeft += e.deltaY; + } + }; + container.addEventListener('wheel', handleWheel, { passive: false }); + // 将事件处理器存储在容器上,以便后续移除 + (container as any)._wheelHandler = handleWheel; + } + }} + onMouseLeave={() => { + // 鼠标离开分组标签区域时,移除滚轮事件监听 + const container = groupContainerRef.current; + if (container && (container as any)._wheelHandler) { + container.removeEventListener('wheel', (container as any)._wheelHandler); + delete (container as any)._wheelHandler; + } + }} + > +
+ {Object.keys(groupedChannels).map((group, index) => ( + + ))} +
+
+
+ + {/* 频道列表 */} +
+ {filteredChannels.length > 0 ? ( + filteredChannels.map(channel => { + const isActive = channel.id === currentChannel?.id; + return ( + + ); + }) + ) : ( +
+
+ +
+

+ 暂无可用频道 +

+

+ 请选择其他直播源或稍后再试 +

+
+ )} +
+ + )} + + {/* 直播源 Tab 内容 */} + {activeTab === 'sources' && ( +
+
+ {liveSources.length > 0 ? ( + liveSources.map((source) => { + const isCurrentSource = source.key === currentSource?.key; + return ( +
!isCurrentSource && handleSourceChange(source)} + className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative + ${isCurrentSource + ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border' + : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer' + }`.trim()} + > + {/* 图标 */} +
+ +
+ + {/* 信息 */} +
+
+ {source.name} +
+
+ {!source.channelNumber || source.channelNumber === 0 ? '-' : `${source.channelNumber} 个频道`} +
+
+ + {/* 当前标识 */} + {isCurrentSource && ( +
+ )} +
+ ); + }) + ) : ( +
+
+ +
+

+ 暂无可用直播源 +

+

+ 请检查网络连接或联系管理员添加直播源 +

+
+ )} +
+
+ )} +
+
+
+
+ + {/* 当前频道信息 */} + {currentChannel && ( +
+
+
+ {currentChannel.logo ? ( + {currentChannel.name} + ) : ( + + )} +
+
+

+ {currentChannel.name} +

+

+ {currentSource?.name} • {currentChannel.group} +

+
+
+
+ )} +
+
+ ); +} + +export default function LivePage() { + return ( + Loading...}> + + + ); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 962c156..c63742a 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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' /> diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index fb3b6f7..3932ec3 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -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(() => { diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 21fa657..c93ca57 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -14,7 +14,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => { return (
{/* 移动端头部 */} - + {/* 主要布局容器 */}
@@ -26,7 +26,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => { {/* 主内容区域 */}
{/* 桌面端左上角返回按钮 */} - {['/play'].includes(activePath) && ( + {['/play', '/live'].includes(activePath) && (
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 23b7f87..04f006c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(() => { diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index f122687..0fb4bba 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -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 = () => {
+ + {/* 直播视频浏览器直连 */} +
+
+

+ IPTV 视频浏览器直连 +

+

+ 开启 IPTV 视频浏览器直连时,需要自备 Allow CORS 插件 +

+
+ +
{/* 底部说明 */} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 84d9fe5..1376745 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -672,7 +672,7 @@ const VideoCard = forwardRef(function VideoCard {/* 年份徽章 */} {config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
[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(); + adminConfig.LiveConfig = adminConfig.LiveConfig.filter((live) => { + if (seenLiveKeys.has(live.key)) { + return false; + } + seenLiveKeys.add(live.key); + return true; + }); + return adminConfig; } diff --git a/src/lib/live.ts b/src/lib/live.ts new file mode 100644 index 0000000..08ab2c5 --- /dev/null +++ b/src/lib/live.ts @@ -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 { + 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 { + 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 + '/'; + } +} \ No newline at end of file