网络直播增加bilibili
This commit is contained in:
@@ -10830,6 +10830,7 @@ const WebLiveConfig = ({
|
||||
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'
|
||||
>
|
||||
<option value='huya'>虎牙</option>
|
||||
<option value='bilibili'>哔哩哔哩</option>
|
||||
</select>
|
||||
<input
|
||||
type='text'
|
||||
@@ -10879,6 +10880,7 @@ const WebLiveConfig = ({
|
||||
className='w-full 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'
|
||||
>
|
||||
<option value='huya'>虎牙</option>
|
||||
<option value='bilibili'>哔哩哔哩</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -10920,9 +10922,9 @@ const WebLiveConfig = ({
|
||||
<tr key={source.key} className='hover:bg-gray-50 dark:hover:bg-gray-800'>
|
||||
<td className='px-3 sm:px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>
|
||||
<div>{source.name}</div>
|
||||
<div className='sm:hidden text-xs text-gray-500 dark:text-gray-400 mt-1'>{source.platform === 'huya' ? '虎牙' : source.platform} · {source.roomId}</div>
|
||||
<div className='sm:hidden text-xs text-gray-500 dark:text-gray-400 mt-1'>{source.platform === 'huya' ? '虎牙' : source.platform === 'bilibili' ? '哔哩哔哩' : source.platform} · {source.roomId}</div>
|
||||
</td>
|
||||
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.platform === 'huya' ? '虎牙' : source.platform}</td>
|
||||
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.platform === 'huya' ? '虎牙' : source.platform === 'bilibili' ? '哔哩哔哩' : source.platform}</td>
|
||||
<td className='hidden sm:table-cell px-6 py-4 text-sm text-gray-900 dark:text-gray-100'>{source.roomId}</td>
|
||||
<td className='px-3 sm:px-6 py-4 whitespace-nowrap'>
|
||||
<span className={`px-2 py-1 text-xs rounded-full whitespace-nowrap ${!source.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'}`}>
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
function getBaseUrl(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split('/');
|
||||
pathParts.pop();
|
||||
return `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`;
|
||||
}
|
||||
|
||||
function processM3u8Content(content: string, baseUrl: string): string {
|
||||
const lines = content.split('\n');
|
||||
const processedLines = lines.map(line => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 跳过注释行和空行
|
||||
if (trimmedLine.startsWith('#') || trimmedLine === '') {
|
||||
return line;
|
||||
}
|
||||
|
||||
// 如果已经是完整URL,不处理
|
||||
if (trimmedLine.startsWith('http://') || trimmedLine.startsWith('https://')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
// 处理相对路径
|
||||
if (trimmedLine.startsWith('/')) {
|
||||
// 绝对路径(相对于域名)
|
||||
const urlObj = new URL(baseUrl);
|
||||
return `${urlObj.protocol}//${urlObj.host}${trimmedLine}`;
|
||||
} else {
|
||||
// 相对路径
|
||||
return `${baseUrl}/${trimmedLine}`;
|
||||
}
|
||||
});
|
||||
|
||||
return processedLines.join('\n');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -9,10 +45,16 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: '缺少URL参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 根据URL判断Referer
|
||||
let referer = 'https://www.huya.com/';
|
||||
if (url.includes('bilivideo.com') || url.includes('bilibili.com')) {
|
||||
referer = 'https://live.bilibili.com/';
|
||||
}
|
||||
|
||||
const streamRes = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://www.huya.com/'
|
||||
'Referer': referer
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,9 +62,32 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: '无法获取直播流' }, { status: 404 });
|
||||
}
|
||||
|
||||
const contentType = streamRes.headers.get('Content-Type') || '';
|
||||
|
||||
// 检测是否为m3u8文件
|
||||
const isM3u8 = url.endsWith('.m3u8') ||
|
||||
contentType.includes('application/vnd.apple.mpegurl') ||
|
||||
contentType.includes('application/x-mpegURL');
|
||||
|
||||
if (isM3u8) {
|
||||
// 读取m3u8内容
|
||||
const content = await streamRes.text();
|
||||
const baseUrl = getBaseUrl(url);
|
||||
const processedContent = processM3u8Content(content, baseUrl);
|
||||
|
||||
return new NextResponse(processedContent, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 非m3u8文件,直接返回流
|
||||
return new NextResponse(streamRes.body, {
|
||||
headers: {
|
||||
'Content-Type': streamRes.headers.get('Content-Type') || 'application/octet-stream',
|
||||
'Content-Type': contentType || 'application/octet-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
|
||||
@@ -24,6 +24,79 @@ function getAntiCode(oldAntiCode: string, streamName: string): string {
|
||||
return `wsSecret=${wsSecretMd5}&wsTime=${wsTime}&seqid=${seqId}&ctype=${urlQuery.get('ctype')}&ver=1&fs=${urlQuery.get('fs')}&uuid=${initUuid}&u=${uid}&t=${paramsT}&sv=${sdkVersion}&sdk_sid=${sdkSid}&codec=264`;
|
||||
}
|
||||
|
||||
async function getBilibiliStream(roomId: string) {
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://live.bilibili.com/'
|
||||
};
|
||||
|
||||
// 获取房间初始化信息
|
||||
const roomInitRes = await fetch(`https://api.live.bilibili.com/room/v1/Room/room_init?id=${roomId}`, {
|
||||
headers
|
||||
});
|
||||
const roomInitData = await roomInitRes.json();
|
||||
|
||||
if (roomInitData.code !== 0) {
|
||||
throw new Error(roomInitData.message || '获取房间信息失败');
|
||||
}
|
||||
|
||||
const roomData = roomInitData.data;
|
||||
const realRoomId = roomData.room_id;
|
||||
const liveStatus = roomData.live_status; // 0=未开播, 1=直播中, 2=轮播
|
||||
|
||||
if (liveStatus !== 1) {
|
||||
throw new Error('直播未开启');
|
||||
}
|
||||
|
||||
// 获取播放地址 (原画质量 qn=10000)
|
||||
const playInfoRes = await fetch(
|
||||
`https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id=${realRoomId}&protocol=0,1&format=0,1,2&codec=0,1&qn=10000&platform=web&ptype=8`,
|
||||
{ headers }
|
||||
);
|
||||
const playInfoData = await playInfoRes.json();
|
||||
|
||||
if (playInfoData.code !== 0) {
|
||||
throw new Error(playInfoData.message || '获取播放信息失败');
|
||||
}
|
||||
|
||||
const playurl = playInfoData.data?.playurl_info?.playurl;
|
||||
if (!playurl) {
|
||||
throw new Error('未找到播放地址');
|
||||
}
|
||||
|
||||
// 提取m3u8地址
|
||||
let m3u8Url = '';
|
||||
const streamList = playurl.stream || [];
|
||||
|
||||
for (const stream of streamList) {
|
||||
const formatList = stream.format || [];
|
||||
for (const fmt of formatList) {
|
||||
if (fmt.format_name === 'ts') {
|
||||
const codecList = fmt.codec || [];
|
||||
for (const codec of codecList) {
|
||||
const urlInfoList = codec.url_info || [];
|
||||
const baseUrl = codec.base_url || '';
|
||||
|
||||
if (urlInfoList.length > 0 && baseUrl) {
|
||||
const host = urlInfoList[0].host || '';
|
||||
const extra = urlInfoList[0].extra || '';
|
||||
m3u8Url = `${host}${baseUrl}${extra}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (m3u8Url) break;
|
||||
}
|
||||
}
|
||||
if (m3u8Url) break;
|
||||
}
|
||||
|
||||
if (!m3u8Url) {
|
||||
throw new Error('未找到m3u8地址');
|
||||
}
|
||||
|
||||
return m3u8Url;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -65,6 +138,16 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'bilibili') {
|
||||
const streamUrl = await getBilibiliStream(roomId);
|
||||
const proxyUrl = `/api/web-live/proxy/proxy.m3u8?url=${encodeURIComponent(streamUrl)}`;
|
||||
|
||||
return NextResponse.json({
|
||||
url: proxyUrl,
|
||||
originalUrl: streamUrl
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '不支持的平台' }, { status: 400 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function WebLivePage() {
|
||||
const [isChannelListCollapsed, setIsChannelListCollapsed] = useState(false);
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const meta = document.createElement('meta');
|
||||
@@ -137,11 +138,30 @@ export default function WebLivePage() {
|
||||
if (source.platform === 'huya') {
|
||||
return `https://huya.com/${source.roomId}`;
|
||||
}
|
||||
if (source.platform === 'bilibili') {
|
||||
return `https://live.bilibili.com/${source.roomId}`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const platforms = Array.from(new Set(sources.map(s => s.platform)));
|
||||
|
||||
// 根据选中的平台筛选房间
|
||||
const filteredSources = selectedPlatform
|
||||
? sources.filter(s => s.platform === selectedPlatform)
|
||||
: sources;
|
||||
|
||||
// 处理平台点击
|
||||
const handlePlatformClick = (platform: string) => {
|
||||
setSelectedPlatform(platform);
|
||||
setActiveTab('rooms');
|
||||
};
|
||||
|
||||
// 清除平台筛选
|
||||
const clearPlatformFilter = () => {
|
||||
setSelectedPlatform(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout activePath='/web-live'>
|
||||
@@ -477,8 +497,24 @@ export default function WebLivePage() {
|
||||
|
||||
{activeTab === 'rooms' && (
|
||||
<div className='flex-1 overflow-y-auto space-y-2 pb-4 mt-4'>
|
||||
{sources.length > 0 ? (
|
||||
sources.map((source) => {
|
||||
{selectedPlatform && (
|
||||
<div className='mb-3 flex items-center justify-between px-2 py-2 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-xs text-green-700 dark:text-green-300'>筛选平台:</span>
|
||||
<span className='text-sm font-medium text-green-800 dark:text-green-200'>
|
||||
{selectedPlatform === 'huya' ? '虎牙' : selectedPlatform === 'bilibili' ? '哔哩哔哩' : selectedPlatform}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearPlatformFilter}
|
||||
className='text-xs text-green-700 dark:text-green-300 hover:text-green-900 dark:hover:text-green-100 underline'
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{filteredSources.length > 0 ? (
|
||||
filteredSources.map((source) => {
|
||||
const isActive = source.key === currentSource?.key;
|
||||
return (
|
||||
<button
|
||||
@@ -503,7 +539,9 @@ export default function WebLivePage() {
|
||||
<div className='w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4'>
|
||||
<Radio className='w-8 h-8 text-gray-400 dark:text-gray-600' />
|
||||
</div>
|
||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>暂无可用房间</p>
|
||||
<p className='text-gray-500 dark:text-gray-400 font-medium'>
|
||||
{selectedPlatform ? '该平台暂无可用房间' : '暂无可用房间'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -514,23 +552,29 @@ export default function WebLivePage() {
|
||||
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
|
||||
{platforms.length > 0 ? (
|
||||
platforms.map((platform) => (
|
||||
<div key={platform} className='flex items-start gap-3 px-2 py-3 rounded-lg bg-gray-200/50 dark:bg-white/10'>
|
||||
<button
|
||||
key={platform}
|
||||
onClick={() => handlePlatformClick(platform)}
|
||||
className='w-full flex items-start gap-3 px-2 py-3 rounded-lg bg-gray-200/50 dark:bg-white/10 hover:bg-gray-300/50 dark:hover:bg-white/20 transition-all duration-200 cursor-pointer'
|
||||
>
|
||||
<div className='w-12 h-12 bg-gray-200 dark:bg-gray-600 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||
{platform === 'huya' ? (
|
||||
<img src='https://hd.huya.com/favicon.ico' alt='虎牙' className='w-8 h-8' />
|
||||
) : platform === 'bilibili' ? (
|
||||
<img src='https://www.bilibili.com/favicon.ico' alt='哔哩哔哩' className='w-8 h-8' />
|
||||
) : (
|
||||
<Radio className='w-6 h-6 text-gray-500' />
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex-1 min-w-0 text-left'>
|
||||
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
|
||||
{platform === 'huya' ? '虎牙' : platform}
|
||||
{platform === 'huya' ? '虎牙' : platform === 'bilibili' ? '哔哩哔哩' : platform}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
{sources.filter(s => s.platform === platform).length} 个房间
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
|
||||
Reference in New Issue
Block a user