115网盘在线播放
This commit is contained in:
@@ -3718,6 +3718,8 @@ const NetDiskConfigComponent = ({
|
||||
const [ucCookie, setUcCookie] = useState('');
|
||||
const [ucToken, setUcToken] = useState('');
|
||||
const [ucSavePath, setUcSavePath] = useState('/');
|
||||
const [pan115Enabled, setPan115Enabled] = useState(false);
|
||||
const [pan115Cookie, setPan115Cookie] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const quark = config?.NetDiskConfig?.Quark;
|
||||
@@ -3739,6 +3741,8 @@ const NetDiskConfigComponent = ({
|
||||
setUcCookie(config?.NetDiskConfig?.UC?.Cookie || '');
|
||||
setUcToken(config?.NetDiskConfig?.UC?.Token || '');
|
||||
setUcSavePath(config?.NetDiskConfig?.UC?.SavePath || '/');
|
||||
setPan115Enabled(config?.NetDiskConfig?.Pan115?.Enabled || false);
|
||||
setPan115Cookie(config?.NetDiskConfig?.Pan115?.Cookie || '');
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -3777,6 +3781,10 @@ const NetDiskConfigComponent = ({
|
||||
Token: ucToken,
|
||||
SavePath: ucSavePath,
|
||||
},
|
||||
Pan115: {
|
||||
Enabled: pan115Enabled,
|
||||
Cookie: pan115Cookie,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3962,6 +3970,34 @@ const NetDiskConfigComponent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleValidatePan115 = async () => {
|
||||
await withLoading('validatePan115NetDisk', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/netdisk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'validate',
|
||||
provider: 'pan115',
|
||||
Pan115: {
|
||||
Cookie: pan115Cookie,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '校验失败');
|
||||
}
|
||||
|
||||
showSuccess(data.message || '115 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'>
|
||||
@@ -4386,6 +4422,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'>
|
||||
115网盘
|
||||
</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'>
|
||||
启用115网盘
|
||||
</h3>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
开启后,网盘搜索中的115网盘资源会显示“立即播放”按钮
|
||||
</p>
|
||||
</div>
|
||||
<label className='relative inline-flex items-center cursor-pointer'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={pan115Enabled}
|
||||
onChange={(e) => setPan115Enabled(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-amber-300 dark:peer-focus:ring-amber-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-amber-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={pan115Cookie}
|
||||
onChange={(e) => setPan115Cookie(e.target.value)}
|
||||
disabled={!pan115Enabled}
|
||||
rows={5}
|
||||
placeholder='粘贴115网盘 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-amber-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleValidatePan115}
|
||||
disabled={!pan115Enabled || !pan115Cookie || isLoading('validatePan115NetDisk')}
|
||||
className={buttonStyles.primary}
|
||||
>
|
||||
{isLoading('validatePan115NetDisk') ? '校验中...' : '校验115 Cookie'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveNetDisk')}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
{isLoading('saveNetDisk') ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
normalizePan123Password,
|
||||
validatePan123Credentials,
|
||||
} from '@/lib/netdisk/pan123.client';
|
||||
import { assertPan115CookieHeaderSafe, normalizePan115Cookie, validatePan115Cookie } from '@/lib/netdisk/pan115.client';
|
||||
import {
|
||||
assertQuarkCookieHeaderSafe,
|
||||
normalizeQuarkCookie,
|
||||
@@ -56,7 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, Quark, Mobile, Baidu, Tianyi, Pan123, UC, provider } = body;
|
||||
const { action, Quark, Mobile, Baidu, Tianyi, Pan123, UC, Pan115, provider } = body;
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
if (action === 'save') {
|
||||
@@ -71,6 +72,7 @@ export async function POST(request: NextRequest) {
|
||||
const normalizedPan123Password = Pan123?.Password ? normalizePan123Password(Pan123.Password) : '';
|
||||
const normalizedUCCookie = UC?.Cookie ? assertUCCookieHeaderSafe(UC.Cookie) : '';
|
||||
const normalizedUCToken = UC?.Token ? String(UC.Token).trim() : '';
|
||||
const normalizedPan115Cookie = Pan115?.Cookie ? assertPan115CookieHeaderSafe(Pan115.Cookie) : '';
|
||||
|
||||
adminConfig.NetDiskConfig = adminConfig.NetDiskConfig || {};
|
||||
adminConfig.NetDiskConfig.Quark = {
|
||||
@@ -102,6 +104,10 @@ export async function POST(request: NextRequest) {
|
||||
Token: normalizedUCToken,
|
||||
SavePath: UC?.SavePath || '/',
|
||||
};
|
||||
adminConfig.NetDiskConfig.Pan115 = {
|
||||
Enabled: Boolean(Pan115?.Enabled),
|
||||
Cookie: normalizedPan115Cookie,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
await setCachedConfig(adminConfig);
|
||||
@@ -167,6 +173,16 @@ export async function POST(request: NextRequest) {
|
||||
message: 'UC Cookie 可读',
|
||||
});
|
||||
}
|
||||
if (provider === 'pan115') {
|
||||
if (!Pan115?.Cookie) {
|
||||
return NextResponse.json({ error: '请先填写115 Cookie' }, { status: 400 });
|
||||
}
|
||||
await validatePan115Cookie(normalizePan115Cookie(Pan115.Cookie));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '115 Cookie 格式正常',
|
||||
});
|
||||
}
|
||||
|
||||
if (!Quark?.Cookie) {
|
||||
return NextResponse.json({ error: '请先填写夸克 Cookie' }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { listPan115ShareVideos } from '@/lib/netdisk/pan115.client';
|
||||
import { createPan115NetdiskSession } from '@/lib/netdisk/pan115-session-cache';
|
||||
import { NETDISK_115_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 pan115Config = config.NetDiskConfig?.Pan115;
|
||||
if (!pan115Config?.Enabled || !pan115Config.Cookie) {
|
||||
return NextResponse.json({ error: '115网盘未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await listPan115ShareVideos(shareUrl, passcode || '');
|
||||
const session = createPan115NetdiskSession({
|
||||
title: title || result.title,
|
||||
shareUrl,
|
||||
passcode,
|
||||
files: result.files,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
source: NETDISK_115_SOURCE,
|
||||
id: session.id,
|
||||
title: title || result.title,
|
||||
fileCount: result.files.length,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '立即播放失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getPan115PlayUrl } from '@/lib/netdisk/pan115.client';
|
||||
import { getPan115NetdiskSession, refreshPan115NetdiskSession } from '@/lib/netdisk/pan115-session-cache';
|
||||
import { resolvePan115Session } from '@/lib/netdisk/pan115-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') || searchParams.get('session');
|
||||
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 });
|
||||
}
|
||||
|
||||
refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id);
|
||||
const { session, cookie } = await resolvePan115Session(id);
|
||||
const file = session.files[episodeIndex];
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: '播放文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = await getPan115PlayUrl(file, cookie);
|
||||
refreshPan115NetdiskSession(id);
|
||||
|
||||
if (format === 'json') {
|
||||
return NextResponse.json({ url, headers: {} });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(url);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '获取播放地址失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
parsePan123NetdiskId,
|
||||
refreshPan123NetdiskSession,
|
||||
} from '@/lib/netdisk/pan123-session-cache';
|
||||
import {
|
||||
createPan115NetdiskSession,
|
||||
getPan115NetdiskSession,
|
||||
parsePan115NetdiskId,
|
||||
refreshPan115NetdiskSession,
|
||||
} from '@/lib/netdisk/pan115-session-cache';
|
||||
import {
|
||||
createQuarkNetdiskSession,
|
||||
getQuarkNetdiskSession,
|
||||
@@ -32,6 +38,7 @@ import {
|
||||
} from '@/lib/netdisk/quark-session-cache';
|
||||
import {
|
||||
LEGACY_QUARK_TEMP_SOURCE,
|
||||
NETDISK_115_SOURCE,
|
||||
NETDISK_123_SOURCE,
|
||||
NETDISK_BAIDU_SOURCE,
|
||||
NETDISK_MOBILE_SOURCE,
|
||||
@@ -630,6 +637,74 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceCode === NETDISK_115_SOURCE) {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const pan115Config = config.NetDiskConfig?.Pan115;
|
||||
if (!pan115Config?.Enabled || !pan115Config.Cookie) {
|
||||
throw new Error('115网盘未配置或未启用');
|
||||
}
|
||||
|
||||
let session = refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id);
|
||||
if (!session) {
|
||||
const payload = parsePan115NetdiskId(id);
|
||||
const { listPan115ShareVideos } = await import('@/lib/netdisk/pan115.client');
|
||||
const result = await listPan115ShareVideos(payload.shareUrl, payload.passcode || '');
|
||||
session = createPan115NetdiskSession({
|
||||
title: title || result.title,
|
||||
shareUrl: payload.shareUrl,
|
||||
passcode: payload.passcode,
|
||||
files: result.files,
|
||||
});
|
||||
}
|
||||
if (!session) {
|
||||
throw new Error('115网盘播放信息恢复失败');
|
||||
}
|
||||
|
||||
const pan115Session = session;
|
||||
const { parseVideoFileName } = await import('@/lib/video-parser');
|
||||
const parsedFiles = pan115Session.files
|
||||
.map((file, index) => {
|
||||
const parsed = parseVideoFileName(file.name);
|
||||
return {
|
||||
...file,
|
||||
originalIndex: index,
|
||||
sortEpisode: parsed.episode || index + 1,
|
||||
isOVA: parsed.isOVA,
|
||||
displayTitle: formatNetdiskEpisodeTitle(parsed, 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_115_SOURCE,
|
||||
source_name: '115网盘',
|
||||
id: pan115Session.id,
|
||||
title: title || pan115Session.title,
|
||||
poster: '',
|
||||
year: '',
|
||||
douban_id: 0,
|
||||
desc: `115网盘分享:${pan115Session.shareUrl}`,
|
||||
episodes: parsedFiles.map((file) => (
|
||||
`/api/netdisk/115/play?id=${encodeURIComponent(pan115Session.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();
|
||||
|
||||
+15
-2
@@ -72,7 +72,7 @@ function HomeClient() {
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
|
||||
const detectNetdiskLink = (url: string): {
|
||||
provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc';
|
||||
provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc' | '115';
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
} | null => {
|
||||
@@ -146,6 +146,17 @@ function HomeClient() {
|
||||
return { provider: 'mobile', shareUrl: trimmed };
|
||||
}
|
||||
|
||||
if (/https:\/\/(?:115|anxia|115cdn)\.com\/s\//i.test(trimmed)) {
|
||||
return {
|
||||
provider: '115',
|
||||
shareUrl: trimmed,
|
||||
passcode: pickPasscode(
|
||||
trimmed.match(/[?&](?:password|pwd|passcode)=([^&]+)/i)?.[1],
|
||||
inlinePasscode(trimmed)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -168,6 +179,8 @@ function HomeClient() {
|
||||
? 'netdisk-baidu'
|
||||
: netdisk.provider === 'tianyi'
|
||||
? 'netdisk-tianyi'
|
||||
: netdisk.provider === '115'
|
||||
? 'netdisk-115'
|
||||
: netdisk.provider === 'uc'
|
||||
? 'netdisk-uc'
|
||||
: netdisk.provider === '123'
|
||||
@@ -860,7 +873,7 @@ function HomeClient() {
|
||||
请输入可直接播放的视频链接。
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
支持夸克、UC、百度、天翼、移动、123 网盘在线播放。
|
||||
支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。
|
||||
</div>
|
||||
<input
|
||||
value={directPlayUrl}
|
||||
|
||||
@@ -2504,6 +2504,7 @@ function PlayPageClient() {
|
||||
const isSpecialLazyPlayUrl =
|
||||
isXiaoyaLazyPlayUrl ||
|
||||
newUrl.startsWith('/api/openlist/play') ||
|
||||
newUrl.startsWith('/api/netdisk/115/play') ||
|
||||
newUrl.startsWith('/api/netdisk/123/play') ||
|
||||
newUrl.startsWith('/api/netdisk/quark/play') ||
|
||||
newUrl.startsWith('/api/netdisk/uc/play') ||
|
||||
|
||||
@@ -309,10 +309,12 @@ export default function PansouSearch({
|
||||
? '/api/netdisk/mobile/instant-play'
|
||||
: cloudType === 'baidu'
|
||||
? '/api/netdisk/baidu/instant-play'
|
||||
: cloudType === 'tianyi'
|
||||
? '/api/netdisk/tianyi/instant-play'
|
||||
: cloudType === 'uc'
|
||||
? '/api/netdisk/uc/instant-play'
|
||||
: cloudType === 'tianyi'
|
||||
? '/api/netdisk/tianyi/instant-play'
|
||||
: cloudType === '115'
|
||||
? '/api/netdisk/115/instant-play'
|
||||
: cloudType === 'uc'
|
||||
? '/api/netdisk/uc/instant-play'
|
||||
: cloudType === '123'
|
||||
? '/api/netdisk/123/instant-play'
|
||||
: '/api/netdisk/quark/instant-play';
|
||||
@@ -334,7 +336,7 @@ export default function PansouSearch({
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/play?source=${encodeURIComponent(data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : cloudType === 'baidu' ? 'netdisk-baidu' : cloudType === 'tianyi' ? 'netdisk-tianyi' : cloudType === 'uc' ? 'netdisk-uc' : cloudType === '123' ? 'netdisk-123' : 'netdisk-quark'))}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(keyword)}`
|
||||
`/play?source=${encodeURIComponent(data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : cloudType === 'baidu' ? 'netdisk-baidu' : cloudType === 'tianyi' ? 'netdisk-tianyi' : cloudType === '115' ? 'netdisk-115' : cloudType === 'uc' ? 'netdisk-uc' : cloudType === '123' ? 'netdisk-123' : 'netdisk-quark'))}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(keyword)}`
|
||||
);
|
||||
} catch (err: any) {
|
||||
setToast({
|
||||
@@ -618,7 +620,7 @@ export default function PansouSearch({
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123' || cloudType === 'uc') && (
|
||||
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123' || cloudType === 'uc' || cloudType === '115') && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleNetdiskInstantPlay(cloudType, link)}
|
||||
|
||||
@@ -176,6 +176,10 @@ export interface AdminConfig {
|
||||
Token?: string;
|
||||
SavePath: string;
|
||||
};
|
||||
Pan115?: {
|
||||
Enabled: boolean;
|
||||
Cookie: string;
|
||||
};
|
||||
};
|
||||
AIConfig?: {
|
||||
Enabled: boolean; // 是否启用AI问片功能
|
||||
|
||||
@@ -694,6 +694,10 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
Token: '',
|
||||
SavePath: '/',
|
||||
},
|
||||
Pan115: {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -744,6 +748,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
};
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig.Pan115) {
|
||||
adminConfig.NetDiskConfig.Pan115 = {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 确保音乐配置存在
|
||||
if (!adminConfig.MusicConfig) {
|
||||
adminConfig.MusicConfig = {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { base58Decode, base58Encode } from '@/lib/utils';
|
||||
|
||||
import type { Pan115ShareVideoFile } from './pan115.client';
|
||||
|
||||
export interface Pan115NetdiskSession {
|
||||
id: string;
|
||||
provider: '115';
|
||||
title: string;
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
files: Pan115ShareVideoFile[];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
const sessionStore = new Map<string, Pan115NetdiskSession>();
|
||||
|
||||
export function buildPan115NetdiskId(input: { shareUrl: string; passcode?: string }) {
|
||||
return base58Encode(JSON.stringify({ shareUrl: input.shareUrl, passcode: input.passcode || '' }));
|
||||
}
|
||||
|
||||
export function parsePan115NetdiskId(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 115 netdisk id');
|
||||
return {
|
||||
shareUrl: parsed.shareUrl,
|
||||
passcode: typeof parsed.passcode === 'string' ? parsed.passcode : '',
|
||||
};
|
||||
} catch {
|
||||
throw new Error('无效的115网盘播放 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 createPan115NetdiskSession(input: {
|
||||
title: string;
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
files: Pan115ShareVideoFile[];
|
||||
}): Pan115NetdiskSession {
|
||||
pruneExpiredSessions();
|
||||
const now = Date.now();
|
||||
const id = buildPan115NetdiskId({ shareUrl: input.shareUrl, passcode: input.passcode });
|
||||
const session: Pan115NetdiskSession = {
|
||||
id,
|
||||
provider: '115',
|
||||
title: input.title,
|
||||
shareUrl: input.shareUrl,
|
||||
passcode: input.passcode,
|
||||
files: input.files,
|
||||
createdAt: now,
|
||||
expiresAt: now + TTL_MS,
|
||||
};
|
||||
sessionStore.set(id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getPan115NetdiskSession(id: string): Pan115NetdiskSession | 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 refreshPan115NetdiskSession(id: string): Pan115NetdiskSession | null {
|
||||
const session = getPan115NetdiskSession(id);
|
||||
if (!session) return null;
|
||||
session.expiresAt = Date.now() + TTL_MS;
|
||||
sessionStore.set(id, session);
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
import { listPan115ShareVideos } from './pan115.client';
|
||||
import {
|
||||
createPan115NetdiskSession,
|
||||
getPan115NetdiskSession,
|
||||
parsePan115NetdiskId,
|
||||
refreshPan115NetdiskSession,
|
||||
} from './pan115-session-cache';
|
||||
|
||||
export async function resolvePan115Session(id: string) {
|
||||
const config = await getConfig();
|
||||
const pan115Config = config.NetDiskConfig?.Pan115;
|
||||
if (!pan115Config?.Enabled || !pan115Config.Cookie) {
|
||||
throw new Error('115网盘未配置或未启用');
|
||||
}
|
||||
|
||||
let session = refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id);
|
||||
if (!session) {
|
||||
const payload = parsePan115NetdiskId(id);
|
||||
const result = await listPan115ShareVideos(payload.shareUrl, payload.passcode || '');
|
||||
session = createPan115NetdiskSession({
|
||||
title: result.title,
|
||||
shareUrl: payload.shareUrl,
|
||||
passcode: payload.passcode,
|
||||
files: result.files,
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error('115网盘播放信息恢复失败');
|
||||
}
|
||||
|
||||
return { session, cookie: pan115Config.Cookie };
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
const SHARE_URL = 'https://webapi.115.com/share/snap';
|
||||
const PLAY_URL = 'http://pro.api.115.com/app/share/downurl';
|
||||
|
||||
export interface Pan115ShareVideoFile {
|
||||
name: string;
|
||||
fileId: string;
|
||||
shareCode: string;
|
||||
receiveCode: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Pan115ShareListResult {
|
||||
title: string;
|
||||
files: Pan115ShareVideoFile[];
|
||||
}
|
||||
|
||||
const VIDEO_EXTENSIONS = [
|
||||
'.mp4', '.webm', '.avi', '.wmv', '.flv', '.mov', '.mkv', '.mpeg', '.3gp', '.ts', '.m2ts', '.mp3', '.wav', '.aac', '.iso',
|
||||
];
|
||||
|
||||
const G_KTS = new Uint8Array([
|
||||
0xf0, 0xe5, 0x69, 0xae, 0xbf, 0xdc, 0xbf, 0x8a, 0x1a, 0x45, 0xe8, 0xbe, 0x7d, 0xa6, 0x73, 0xb8,
|
||||
0xde, 0x8f, 0xe7, 0xc4, 0x45, 0xda, 0x86, 0xc4, 0x9b, 0x64, 0x8b, 0x14, 0x6a, 0xb4, 0xf1, 0xaa,
|
||||
0x38, 0x01, 0x35, 0x9e, 0x26, 0x69, 0x2c, 0x86, 0x00, 0x6b, 0x4f, 0xa5, 0x36, 0x34, 0x62, 0xa6,
|
||||
0x2a, 0x96, 0x68, 0x18, 0xf2, 0x4a, 0xfd, 0xbd, 0x6b, 0x97, 0x8f, 0x4d, 0x8f, 0x89, 0x13, 0xb7,
|
||||
0x6c, 0x8e, 0x93, 0xed, 0x0e, 0x0d, 0x48, 0x3e, 0xd7, 0x2f, 0x88, 0xd8, 0xfe, 0xfe, 0x7e, 0x86,
|
||||
0x50, 0x95, 0x4f, 0xd1, 0xeb, 0x83, 0x26, 0x34, 0xdb, 0x66, 0x7b, 0x9c, 0x7e, 0x9d, 0x7a, 0x81,
|
||||
0x32, 0xea, 0xb6, 0x33, 0xde, 0x3a, 0xa9, 0x59, 0x34, 0x66, 0x3b, 0xaa, 0xba, 0x81, 0x60, 0x48,
|
||||
0xb9, 0xd5, 0x81, 0x9c, 0xf8, 0x6c, 0x84, 0x77, 0xff, 0x54, 0x78, 0x26, 0x5f, 0xbe, 0xe8, 0x1e,
|
||||
0x36, 0x9f, 0x34, 0x80, 0x5c, 0x45, 0x2c, 0x9b, 0x76, 0xd5, 0x1b, 0x8f, 0xcc, 0xc3, 0xb8, 0xf5,
|
||||
]);
|
||||
|
||||
const RSA_E = BigInt(`0x8686980c0f5a24c4b9d43020cd2c22703ff3f450756529058b1cf88f09b8602136477198a6e2683149659bd122c33592fdb5ad47944ad1ea4d36c6b172aad6338c3bb6ac6227502d010993ac967d1aef00f0c8e038de2e4d3bc2ec368af2e9f10a6f1eda4f7262f136420c07c331b871bf139f74f3010e3c4fe57df3afb71683`);
|
||||
const RSA_N = BigInt(0x10001);
|
||||
|
||||
function assertSafe(value: string, label: string) {
|
||||
const normalized = value.trim();
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
if (normalized.charCodeAt(i) > 255) {
|
||||
throw new Error(`${label} 含有非法字符,请检查是否包含中文标点或说明文字`);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizePan115Cookie(cookie: string) {
|
||||
return assertSafe(cookie.replace(/;/g, ';').replace(/:/g, ':').replace(/,/g, ','), '115 Cookie');
|
||||
}
|
||||
|
||||
export function assertPan115CookieHeaderSafe(cookie: string) {
|
||||
return normalizePan115Cookie(cookie);
|
||||
}
|
||||
|
||||
function isMediaFile(filename: string) {
|
||||
const lower = filename.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
export function parsePan115ShareUrl(shareUrl: string, passcode = '') {
|
||||
const cleaned = decodeURIComponent(shareUrl.trim()).replace(/[#.,,/\s]+$/, '');
|
||||
const matches = /https:\/\/(?:115|anxia|115cdn)\.com\/s\/([a-zA-Z0-9]+)(?:\?password=([^&#\s]+))?/i.exec(cleaned);
|
||||
if (!matches) throw new Error('无法解析115分享链接');
|
||||
return {
|
||||
shareCode: matches[1],
|
||||
receiveCode: passcode || matches[2] || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJson(response: Response) {
|
||||
const text = await response.text();
|
||||
try {
|
||||
return typeof text === 'string' ? JSON.parse(text) : text;
|
||||
} catch {
|
||||
throw new Error(`115接口返回异常:${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchShareDir(shareCode: string, receiveCode: string, cid: string) {
|
||||
const url = new URL(SHARE_URL);
|
||||
url.searchParams.set('share_code', shareCode);
|
||||
url.searchParams.set('receive_code', receiveCode);
|
||||
url.searchParams.set('cid', cid);
|
||||
url.searchParams.set('limit', '9999');
|
||||
url.searchParams.set('offset', '0');
|
||||
const response = await fetch(url, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`115分享接口请求失败 (${response.status})`);
|
||||
}
|
||||
return parseJson(response);
|
||||
}
|
||||
|
||||
async function collectFilesRecursive(shareCode: string, receiveCode: string, cid: string, files: Pan115ShareVideoFile[]) {
|
||||
const responseData = await fetchShareDir(shareCode, receiveCode, cid);
|
||||
if (!responseData?.data) return;
|
||||
if (responseData.data.share_state === 7) {
|
||||
throw new Error(responseData.data.shareinfo?.forbid_reason || '链接已过期');
|
||||
}
|
||||
|
||||
const list = Array.isArray(responseData.data.list) ? responseData.data.list : [];
|
||||
const mediaFiles = list.filter((item: any) => Number(item.fc) === 1 && isMediaFile(String(item.n || '')));
|
||||
const folders = list.filter((item: any) => Number(item.fc) === 0);
|
||||
|
||||
mediaFiles.forEach((file: any) => {
|
||||
files.push({
|
||||
name: String(file.n || ''),
|
||||
fileId: String(file.fid || ''),
|
||||
shareCode,
|
||||
receiveCode,
|
||||
size: Number(file.s || 0),
|
||||
});
|
||||
});
|
||||
|
||||
for (const folder of folders) {
|
||||
await collectFilesRecursive(shareCode, receiveCode, String(folder.cid || ''), files);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPan115ShareVideos(shareUrl: string, passcode = ''): Promise<Pan115ShareListResult> {
|
||||
const { shareCode, receiveCode } = parsePan115ShareUrl(shareUrl, passcode);
|
||||
const files: Pan115ShareVideoFile[] = [];
|
||||
await collectFilesRecursive(shareCode, receiveCode, shareCode, files);
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }));
|
||||
if (files.length === 0) {
|
||||
throw new Error('115分享中没有可播放的视频文件');
|
||||
}
|
||||
return {
|
||||
title: files.length === 1 ? files[0].name.replace(/\.[^.]+$/, '') : '115网盘立即播放',
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function bytesToBigInt(bytes: Uint8Array) {
|
||||
let value = BigInt(0);
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
value = (value << BigInt(8)) | BigInt(bytes[i]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function bigIntToBytes(value: bigint, length?: number) {
|
||||
const hex = value.toString(16);
|
||||
const padded = hex.length % 2 === 0 ? hex : `0${hex}`;
|
||||
const bytes = Buffer.from(padded, 'hex');
|
||||
if (!length) return new Uint8Array(bytes);
|
||||
if (bytes.length >= length) return new Uint8Array(bytes.slice(-length));
|
||||
const buffer = new Uint8Array(length);
|
||||
buffer.set(bytes, length - bytes.length);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function* accStep(start: number, stop: number, step = 1): Generator<[number, number, number]> {
|
||||
for (let i = start + step; i < stop; i += step) {
|
||||
yield [start, i, step];
|
||||
start = i;
|
||||
}
|
||||
if (start !== stop) yield [start, stop, stop - start];
|
||||
}
|
||||
|
||||
function bytesXor(v1: Uint8Array, v2: Uint8Array) {
|
||||
const result = new Uint8Array(v1.length);
|
||||
for (let i = 0; i < v1.length; i += 1) result[i] = v1[i] ^ v2[i];
|
||||
return result;
|
||||
}
|
||||
|
||||
function xor(src: Uint8Array, key: Uint8Array) {
|
||||
const buffer = new Uint8Array(src.length);
|
||||
const offset = src.length & 0b11;
|
||||
if (offset) buffer.set(bytesXor(src.subarray(0, offset), key.subarray(0, offset)));
|
||||
const iterator = accStep(offset, src.length, key.length);
|
||||
let next = iterator.next();
|
||||
while (!next.done) {
|
||||
const [j, k] = next.value;
|
||||
buffer.set(bytesXor(src.subarray(j, k), key), j);
|
||||
next = iterator.next();
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function genKey(randKey: Uint8Array, skLen: number) {
|
||||
const xorKey = new Uint8Array(skLen);
|
||||
let length = skLen * (skLen - 1);
|
||||
let index = 0;
|
||||
for (let i = 0; i < skLen; i += 1) {
|
||||
const x = (randKey[i] + G_KTS[index]) & 0xff;
|
||||
xorKey[i] = G_KTS[length] ^ x;
|
||||
length -= skLen;
|
||||
index += skLen;
|
||||
}
|
||||
return xorKey;
|
||||
}
|
||||
|
||||
function padPkcs1V15(message: Uint8Array) {
|
||||
const buffer = new Uint8Array(128);
|
||||
buffer.fill(0x02, 1, 127 - message.length);
|
||||
buffer.set(message, 128 - message.length);
|
||||
return bytesToBigInt(buffer);
|
||||
}
|
||||
|
||||
function modPow(base: bigint, exponent: bigint, modulus: bigint) {
|
||||
let result = BigInt(1);
|
||||
let b = base % modulus;
|
||||
let e = exponent;
|
||||
while (e > BigInt(0)) {
|
||||
if (e & BigInt(1)) result = (result * b) % modulus;
|
||||
e >>= BigInt(1);
|
||||
b = (b * b) % modulus;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function reverseBytes(bytes: Uint8Array) {
|
||||
return Uint8Array.from(Array.from(bytes).reverse());
|
||||
}
|
||||
|
||||
function encrypt115(input: string) {
|
||||
const data = new Uint8Array(Buffer.from(input, 'utf8'));
|
||||
const xorText = new Uint8Array(16 + data.length);
|
||||
xorText.set(
|
||||
xor(
|
||||
reverseBytes(xor(data, new Uint8Array([0x8d, 0xa5, 0xa5, 0x8d]))),
|
||||
new Uint8Array([0x78, 0x06, 0xad, 0x4c, 0x33, 0x86, 0x5d, 0x18, 0x4c, 0x01, 0x3f, 0x46])
|
||||
),
|
||||
16
|
||||
);
|
||||
const cipherData = new Uint8Array(Math.ceil(xorText.length / 117) * 128);
|
||||
let start = 0;
|
||||
const iterator = accStep(0, xorText.length, 117);
|
||||
let next = iterator.next();
|
||||
while (!next.done) {
|
||||
const [l, r] = next.value;
|
||||
cipherData.set(bigIntToBytes(modPow(padPkcs1V15(xorText.subarray(l, r)), RSA_N, RSA_E), 128), start);
|
||||
start += 128;
|
||||
next = iterator.next();
|
||||
}
|
||||
return Buffer.from(cipherData).toString('base64');
|
||||
}
|
||||
|
||||
function decrypt115(cipherData: string) {
|
||||
const cipherBytes = new Uint8Array(Buffer.from(cipherData, 'base64'));
|
||||
const data: number[] = [];
|
||||
const iterator = accStep(0, cipherBytes.length, 128);
|
||||
let next = iterator.next();
|
||||
while (!next.done) {
|
||||
const [l, r] = next.value;
|
||||
const p = modPow(bytesToBigInt(cipherBytes.subarray(l, r)), RSA_N, RSA_E);
|
||||
const b = bigIntToBytes(p);
|
||||
const idx = b.indexOf(0);
|
||||
data.push(...Array.from(b.subarray(idx + 1)));
|
||||
next = iterator.next();
|
||||
}
|
||||
const keyL = genKey(new Uint8Array(data.slice(0, 16)), 12);
|
||||
const tmp = reverseBytes(xor(new Uint8Array(data.slice(16)), keyL));
|
||||
const bytes = xor(tmp, new Uint8Array([0x8d, 0xa5, 0xa5, 0x8d]));
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
|
||||
export async function getPan115PlayUrl(file: Pan115ShareVideoFile, cookie: string) {
|
||||
const safeCookie = assertPan115CookieHeaderSafe(cookie);
|
||||
const payload = `data=${encodeURIComponent(encrypt115(JSON.stringify({
|
||||
share_code: file.shareCode,
|
||||
receive_code: file.receiveCode,
|
||||
file_id: file.fileId,
|
||||
})))}`;
|
||||
|
||||
const response = await fetch(PLAY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: safeCookie,
|
||||
},
|
||||
body: payload,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const responseData = await parseJson(response);
|
||||
if (!responseData) {
|
||||
throw new Error('115盘无响应数据');
|
||||
}
|
||||
if (responseData.state === false) {
|
||||
const errorMsg = responseData.msg || responseData.error || '未知错误';
|
||||
if (String(errorMsg).includes('登录')) {
|
||||
throw new Error('115 Cookie 无效,请重新填写');
|
||||
}
|
||||
throw new Error(`115盘错误: ${errorMsg}`);
|
||||
}
|
||||
if (!responseData.data || typeof responseData.data !== 'string') {
|
||||
throw new Error('115 Cookie 无效,请重新填写');
|
||||
}
|
||||
const parsed = JSON.parse(decrypt115(responseData.data));
|
||||
const playUrl = parsed?.url?.url;
|
||||
if (!playUrl) throw new Error('未获取到115播放地址');
|
||||
return playUrl as string;
|
||||
}
|
||||
|
||||
export async function validatePan115Cookie(cookie: string): Promise<void> {
|
||||
assertPan115CookieHeaderSafe(cookie);
|
||||
}
|
||||
@@ -5,8 +5,9 @@ export const NETDISK_BAIDU_SOURCE = 'netdisk-baidu';
|
||||
export const NETDISK_TIANYI_SOURCE = 'netdisk-tianyi';
|
||||
export const NETDISK_123_SOURCE = 'netdisk-123';
|
||||
export const NETDISK_UC_SOURCE = 'netdisk-uc';
|
||||
export const NETDISK_115_SOURCE = 'netdisk-115';
|
||||
|
||||
export type NetdiskProvider = 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc';
|
||||
export type NetdiskProvider = 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc' | '115';
|
||||
|
||||
export function normalizeNetdiskSource(source?: string | null): string {
|
||||
if (!source) return '';
|
||||
@@ -16,7 +17,7 @@ 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 || normalized === NETDISK_BAIDU_SOURCE || normalized === NETDISK_TIANYI_SOURCE || normalized === NETDISK_123_SOURCE || normalized === NETDISK_UC_SOURCE;
|
||||
return normalized === NETDISK_QUARK_SOURCE || normalized === NETDISK_MOBILE_SOURCE || normalized === NETDISK_BAIDU_SOURCE || normalized === NETDISK_TIANYI_SOURCE || normalized === NETDISK_123_SOURCE || normalized === NETDISK_UC_SOURCE || normalized === NETDISK_115_SOURCE;
|
||||
}
|
||||
|
||||
export function getNetdiskProvider(source?: string | null): NetdiskProvider | null {
|
||||
@@ -27,6 +28,7 @@ export function getNetdiskProvider(source?: string | null): NetdiskProvider | nu
|
||||
if (normalized === NETDISK_TIANYI_SOURCE) return 'tianyi';
|
||||
if (normalized === NETDISK_123_SOURCE) return '123';
|
||||
if (normalized === NETDISK_UC_SOURCE) return 'uc';
|
||||
if (normalized === NETDISK_115_SOURCE) return '115';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -53,3 +55,7 @@ export function isNetdisk123Source(source?: string | null): boolean {
|
||||
export function isNetdiskUCSource(source?: string | null): boolean {
|
||||
return normalizeNetdiskSource(source) === NETDISK_UC_SOURCE;
|
||||
}
|
||||
|
||||
export function isNetdisk115Source(source?: string | null): boolean {
|
||||
return normalizeNetdiskSource(source) === NETDISK_115_SOURCE;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user