百度网盘在线播放
This commit is contained in:
@@ -3708,6 +3708,8 @@ const NetDiskConfigComponent = ({
|
||||
const [openListTempPath, setOpenListTempPath] = useState('/');
|
||||
const [mobileEnabled, setMobileEnabled] = useState(false);
|
||||
const [mobileAuthorization, setMobileAuthorization] = useState('');
|
||||
const [baiduEnabled, setBaiduEnabled] = useState(false);
|
||||
const [baiduCookie, setBaiduCookie] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const quark = config?.NetDiskConfig?.Quark;
|
||||
@@ -3719,6 +3721,8 @@ const NetDiskConfigComponent = ({
|
||||
setOpenListTempPath(quark?.OpenListTempPath || '/');
|
||||
setMobileEnabled(mobile?.Enabled || false);
|
||||
setMobileAuthorization(mobile?.Authorization || '');
|
||||
setBaiduEnabled(config?.NetDiskConfig?.Baidu?.Enabled || false);
|
||||
setBaiduCookie(config?.NetDiskConfig?.Baidu?.Cookie || '');
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -3739,6 +3743,10 @@ const NetDiskConfigComponent = ({
|
||||
Enabled: mobileEnabled,
|
||||
Authorization: mobileAuthorization,
|
||||
},
|
||||
Baidu: {
|
||||
Enabled: baiduEnabled,
|
||||
Cookie: baiduCookie,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3809,6 +3817,34 @@ const NetDiskConfigComponent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleValidateBaidu = async () => {
|
||||
await withLoading('validateBaiduNetDisk', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/netdisk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'validate',
|
||||
provider: 'baidu',
|
||||
Baidu: {
|
||||
Cookie: baiduCookie,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '校验失败');
|
||||
}
|
||||
|
||||
showSuccess(data.message || '百度网盘 Cookie 格式正常', showAlert);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '校验失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
@@ -3986,6 +4022,64 @@ const NetDiskConfigComponent = ({
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<summary className='text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer'>
|
||||
百度网盘
|
||||
</summary>
|
||||
<div className='mt-4 space-y-4'>
|
||||
<div className='flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
启用百度网盘
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
开启后,网盘搜索中的百度网盘资源会显示“立即播放”按钮
|
||||
</p>
|
||||
</div>
|
||||
<label className='relative inline-flex items-center cursor-pointer'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={baiduEnabled}
|
||||
onChange={(e) => setBaiduEnabled(e.target.checked)}
|
||||
className='sr-only peer'
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-sky-300 dark:peer-focus:ring-sky-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-sky-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Cookie
|
||||
</label>
|
||||
<textarea
|
||||
value={baiduCookie}
|
||||
onChange={(e) => setBaiduCookie(e.target.value)}
|
||||
disabled={!baiduEnabled}
|
||||
rows={5}
|
||||
placeholder='粘贴百度网盘 Cookie'
|
||||
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 focus:ring-2 focus:ring-sky-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleValidateBaidu}
|
||||
disabled={!baiduEnabled || !baiduCookie || isLoading('validateBaiduNetDisk')}
|
||||
className={buttonStyles.primary}
|
||||
>
|
||||
{isLoading('validateBaiduNetDisk') ? '校验中...' : '校验百度网盘 Cookie'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveNetDisk')}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
{isLoading('saveNetDisk') ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig, setCachedConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { assertBaiduCookieHeaderSafe, normalizeBaiduCookie } from '@/lib/netdisk/baidu.client';
|
||||
import {
|
||||
assertMobileAuthorizationHeaderSafe,
|
||||
normalizeMobileAuthorization,
|
||||
@@ -44,7 +45,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, Quark, Mobile, provider } = body;
|
||||
const { action, Quark, Mobile, Baidu, provider } = body;
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
if (action === 'save') {
|
||||
@@ -52,6 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
const normalizedMobileAuthorization = Mobile?.Authorization
|
||||
? assertMobileAuthorizationHeaderSafe(Mobile.Authorization)
|
||||
: '';
|
||||
const normalizedBaiduCookie = Baidu?.Cookie ? assertBaiduCookieHeaderSafe(Baidu.Cookie) : '';
|
||||
|
||||
adminConfig.NetDiskConfig = adminConfig.NetDiskConfig || {};
|
||||
adminConfig.NetDiskConfig.Quark = {
|
||||
@@ -65,6 +67,10 @@ export async function POST(request: NextRequest) {
|
||||
Enabled: Boolean(Mobile?.Enabled),
|
||||
Authorization: normalizedMobileAuthorization,
|
||||
};
|
||||
adminConfig.NetDiskConfig.Baidu = {
|
||||
Enabled: Boolean(Baidu?.Enabled),
|
||||
Cookie: normalizedBaiduCookie,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
await setCachedConfig(adminConfig);
|
||||
@@ -84,6 +90,16 @@ export async function POST(request: NextRequest) {
|
||||
message: '移动云盘 Authorization 格式正常',
|
||||
});
|
||||
}
|
||||
if (provider === 'baidu') {
|
||||
if (!Baidu?.Cookie) {
|
||||
return NextResponse.json({ error: '请先填写百度网盘 Cookie' }, { status: 400 });
|
||||
}
|
||||
normalizeBaiduCookie(Baidu.Cookie);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '百度网盘 Cookie 格式正常',
|
||||
});
|
||||
}
|
||||
|
||||
if (!Quark?.Cookie) {
|
||||
return NextResponse.json({ error: '请先填写夸克 Cookie' }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { listBaiduShareVideos } from '@/lib/netdisk/baidu.client';
|
||||
import { createBaiduNetdiskSession } from '@/lib/netdisk/baidu-session-cache';
|
||||
import { NETDISK_BAIDU_SOURCE } from '@/lib/netdisk/source';
|
||||
import { hasFeaturePermission } from '@/lib/permissions';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
if (!(await hasFeaturePermission(authInfo.username, 'netdisk_temp_play'))) {
|
||||
return NextResponse.json({ error: '无权限使用临时播放' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { shareUrl, passcode, title } = await request.json();
|
||||
if (!shareUrl) {
|
||||
return NextResponse.json({ error: '分享链接不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const baiduConfig = config.NetDiskConfig?.Baidu;
|
||||
if (!baiduConfig?.Enabled || !baiduConfig.Cookie) {
|
||||
return NextResponse.json({ error: '百度网盘未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await listBaiduShareVideos(shareUrl, baiduConfig.Cookie, passcode || '');
|
||||
const session = createBaiduNetdiskSession({
|
||||
title: title || result.title,
|
||||
shareUrl,
|
||||
passcode,
|
||||
files: result.files,
|
||||
meta: result.meta,
|
||||
cookie: result.cookie,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
source: NETDISK_BAIDU_SOURCE,
|
||||
id: session.id,
|
||||
title: title || result.title,
|
||||
totalFiles: result.files.length,
|
||||
expiresAt: session.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '立即播放失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const episodeIndexRaw = searchParams.get('episodeIndex');
|
||||
const format = searchParams.get('format');
|
||||
if (!id || episodeIndexRaw == null) {
|
||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const episodeIndex = Number.parseInt(episodeIndexRaw, 10);
|
||||
if (!Number.isInteger(episodeIndex) || episodeIndex < 0) {
|
||||
return NextResponse.json({ error: '无效的 episodeIndex' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxyUrl = `/api/netdisk/baidu/proxy?id=${encodeURIComponent(id)}&episodeIndex=${episodeIndex}`;
|
||||
if (format === 'json') {
|
||||
return NextResponse.json({ url: proxyUrl, headers: {} });
|
||||
}
|
||||
return NextResponse.redirect(new URL(proxyUrl, request.url));
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '获取播放地址失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getBaiduDirectPlayUrl } from '@/lib/netdisk/baidu.client';
|
||||
import { resolveBaiduSession } from '@/lib/netdisk/baidu-session-resolver';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo?.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const episodeIndexRaw = searchParams.get('episodeIndex');
|
||||
if (!id || episodeIndexRaw == null) {
|
||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const episodeIndex = Number.parseInt(episodeIndexRaw, 10);
|
||||
if (!Number.isInteger(episodeIndex) || episodeIndex < 0) {
|
||||
return NextResponse.json({ error: '无效的 episodeIndex' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { session, cookie } = await resolveBaiduSession(id);
|
||||
const file = session.files[episodeIndex];
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: '播放文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { url, headers } = await getBaiduDirectPlayUrl(session.meta, file.fid, cookie);
|
||||
const range = request.headers.get('range');
|
||||
const upstream = await fetch(url, {
|
||||
headers: {
|
||||
...headers,
|
||||
Cookie: cookie,
|
||||
...(range ? { Range: range } : {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return NextResponse.json(
|
||||
{ error: `百度网盘视频代理失败 (${upstream.status})` },
|
||||
{ status: upstream.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
const copyHeaders = ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified'];
|
||||
copyHeaders.forEach((name) => {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) responseHeaders.set(name, value);
|
||||
});
|
||||
responseHeaders.set('Cache-Control', 'private, no-store');
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: range && upstream.headers.get('content-range') ? 206 : 200,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '百度网盘代理失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,19 @@ import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||
import { getDetailFromApiV2 } from '@/lib/downstream';
|
||||
import { getProxyToken } from '@/lib/emby-token';
|
||||
import {
|
||||
createBaiduNetdiskSession,
|
||||
getBaiduNetdiskSession,
|
||||
parseBaiduNetdiskId,
|
||||
refreshBaiduNetdiskSession,
|
||||
} from '@/lib/netdisk/baidu-session-cache';
|
||||
import {
|
||||
createMobileNetdiskSession,
|
||||
getMobileNetdiskSession,
|
||||
parseMobileNetdiskId,
|
||||
refreshMobileNetdiskSession,
|
||||
} from '@/lib/netdisk/mobile-session-cache';
|
||||
import { LEGACY_QUARK_TEMP_SOURCE, NETDISK_MOBILE_SOURCE, NETDISK_QUARK_SOURCE, normalizeNetdiskSource } from '@/lib/netdisk/source';
|
||||
import { LEGACY_QUARK_TEMP_SOURCE, NETDISK_BAIDU_SOURCE, NETDISK_MOBILE_SOURCE, NETDISK_QUARK_SOURCE, normalizeNetdiskSource } from '@/lib/netdisk/source';
|
||||
import {
|
||||
executeSavedSourceScript,
|
||||
normalizeScriptDetailResult,
|
||||
@@ -354,6 +360,76 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceCode === NETDISK_BAIDU_SOURCE) {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const baiduConfig = config.NetDiskConfig?.Baidu;
|
||||
if (!baiduConfig?.Enabled || !baiduConfig.Cookie) {
|
||||
throw new Error('百度网盘未配置或未启用');
|
||||
}
|
||||
|
||||
let session = refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id);
|
||||
if (!session) {
|
||||
const payload = parseBaiduNetdiskId(id);
|
||||
const { listBaiduShareVideos } = await import('@/lib/netdisk/baidu.client');
|
||||
const result = await listBaiduShareVideos(payload.shareUrl, baiduConfig.Cookie, payload.passcode || '');
|
||||
session = createBaiduNetdiskSession({
|
||||
title: title || result.title,
|
||||
shareUrl: payload.shareUrl,
|
||||
passcode: payload.passcode,
|
||||
files: result.files,
|
||||
meta: result.meta,
|
||||
cookie: result.cookie,
|
||||
});
|
||||
}
|
||||
if (!session) {
|
||||
throw new Error('百度网盘播放信息恢复失败');
|
||||
}
|
||||
const baiduSession = session;
|
||||
const { parseVideoFileName } = await import('@/lib/video-parser');
|
||||
const parsedFiles = baiduSession.files
|
||||
.map((file, index) => {
|
||||
const parsed = parseVideoFileName(file.name);
|
||||
return {
|
||||
...file,
|
||||
originalIndex: index,
|
||||
sortEpisode: parsed.episode || index + 1,
|
||||
isOVA: parsed.isOVA,
|
||||
displayTitle:
|
||||
parsed.title || (parsed.episode ? `第${parsed.episode}集` : file.name),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.isOVA && !b.isOVA) return 1;
|
||||
if (!a.isOVA && b.isOVA) return -1;
|
||||
return a.sortEpisode !== b.sortEpisode
|
||||
? a.sortEpisode - b.sortEpisode
|
||||
: a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
source: NETDISK_BAIDU_SOURCE,
|
||||
source_name: '百度网盘',
|
||||
id: baiduSession.id,
|
||||
title: title || baiduSession.title,
|
||||
poster: '',
|
||||
year: '',
|
||||
douban_id: 0,
|
||||
desc: `百度网盘分享:${baiduSession.shareUrl}`,
|
||||
episodes: parsedFiles.map((file) => (
|
||||
`/api/netdisk/baidu/play?id=${encodeURIComponent(baiduSession.id)}&episodeIndex=${file.originalIndex}`
|
||||
)),
|
||||
episodes_titles: parsedFiles.map((file) => file.displayTitle),
|
||||
proxyMode: false,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceCode === NETDISK_QUARK_SOURCE || sourceCode === LEGACY_QUARK_TEMP_SOURCE) {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -2504,6 +2504,7 @@ function PlayPageClient() {
|
||||
const isSpecialLazyPlayUrl =
|
||||
isXiaoyaLazyPlayUrl ||
|
||||
newUrl.startsWith('/api/openlist/play') ||
|
||||
newUrl.startsWith('/api/netdisk/baidu/play') ||
|
||||
newUrl.startsWith('/api/source-script/play');
|
||||
|
||||
if (isSpecialLazyPlayUrl) {
|
||||
|
||||
@@ -163,7 +163,13 @@ export default function PansouSearch({
|
||||
const handleNetdiskInstantPlay = async (cloudType: string, link: PansouLink) => {
|
||||
try {
|
||||
setPlayingUrl(link.url);
|
||||
const response = await fetch(cloudType === 'mobile' ? '/api/netdisk/mobile/instant-play' : '/api/netdisk/quark/instant-play', {
|
||||
const instantPlayApi =
|
||||
cloudType === 'mobile'
|
||||
? '/api/netdisk/mobile/instant-play'
|
||||
: cloudType === 'baidu'
|
||||
? '/api/netdisk/baidu/instant-play'
|
||||
: '/api/netdisk/quark/instant-play';
|
||||
const response = await fetch(instantPlayApi, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -181,7 +187,7 @@ export default function PansouSearch({
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/play?source=${encodeURIComponent(data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : 'netdisk-quark'))}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
|
||||
`/play?source=${encodeURIComponent(data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : cloudType === 'baidu' ? 'netdisk-baidu' : 'netdisk-quark'))}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
|
||||
);
|
||||
} catch (err: any) {
|
||||
setToast({
|
||||
@@ -339,7 +345,7 @@ export default function PansouSearch({
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
{(cloudType === 'quark' || cloudType === 'mobile') && (
|
||||
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu') && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleNetdiskInstantPlay(cloudType, link)}
|
||||
|
||||
@@ -158,6 +158,10 @@ export interface AdminConfig {
|
||||
Enabled: boolean;
|
||||
Authorization: string;
|
||||
};
|
||||
Baidu?: {
|
||||
Enabled: boolean;
|
||||
Cookie: string;
|
||||
};
|
||||
};
|
||||
AIConfig?: {
|
||||
Enabled: boolean; // 是否启用AI问片功能
|
||||
|
||||
@@ -676,6 +676,10 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
Enabled: false,
|
||||
Authorization: '',
|
||||
},
|
||||
Baidu: {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -696,6 +700,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
};
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig.Baidu) {
|
||||
adminConfig.NetDiskConfig.Baidu = {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 确保音乐配置存在
|
||||
if (!adminConfig.MusicConfig) {
|
||||
adminConfig.MusicConfig = {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { base58Decode, base58Encode } from '@/lib/utils';
|
||||
|
||||
export interface BaiduNetdiskSessionFile {
|
||||
fid: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface BaiduNetdiskSessionMeta {
|
||||
uk: string;
|
||||
shareid: string;
|
||||
randsk: string;
|
||||
shareId: string;
|
||||
}
|
||||
|
||||
export interface BaiduNetdiskSession {
|
||||
id: string;
|
||||
provider: 'baidu';
|
||||
title: string;
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
files: BaiduNetdiskSessionFile[];
|
||||
meta: BaiduNetdiskSessionMeta;
|
||||
cookie: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
const sessionStore = new Map<string, BaiduNetdiskSession>();
|
||||
|
||||
export function buildBaiduNetdiskId(input: { shareUrl: string; passcode?: string }): string {
|
||||
return base58Encode(
|
||||
JSON.stringify({
|
||||
shareUrl: input.shareUrl,
|
||||
passcode: input.passcode || '',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function parseBaiduNetdiskId(id: string): { shareUrl: string; passcode?: string } {
|
||||
try {
|
||||
const decoded = base58Decode(id);
|
||||
const parsed = JSON.parse(decoded);
|
||||
if (!parsed?.shareUrl || typeof parsed.shareUrl !== 'string') {
|
||||
throw new Error('invalid baidu netdisk id');
|
||||
}
|
||||
return {
|
||||
shareUrl: parsed.shareUrl,
|
||||
passcode: typeof parsed.passcode === 'string' ? parsed.passcode : '',
|
||||
};
|
||||
} catch {
|
||||
throw new Error('无效的百度网盘播放 ID');
|
||||
}
|
||||
}
|
||||
|
||||
function pruneExpiredSessions() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of Array.from(sessionStore.entries())) {
|
||||
if (value.expiresAt <= now) {
|
||||
sessionStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBaiduNetdiskSession(input: {
|
||||
title: string;
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
files: BaiduNetdiskSessionFile[];
|
||||
meta: BaiduNetdiskSessionMeta;
|
||||
cookie: string;
|
||||
}): BaiduNetdiskSession {
|
||||
pruneExpiredSessions();
|
||||
const now = Date.now();
|
||||
const id = buildBaiduNetdiskId({ shareUrl: input.shareUrl, passcode: input.passcode });
|
||||
const session: BaiduNetdiskSession = {
|
||||
id,
|
||||
provider: 'baidu',
|
||||
title: input.title,
|
||||
shareUrl: input.shareUrl,
|
||||
passcode: input.passcode,
|
||||
files: input.files,
|
||||
meta: input.meta,
|
||||
cookie: input.cookie,
|
||||
createdAt: now,
|
||||
expiresAt: now + TTL_MS,
|
||||
};
|
||||
sessionStore.set(id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getBaiduNetdiskSession(id: string): BaiduNetdiskSession | null {
|
||||
pruneExpiredSessions();
|
||||
const session = sessionStore.get(id);
|
||||
if (!session) return null;
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
sessionStore.delete(id);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function refreshBaiduNetdiskSession(id: string): BaiduNetdiskSession | null {
|
||||
const session = getBaiduNetdiskSession(id);
|
||||
if (!session) return null;
|
||||
session.expiresAt = Date.now() + TTL_MS;
|
||||
sessionStore.set(id, session);
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
import { listBaiduShareVideos } from './baidu.client';
|
||||
import {
|
||||
createBaiduNetdiskSession,
|
||||
getBaiduNetdiskSession,
|
||||
parseBaiduNetdiskId,
|
||||
refreshBaiduNetdiskSession,
|
||||
} from './baidu-session-cache';
|
||||
|
||||
export async function resolveBaiduSession(id: string) {
|
||||
const config = await getConfig();
|
||||
const baiduConfig = config.NetDiskConfig?.Baidu;
|
||||
if (!baiduConfig?.Enabled || !baiduConfig.Cookie) {
|
||||
throw new Error('百度网盘未配置或未启用');
|
||||
}
|
||||
|
||||
let session = refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id);
|
||||
if (!session) {
|
||||
const payload = parseBaiduNetdiskId(id);
|
||||
const result = await listBaiduShareVideos(payload.shareUrl, baiduConfig.Cookie, payload.passcode || '');
|
||||
session = createBaiduNetdiskSession({
|
||||
title: result.title,
|
||||
shareUrl: payload.shareUrl,
|
||||
passcode: payload.passcode,
|
||||
files: result.files,
|
||||
meta: result.meta,
|
||||
cookie: result.cookie,
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error('百度网盘播放信息恢复失败');
|
||||
}
|
||||
|
||||
return { session, cookie: session.cookie || baiduConfig.Cookie };
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import type { BaiduNetdiskSessionFile, BaiduNetdiskSessionMeta } from './baidu-session-cache';
|
||||
|
||||
export interface BaiduShareListResult {
|
||||
title: string;
|
||||
files: BaiduNetdiskSessionFile[];
|
||||
meta: BaiduNetdiskSessionMeta;
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://pan.baidu.com/';
|
||||
const VIDEO_EXTS = [
|
||||
'.mp4', '.mkv', '.avi', '.rmvb', '.mov', '.flv', '.wmv', '.webm', '.3gp', '.mpeg', '.mpg', '.ts', '.mts', '.m2ts', '.vob', '.divx', '.xvid', '.m4v', '.ogv', '.f4v', '.rm', '.asf', '.dat', '.dv', '.m2v',
|
||||
];
|
||||
|
||||
function sha1(value: string) {
|
||||
return createHash('sha1').update(value).digest('hex');
|
||||
}
|
||||
|
||||
export function normalizeBaiduCookie(cookie: string): string {
|
||||
return cookie.replace(/;/g, ';').replace(/:/g, ':').replace(/,/g, ',').trim();
|
||||
}
|
||||
|
||||
export function assertBaiduCookieHeaderSafe(cookie: string): string {
|
||||
const normalized = normalizeBaiduCookie(cookie);
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
if (normalized.charCodeAt(i) > 255) {
|
||||
throw new Error('百度网盘 Cookie 含有非法字符,请确认没有中文标点、中文空格或说明文字');
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function parseBaiduShareUrl(url: string, passcode = ''): { shareId: string; sharePwd: string } {
|
||||
const decoded = decodeURIComponent(url).replace(/\s+/g, '');
|
||||
const match = decoded.match(/pan\.baidu\.com\/(s\/|wap\/init\?surl=)([^?&#]+)/);
|
||||
if (!match) {
|
||||
throw new Error('无法解析百度网盘分享链接');
|
||||
}
|
||||
const shareId = match[2].replace(/^1+/, '').split('?')[0].split('#')[0];
|
||||
const pwdMatch = decoded.match(/(提取码|密码|pwd)=([^&\s]{4})/i);
|
||||
return { shareId, sharePwd: passcode || pwdMatch?.[2] || '' };
|
||||
}
|
||||
|
||||
function getBaseHeaders(cookie: string): HeadersInit {
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
'Accept-Encoding': 'gzip',
|
||||
Referer: 'https://pan.baidu.com',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: assertBaiduCookieHeaderSafe(cookie),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeCookie(cookie: string, key: string, value: string): string {
|
||||
let next = cookie.replace(new RegExp(`${key}=[^;]*;?\\s*`, 'g'), '');
|
||||
if (next.length > 0 && !next.trim().endsWith(';')) next += '; ';
|
||||
next += `${key}=${value}`;
|
||||
return next;
|
||||
}
|
||||
|
||||
async function requestApi(
|
||||
path: string,
|
||||
{
|
||||
cookie,
|
||||
data = {},
|
||||
method = 'post',
|
||||
extraHeaders = {},
|
||||
retry = 2,
|
||||
}: {
|
||||
cookie: string;
|
||||
data?: Record<string, any>;
|
||||
method?: 'get' | 'post';
|
||||
extraHeaders?: Record<string, string>;
|
||||
retry?: number;
|
||||
}
|
||||
): Promise<any> {
|
||||
const headers = { ...getBaseHeaders(cookie), ...extraHeaders };
|
||||
const objectToQuery = (obj: Record<string, any>) =>
|
||||
Object.entries(obj)
|
||||
.filter(([, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||
.join('&');
|
||||
|
||||
try {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const response = await fetch(method === 'get' ? `${url}${objectToQuery(data) ? `?${objectToQuery(data)}` : ''}` : url, {
|
||||
method: method.toUpperCase(),
|
||||
headers,
|
||||
body: method === 'post' ? objectToQuery(data) : undefined,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`百度网盘接口返回异常:${text.slice(0, 200)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (retry > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, (3 - retry) * 1000));
|
||||
return requestApi(path, { cookie, data, method, extraHeaders, retry: retry - 1 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getUid(cookie: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://mbd.baidu.com/userx/v1/info/get?appname=baiduboxapp&fields=%5B%22bg_image%22,%22member%22,%22uid%22,%22avatar%22,%22avatar_member%22%5D&client&clientfrom&lang=zh-cn&tpl&ttt',
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
Cookie: assertBaiduCookieHeaderSafe(cookie),
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
return data?.data?.fields?.uid || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyShare(share: { shareId: string; sharePwd: string }, cookie: string) {
|
||||
const result = await requestApi(`share/verify?t=${Date.now()}&surl=${share.shareId}`, {
|
||||
cookie,
|
||||
data: { pwd: share.sharePwd || '' },
|
||||
method: 'post',
|
||||
});
|
||||
if (result?.errno !== 0) {
|
||||
throw new Error(result?.errmsg || result?.show_msg || '验证百度网盘分享失败');
|
||||
}
|
||||
const nextCookie = result?.randsk ? mergeCookie(cookie, 'BDCLND', result.randsk) : cookie;
|
||||
return { result, cookie: nextCookie };
|
||||
}
|
||||
|
||||
async function getShareToken(share: { shareId: string; sharePwd: string }, cookie: string) {
|
||||
const verified = await verifyShare(share, cookie);
|
||||
const listData = await requestApi('share/list', {
|
||||
cookie: verified.cookie,
|
||||
data: {
|
||||
shorturl: share.shareId,
|
||||
root: 1,
|
||||
page: 1,
|
||||
num: 100,
|
||||
},
|
||||
method: 'get',
|
||||
});
|
||||
if (listData?.errno !== 0) {
|
||||
throw new Error(listData?.errmsg || listData?.show_msg || '获取百度网盘文件列表失败');
|
||||
}
|
||||
return {
|
||||
cookie: verified.cookie,
|
||||
meta: {
|
||||
uk: String(listData.uk || listData.share_uk || ''),
|
||||
shareid: String(listData.share_id || verified.result?.share_id || ''),
|
||||
randsk: String(verified.result?.randsk || ''),
|
||||
shareId: share.shareId,
|
||||
},
|
||||
rootList: Array.isArray(listData.list) ? listData.list : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function listShareDirectory(
|
||||
cookie: string,
|
||||
meta: BaiduNetdiskSessionMeta,
|
||||
dirPath: string,
|
||||
dirFsId: string
|
||||
): Promise<any[]> {
|
||||
const shareDir = `/sharelink${meta.shareid}-${dirFsId}${dirPath}`;
|
||||
const data = await requestApi('share/list', {
|
||||
cookie,
|
||||
data: {
|
||||
sekey: meta.randsk,
|
||||
uk: meta.uk,
|
||||
shareid: meta.shareid,
|
||||
page: 1,
|
||||
num: 100,
|
||||
dir: shareDir,
|
||||
},
|
||||
method: 'get',
|
||||
});
|
||||
if (data?.errno !== 0 || !Array.isArray(data?.list)) return [];
|
||||
return data.list;
|
||||
}
|
||||
|
||||
async function collectVideosFromList(
|
||||
cookie: string,
|
||||
meta: BaiduNetdiskSessionMeta,
|
||||
list: any[],
|
||||
parentPath = ''
|
||||
): Promise<BaiduNetdiskSessionFile[]> {
|
||||
const videos: BaiduNetdiskSessionFile[] = [];
|
||||
for (const item of list) {
|
||||
if (item.isdir === 1 || item.isdir === '1') {
|
||||
const dirPath = `${parentPath}/${item.server_filename}`;
|
||||
const nested = await listShareDirectory(cookie, meta, dirPath, String(item.fs_id));
|
||||
videos.push(...(await collectVideosFromList(cookie, meta, nested, dirPath)));
|
||||
continue;
|
||||
}
|
||||
const name = String(item.server_filename || '');
|
||||
const ext = name.substring(name.lastIndexOf('.') || 0).toLowerCase();
|
||||
if (!VIDEO_EXTS.includes(ext)) continue;
|
||||
videos.push({
|
||||
fid: String(item.fs_id),
|
||||
name,
|
||||
size: Number(item.size || 0),
|
||||
path: parentPath,
|
||||
});
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
export async function listBaiduShareVideos(shareUrl: string, cookie: string, passcode = ''): Promise<BaiduShareListResult> {
|
||||
const safeCookie = assertBaiduCookieHeaderSafe(cookie);
|
||||
const share = parseBaiduShareUrl(shareUrl, passcode);
|
||||
const tokenData = await getShareToken(share, safeCookie);
|
||||
const files = await collectVideosFromList(tokenData.cookie, tokenData.meta, tokenData.rootList);
|
||||
if (files.length === 0) {
|
||||
throw new Error('百度网盘分享中没有视频文件');
|
||||
}
|
||||
return {
|
||||
title: files.length === 1 ? files[0].name.replace(/\.[^.]+$/, '') : '百度网盘立即播放',
|
||||
files,
|
||||
meta: tokenData.meta,
|
||||
cookie: tokenData.cookie,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBaiduDirectPlayUrl(
|
||||
meta: BaiduNetdiskSessionMeta,
|
||||
fid: string,
|
||||
cookie: string
|
||||
): Promise<{ url: string; headers: Record<string, string> }> {
|
||||
const uid = await getUid(cookie);
|
||||
if (!uid) {
|
||||
throw new Error('获取百度网盘 UID 失败');
|
||||
}
|
||||
const devuid = '73CED981D0F186D12BC18CAE1684FFD5|VSRCQTF6W';
|
||||
const time = String(Date.now());
|
||||
const bduss = assertBaiduCookieHeaderSafe(cookie).match(/BDUSS=([^;]+)/)?.[1];
|
||||
if (!bduss) {
|
||||
throw new Error('百度网盘 Cookie 缺少 BDUSS');
|
||||
}
|
||||
const rand = sha1(
|
||||
sha1(bduss) + uid + 'ebrcUYiuxaZv2XGu7KIYKxUrqfnOfpDF' + time + devuid + '11.30.2ae5821440fab5e1a61a025f014bd8972'
|
||||
);
|
||||
const path = `share/list?shareid=${meta.shareid}&uk=${meta.uk}&fid=${fid}&sekey=${encodeURIComponent(meta.randsk)}&origin=dlna&devuid=${encodeURIComponent(devuid)}&clienttype=1&channel=android_12_zhao_bd-netdisk_1024266h&version=11.30.2&time=${time}&rand=${rand}`;
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
'User-Agent': 'netdisk;P2SP;2.2.91.136;android-android;',
|
||||
Cookie: cookie,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
const url = data?.list?.[0]?.dlink;
|
||||
if (!url) {
|
||||
throw new Error(data?.errmsg || data?.show_msg || '获取百度网盘播放地址失败');
|
||||
}
|
||||
return {
|
||||
url,
|
||||
headers: {
|
||||
'User-Agent': 'netdisk;P2SP;2.2.91.136;android-android;',
|
||||
Referer: 'https://pan.baidu.com',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
export const LEGACY_QUARK_TEMP_SOURCE = 'quark-temp';
|
||||
export const NETDISK_QUARK_SOURCE = 'netdisk-quark';
|
||||
export const NETDISK_MOBILE_SOURCE = 'netdisk-mobile';
|
||||
export const NETDISK_BAIDU_SOURCE = 'netdisk-baidu';
|
||||
|
||||
export type NetdiskProvider = 'quark' | 'mobile';
|
||||
export type NetdiskProvider = 'quark' | 'mobile' | 'baidu';
|
||||
|
||||
export function normalizeNetdiskSource(source?: string | null): string {
|
||||
if (!source) return '';
|
||||
@@ -12,13 +13,14 @@ export function normalizeNetdiskSource(source?: string | null): string {
|
||||
|
||||
export function isNetdiskSource(source?: string | null): boolean {
|
||||
const normalized = normalizeNetdiskSource(source);
|
||||
return normalized === NETDISK_QUARK_SOURCE || normalized === NETDISK_MOBILE_SOURCE;
|
||||
return normalized === NETDISK_QUARK_SOURCE || normalized === NETDISK_MOBILE_SOURCE || normalized === NETDISK_BAIDU_SOURCE;
|
||||
}
|
||||
|
||||
export function getNetdiskProvider(source?: string | null): NetdiskProvider | null {
|
||||
const normalized = normalizeNetdiskSource(source);
|
||||
if (normalized === NETDISK_QUARK_SOURCE) return 'quark';
|
||||
if (normalized === NETDISK_MOBILE_SOURCE) return 'mobile';
|
||||
if (normalized === NETDISK_BAIDU_SOURCE) return 'baidu';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -29,3 +31,7 @@ export function isNetdiskQuarkSource(source?: string | null): boolean {
|
||||
export function isNetdiskMobileSource(source?: string | null): boolean {
|
||||
return normalizeNetdiskSource(source) === NETDISK_MOBILE_SOURCE;
|
||||
}
|
||||
|
||||
export function isNetdiskBaiduSource(source?: string | null): boolean {
|
||||
return normalizeNetdiskSource(source) === NETDISK_BAIDU_SOURCE;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user