增加天翼网盘在线播放
This commit is contained in:
@@ -3708,6 +3708,9 @@ const NetDiskConfigComponent = ({
|
||||
const [mobileAuthorization, setMobileAuthorization] = useState('');
|
||||
const [baiduEnabled, setBaiduEnabled] = useState(false);
|
||||
const [baiduCookie, setBaiduCookie] = useState('');
|
||||
const [tianyiEnabled, setTianyiEnabled] = useState(false);
|
||||
const [tianyiAccount, setTianyiAccount] = useState('');
|
||||
const [tianyiPassword, setTianyiPassword] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const quark = config?.NetDiskConfig?.Quark;
|
||||
@@ -3719,6 +3722,9 @@ const NetDiskConfigComponent = ({
|
||||
setMobileAuthorization(mobile?.Authorization || '');
|
||||
setBaiduEnabled(config?.NetDiskConfig?.Baidu?.Enabled || false);
|
||||
setBaiduCookie(config?.NetDiskConfig?.Baidu?.Cookie || '');
|
||||
setTianyiEnabled(config?.NetDiskConfig?.Tianyi?.Enabled || false);
|
||||
setTianyiAccount(config?.NetDiskConfig?.Tianyi?.Account || '');
|
||||
setTianyiPassword(config?.NetDiskConfig?.Tianyi?.Password || '');
|
||||
}, [config]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -3741,6 +3747,11 @@ const NetDiskConfigComponent = ({
|
||||
Enabled: baiduEnabled,
|
||||
Cookie: baiduCookie,
|
||||
},
|
||||
Tianyi: {
|
||||
Enabled: tianyiEnabled,
|
||||
Account: tianyiAccount,
|
||||
Password: tianyiPassword,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3838,6 +3849,35 @@ const NetDiskConfigComponent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleValidateTianyi = async () => {
|
||||
await withLoading('validateTianyiNetDisk', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/netdisk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'validate',
|
||||
provider: 'tianyi',
|
||||
Tianyi: {
|
||||
Account: tianyiAccount,
|
||||
Password: tianyiPassword,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '校验失败');
|
||||
}
|
||||
|
||||
showSuccess(data.message || '天翼云盘账号密码可用', 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'>
|
||||
@@ -4028,6 +4068,82 @@ 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='rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300'>
|
||||
使用天翼云盘前,请先关闭账号的设备锁,否则可能无法登录。
|
||||
</div>
|
||||
|
||||
<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={tianyiEnabled}
|
||||
onChange={(e) => setTianyiEnabled(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-red-300 dark:peer-focus:ring-red-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-red-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
账号
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={tianyiAccount}
|
||||
onChange={(e) => setTianyiAccount(e.target.value)}
|
||||
disabled={!tianyiEnabled}
|
||||
placeholder='手机号 / 邮箱 / 天翼账号'
|
||||
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-red-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={tianyiPassword}
|
||||
onChange={(e) => setTianyiPassword(e.target.value)}
|
||||
disabled={!tianyiEnabled}
|
||||
placeholder='输入天翼云盘密码'
|
||||
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-red-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleValidateTianyi}
|
||||
disabled={!tianyiEnabled || !tianyiAccount || !tianyiPassword || isLoading('validateTianyiNetDisk')}
|
||||
className={buttonStyles.primary}
|
||||
>
|
||||
{isLoading('validateTianyiNetDisk') ? '校验中...' : '校验天翼云盘账号密码'}
|
||||
</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 {
|
||||
normalizeQuarkCookie,
|
||||
validateQuarkCookieReadable,
|
||||
} from '@/lib/netdisk/quark.client';
|
||||
import { normalizeTianyiAccount, normalizeTianyiPassword, validateTianyiCredentials } from '@/lib/netdisk/tianyi.client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -45,7 +46,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, Quark, Mobile, Baidu, provider } = body;
|
||||
const { action, Quark, Mobile, Baidu, Tianyi, provider } = body;
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
if (action === 'save') {
|
||||
@@ -54,6 +55,8 @@ export async function POST(request: NextRequest) {
|
||||
? assertMobileAuthorizationHeaderSafe(Mobile.Authorization)
|
||||
: '';
|
||||
const normalizedBaiduCookie = Baidu?.Cookie ? assertBaiduCookieHeaderSafe(Baidu.Cookie) : '';
|
||||
const normalizedTianyiAccount = Tianyi?.Account ? normalizeTianyiAccount(Tianyi.Account) : '';
|
||||
const normalizedTianyiPassword = Tianyi?.Password ? normalizeTianyiPassword(Tianyi.Password) : '';
|
||||
|
||||
adminConfig.NetDiskConfig = adminConfig.NetDiskConfig || {};
|
||||
adminConfig.NetDiskConfig.Quark = {
|
||||
@@ -69,6 +72,11 @@ export async function POST(request: NextRequest) {
|
||||
Enabled: Boolean(Baidu?.Enabled),
|
||||
Cookie: normalizedBaiduCookie,
|
||||
};
|
||||
adminConfig.NetDiskConfig.Tianyi = {
|
||||
Enabled: Boolean(Tianyi?.Enabled),
|
||||
Account: normalizedTianyiAccount,
|
||||
Password: normalizedTianyiPassword,
|
||||
};
|
||||
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
await setCachedConfig(adminConfig);
|
||||
@@ -98,6 +106,19 @@ export async function POST(request: NextRequest) {
|
||||
message: '百度网盘 Cookie 格式正常',
|
||||
});
|
||||
}
|
||||
if (provider === 'tianyi') {
|
||||
if (!Tianyi?.Account || !Tianyi?.Password) {
|
||||
return NextResponse.json({ error: '请先填写天翼云盘账号和密码' }, { status: 400 });
|
||||
}
|
||||
await validateTianyiCredentials(
|
||||
normalizeTianyiAccount(Tianyi.Account),
|
||||
normalizeTianyiPassword(Tianyi.Password)
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '天翼云盘账号密码可用',
|
||||
});
|
||||
}
|
||||
|
||||
if (!Quark?.Cookie) {
|
||||
return NextResponse.json({ error: '请先填写夸克 Cookie' }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { NETDISK_TIANYI_SOURCE } from '@/lib/netdisk/source';
|
||||
import { listTianyiShareVideos } from '@/lib/netdisk/tianyi.client';
|
||||
import { createTianyiNetdiskSession } from '@/lib/netdisk/tianyi-session-cache';
|
||||
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 tianyiConfig = config.NetDiskConfig?.Tianyi;
|
||||
if (!tianyiConfig?.Enabled || !tianyiConfig.Account || !tianyiConfig.Password) {
|
||||
return NextResponse.json({ error: '天翼云盘未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await listTianyiShareVideos(
|
||||
shareUrl,
|
||||
tianyiConfig.Account,
|
||||
tianyiConfig.Password,
|
||||
passcode || ''
|
||||
);
|
||||
const session = createTianyiNetdiskSession({
|
||||
title: title || result.title,
|
||||
shareUrl,
|
||||
passcode,
|
||||
shareId: result.shareId,
|
||||
shareMode: result.shareMode,
|
||||
isFolder: result.isFolder,
|
||||
accessCode: result.accessCode,
|
||||
files: result.files,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
source: NETDISK_TIANYI_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,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getTianyiSharePlayUrl } from '@/lib/netdisk/tianyi.client';
|
||||
import { listTianyiShareVideos } from '@/lib/netdisk/tianyi.client';
|
||||
import { createTianyiNetdiskSession, getTianyiNetdiskSession, parseTianyiNetdiskId, refreshTianyiNetdiskSession } from '@/lib/netdisk/tianyi-session-cache';
|
||||
|
||||
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 sessionId = searchParams.get('id') || searchParams.get('session');
|
||||
const episodeIndexRaw = searchParams.get('episodeIndex');
|
||||
const format = searchParams.get('format');
|
||||
if (!sessionId || 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 config = await getConfig();
|
||||
const tianyiConfig = config.NetDiskConfig?.Tianyi;
|
||||
if (!tianyiConfig?.Enabled || !tianyiConfig.Account || !tianyiConfig.Password) {
|
||||
return NextResponse.json({ error: '天翼云盘未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
let session = refreshTianyiNetdiskSession(sessionId) || getTianyiNetdiskSession(sessionId);
|
||||
if (!session) {
|
||||
const payload = parseTianyiNetdiskId(sessionId);
|
||||
const result = await listTianyiShareVideos(
|
||||
payload.shareUrl,
|
||||
tianyiConfig.Account,
|
||||
tianyiConfig.Password,
|
||||
payload.passcode || ''
|
||||
);
|
||||
session = createTianyiNetdiskSession({
|
||||
title: result.title,
|
||||
shareUrl: payload.shareUrl,
|
||||
passcode: payload.passcode,
|
||||
shareId: result.shareId,
|
||||
shareMode: result.shareMode,
|
||||
isFolder: result.isFolder,
|
||||
accessCode: result.accessCode,
|
||||
files: result.files,
|
||||
});
|
||||
}
|
||||
|
||||
const file = session.files[episodeIndex];
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: '播放文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = await getTianyiSharePlayUrl(
|
||||
file.fileId,
|
||||
file.shareId,
|
||||
tianyiConfig.Account,
|
||||
tianyiConfig.Password
|
||||
);
|
||||
refreshTianyiNetdiskSession(sessionId);
|
||||
|
||||
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,7 +24,13 @@ import {
|
||||
parseQuarkNetdiskId,
|
||||
refreshQuarkNetdiskSession,
|
||||
} from '@/lib/netdisk/quark-session-cache';
|
||||
import { LEGACY_QUARK_TEMP_SOURCE, NETDISK_BAIDU_SOURCE, NETDISK_MOBILE_SOURCE, NETDISK_QUARK_SOURCE, normalizeNetdiskSource } from '@/lib/netdisk/source';
|
||||
import {
|
||||
createTianyiNetdiskSession,
|
||||
getTianyiNetdiskSession,
|
||||
parseTianyiNetdiskId,
|
||||
refreshTianyiNetdiskSession,
|
||||
} from '@/lib/netdisk/tianyi-session-cache';
|
||||
import { LEGACY_QUARK_TEMP_SOURCE, NETDISK_BAIDU_SOURCE, NETDISK_MOBILE_SOURCE, NETDISK_QUARK_SOURCE, NETDISK_TIANYI_SOURCE, normalizeNetdiskSource } from '@/lib/netdisk/source';
|
||||
import {
|
||||
executeSavedSourceScript,
|
||||
normalizeScriptDetailResult,
|
||||
@@ -436,6 +442,84 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceCode === NETDISK_TIANYI_SOURCE) {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const tianyiConfig = config.NetDiskConfig?.Tianyi;
|
||||
if (!tianyiConfig?.Enabled || !tianyiConfig.Account || !tianyiConfig.Password) {
|
||||
throw new Error('天翼云盘未配置或未启用');
|
||||
}
|
||||
|
||||
let session = refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id);
|
||||
if (!session) {
|
||||
const payload = parseTianyiNetdiskId(id);
|
||||
const { listTianyiShareVideos } = await import('@/lib/netdisk/tianyi.client');
|
||||
const result = await listTianyiShareVideos(
|
||||
payload.shareUrl,
|
||||
tianyiConfig.Account,
|
||||
tianyiConfig.Password,
|
||||
payload.passcode || ''
|
||||
);
|
||||
session = createTianyiNetdiskSession({
|
||||
title: title || result.title,
|
||||
shareUrl: payload.shareUrl,
|
||||
passcode: payload.passcode,
|
||||
shareId: result.shareId,
|
||||
shareMode: result.shareMode,
|
||||
isFolder: result.isFolder,
|
||||
accessCode: result.accessCode,
|
||||
files: result.files,
|
||||
});
|
||||
}
|
||||
if (!session) {
|
||||
throw new Error('天翼云盘播放信息恢复失败');
|
||||
}
|
||||
|
||||
const tianyiSession = session;
|
||||
const { parseVideoFileName } = await import('@/lib/video-parser');
|
||||
const parsedFiles = tianyiSession.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_TIANYI_SOURCE,
|
||||
source_name: '天翼云盘',
|
||||
id: tianyiSession.id,
|
||||
title: title || tianyiSession.title,
|
||||
poster: '',
|
||||
year: '',
|
||||
douban_id: 0,
|
||||
desc: `天翼云盘分享:${tianyiSession.shareUrl}`,
|
||||
episodes: parsedFiles.map((file) => (
|
||||
`/api/netdisk/tianyi/play?id=${encodeURIComponent(tianyiSession.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();
|
||||
|
||||
@@ -168,7 +168,9 @@ export default function PansouSearch({
|
||||
? '/api/netdisk/mobile/instant-play'
|
||||
: cloudType === 'baidu'
|
||||
? '/api/netdisk/baidu/instant-play'
|
||||
: '/api/netdisk/quark/instant-play';
|
||||
: cloudType === 'tianyi'
|
||||
? '/api/netdisk/tianyi/instant-play'
|
||||
: '/api/netdisk/quark/instant-play';
|
||||
const response = await fetch(instantPlayApi, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -187,7 +189,7 @@ export default function PansouSearch({
|
||||
}
|
||||
|
||||
router.push(
|
||||
`/play?source=${encodeURIComponent(data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : cloudType === 'baidu' ? 'netdisk-baidu' : 'netdisk-quark'))}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
|
||||
`/play?source=${encodeURIComponent(data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : cloudType === 'baidu' ? 'netdisk-baidu' : cloudType === 'tianyi' ? 'netdisk-tianyi' : 'netdisk-quark'))}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(data.title || keyword)}`
|
||||
);
|
||||
} catch (err: any) {
|
||||
setToast({
|
||||
@@ -345,7 +347,7 @@ export default function PansouSearch({
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu') && (
|
||||
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi') && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleNetdiskInstantPlay(cloudType, link)}
|
||||
|
||||
@@ -49,9 +49,9 @@ export default function Toast({ message, type = 'info', duration = 3000, onClose
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||
}`}
|
||||
>
|
||||
<div className={`${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px]`}>
|
||||
<div className={`${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-start gap-3 min-w-[300px] max-w-[min(90vw,560px)]`}>
|
||||
<div className="flex-shrink-0">{icons[type]}</div>
|
||||
<div className="flex-1 text-sm font-medium">{message}</div>
|
||||
<div className="flex-1 text-sm font-medium whitespace-pre-wrap break-words">{message}</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 hover:bg-white/20 rounded p-1 transition-colors"
|
||||
|
||||
@@ -160,6 +160,11 @@ export interface AdminConfig {
|
||||
Enabled: boolean;
|
||||
Cookie: string;
|
||||
};
|
||||
Tianyi?: {
|
||||
Enabled: boolean;
|
||||
Account: string;
|
||||
Password: string;
|
||||
};
|
||||
};
|
||||
AIConfig?: {
|
||||
Enabled: boolean; // 是否启用AI问片功能
|
||||
|
||||
@@ -678,6 +678,11 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
Enabled: false,
|
||||
Cookie: '',
|
||||
},
|
||||
Tianyi: {
|
||||
Enabled: false,
|
||||
Account: '',
|
||||
Password: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -703,6 +708,14 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
};
|
||||
}
|
||||
|
||||
if (!adminConfig.NetDiskConfig.Tianyi) {
|
||||
adminConfig.NetDiskConfig.Tianyi = {
|
||||
Enabled: false,
|
||||
Account: '',
|
||||
Password: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 确保音乐配置存在
|
||||
if (!adminConfig.MusicConfig) {
|
||||
adminConfig.MusicConfig = {
|
||||
|
||||
@@ -2,8 +2,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 const NETDISK_TIANYI_SOURCE = 'netdisk-tianyi';
|
||||
|
||||
export type NetdiskProvider = 'quark' | 'mobile' | 'baidu';
|
||||
export type NetdiskProvider = 'quark' | 'mobile' | 'baidu' | 'tianyi';
|
||||
|
||||
export function normalizeNetdiskSource(source?: string | null): string {
|
||||
if (!source) return '';
|
||||
@@ -13,7 +14,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;
|
||||
return normalized === NETDISK_QUARK_SOURCE || normalized === NETDISK_MOBILE_SOURCE || normalized === NETDISK_BAIDU_SOURCE || normalized === NETDISK_TIANYI_SOURCE;
|
||||
}
|
||||
|
||||
export function getNetdiskProvider(source?: string | null): NetdiskProvider | null {
|
||||
@@ -21,6 +22,7 @@ export function getNetdiskProvider(source?: string | null): NetdiskProvider | nu
|
||||
if (normalized === NETDISK_QUARK_SOURCE) return 'quark';
|
||||
if (normalized === NETDISK_MOBILE_SOURCE) return 'mobile';
|
||||
if (normalized === NETDISK_BAIDU_SOURCE) return 'baidu';
|
||||
if (normalized === NETDISK_TIANYI_SOURCE) return 'tianyi';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -35,3 +37,7 @@ export function isNetdiskMobileSource(source?: string | null): boolean {
|
||||
export function isNetdiskBaiduSource(source?: string | null): boolean {
|
||||
return normalizeNetdiskSource(source) === NETDISK_BAIDU_SOURCE;
|
||||
}
|
||||
|
||||
export function isNetdiskTianyiSource(source?: string | null): boolean {
|
||||
return normalizeNetdiskSource(source) === NETDISK_TIANYI_SOURCE;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { base58Decode, base58Encode } from '@/lib/utils';
|
||||
|
||||
export interface TianyiNetdiskSessionFile {
|
||||
name: string;
|
||||
fileId: string;
|
||||
shareId: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface TianyiNetdiskSession {
|
||||
id: string;
|
||||
provider: 'tianyi';
|
||||
title: string;
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
shareId: string;
|
||||
shareMode: string;
|
||||
isFolder: string | number | boolean;
|
||||
accessCode: string;
|
||||
files: TianyiNetdiskSessionFile[];
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
const sessionStore = new Map<string, TianyiNetdiskSession>();
|
||||
|
||||
export function buildTianyiNetdiskId(input: { shareUrl: string; passcode?: string }): string {
|
||||
return base58Encode(JSON.stringify({ shareUrl: input.shareUrl, passcode: input.passcode || '' }));
|
||||
}
|
||||
|
||||
export function parseTianyiNetdiskId(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 tianyi 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 createTianyiNetdiskSession(input: {
|
||||
title: string;
|
||||
shareUrl: string;
|
||||
passcode?: string;
|
||||
shareId: string;
|
||||
shareMode: string;
|
||||
isFolder: string | number | boolean;
|
||||
accessCode: string;
|
||||
files: TianyiNetdiskSessionFile[];
|
||||
}) {
|
||||
pruneExpiredSessions();
|
||||
const now = Date.now();
|
||||
const id = buildTianyiNetdiskId({ shareUrl: input.shareUrl, passcode: input.passcode });
|
||||
const session: TianyiNetdiskSession = {
|
||||
id,
|
||||
provider: 'tianyi',
|
||||
title: input.title,
|
||||
shareUrl: input.shareUrl,
|
||||
passcode: input.passcode,
|
||||
shareId: input.shareId,
|
||||
shareMode: input.shareMode,
|
||||
isFolder: input.isFolder,
|
||||
accessCode: input.accessCode,
|
||||
files: input.files,
|
||||
createdAt: now,
|
||||
expiresAt: now + TTL_MS,
|
||||
};
|
||||
sessionStore.set(id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function getTianyiNetdiskSession(id: string): TianyiNetdiskSession | 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 refreshTianyiNetdiskSession(id: string): TianyiNetdiskSession | null {
|
||||
const session = getTianyiNetdiskSession(id);
|
||||
if (!session) return null;
|
||||
session.expiresAt = Date.now() + TTL_MS;
|
||||
sessionStore.set(id, session);
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
import { listTianyiShareVideos } from './tianyi.client';
|
||||
import {
|
||||
createTianyiNetdiskSession,
|
||||
getTianyiNetdiskSession,
|
||||
parseTianyiNetdiskId,
|
||||
refreshTianyiNetdiskSession,
|
||||
} from './tianyi-session-cache';
|
||||
|
||||
export async function resolveTianyiSession(id: string) {
|
||||
const config = await getConfig();
|
||||
const tianyiConfig = config.NetDiskConfig?.Tianyi;
|
||||
if (!tianyiConfig?.Enabled || !tianyiConfig.Account || !tianyiConfig.Password) {
|
||||
throw new Error('天翼云盘未配置或未启用');
|
||||
}
|
||||
|
||||
let session = refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id);
|
||||
if (!session) {
|
||||
const payload = parseTianyiNetdiskId(id);
|
||||
const result = await listTianyiShareVideos(
|
||||
payload.shareUrl,
|
||||
tianyiConfig.Account,
|
||||
tianyiConfig.Password,
|
||||
payload.passcode || ''
|
||||
);
|
||||
session = createTianyiNetdiskSession({
|
||||
title: result.title,
|
||||
shareUrl: payload.shareUrl,
|
||||
passcode: payload.passcode,
|
||||
shareId: result.shareId,
|
||||
shareMode: result.shareMode,
|
||||
isFolder: result.isFolder,
|
||||
accessCode: result.accessCode,
|
||||
files: result.files,
|
||||
});
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error('天翼云盘播放信息恢复失败');
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
account: tianyiConfig.Account,
|
||||
password: tianyiConfig.Password,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { constants,publicEncrypt } from 'crypto';
|
||||
|
||||
export interface TianyiShareVideoFile {
|
||||
name: string;
|
||||
fileId: string;
|
||||
shareId: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface TianyiShareListResult {
|
||||
title: string;
|
||||
files: TianyiShareVideoFile[];
|
||||
shareId: string;
|
||||
shareMode: string;
|
||||
isFolder: string | number | boolean;
|
||||
accessCode: string;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://cloud.189.cn/api';
|
||||
const LOGIN_URL = 'https://open.e.189.cn';
|
||||
const VIDEO_EXTENSIONS = [
|
||||
'.mp4', '.mkv', '.avi', '.mov', '.flv', '.wmv', '.m3u8', '.ts', '.rmvb', '.rm', '.mpeg', '.mpg', '.m4v', '.webm',
|
||||
];
|
||||
|
||||
const NORMAL_HEADERS: HeadersInit = {
|
||||
'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: 'application/json;charset=UTF-8',
|
||||
};
|
||||
|
||||
const loginCookieStore = new Map<string, { cookie: string; expiresAt: number }>();
|
||||
const LOGIN_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export function normalizeTianyiAccount(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function normalizeTianyiPassword(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function makeLoginCacheKey(account: string, password: string) {
|
||||
return `${account}\n${password}`;
|
||||
}
|
||||
|
||||
export function clearTianyiLoginCache(account: string, password: string) {
|
||||
loginCookieStore.delete(
|
||||
makeLoginCacheKey(normalizeTianyiAccount(account), normalizeTianyiPassword(password))
|
||||
);
|
||||
}
|
||||
|
||||
function pruneLoginCookies() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of Array.from(loginCookieStore.entries())) {
|
||||
if (value.expiresAt <= now) {
|
||||
loginCookieStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertHeaderSafe(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;
|
||||
}
|
||||
|
||||
function isVideoFile(name: string) {
|
||||
const lower = name.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
function extractSetCookies(response: Response): string[] {
|
||||
const headersAny = response.headers as Headers & {
|
||||
getSetCookie?: () => string[];
|
||||
};
|
||||
if (typeof headersAny.getSetCookie === 'function') {
|
||||
return headersAny.getSetCookie();
|
||||
}
|
||||
const raw = response.headers.get('set-cookie');
|
||||
return raw ? [raw] : [];
|
||||
}
|
||||
|
||||
function mergeCookies(...cookieGroups: Array<string | string[] | undefined>) {
|
||||
const map = new Map<string, string>();
|
||||
for (const group of cookieGroups) {
|
||||
const list = Array.isArray(group) ? group : group ? [group] : [];
|
||||
for (const item of list) {
|
||||
for (const chunk of item.split(/,(?=[^;,]+=)/g)) {
|
||||
const pair = chunk.split(';')[0]?.trim();
|
||||
if (!pair || !pair.includes('=')) continue;
|
||||
const index = pair.indexOf('=');
|
||||
const key = pair.slice(0, index);
|
||||
const value = pair.slice(index + 1);
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries()).map(([key, value]) => `${key}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
function toFormBody(data: Record<string, any>) {
|
||||
return new URLSearchParams(
|
||||
Object.entries(data).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
acc[key] = String(value);
|
||||
return acc;
|
||||
}, {})
|
||||
).toString();
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T = any>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
try {
|
||||
const normalized = text.replace(/(:\s*)(-?\d{15,})(\s*[,}])/g, '$1"$2"$3');
|
||||
return JSON.parse(normalized) as T;
|
||||
} catch {
|
||||
throw new Error(`天翼云盘接口返回异常:${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function encryptCredential(value: string, pubKey: string) {
|
||||
const encrypted = publicEncrypt(
|
||||
{
|
||||
key: `-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`,
|
||||
padding: constants.RSA_PKCS1_PADDING,
|
||||
},
|
||||
Buffer.from(value, 'utf8')
|
||||
);
|
||||
return `{NRP}${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function parseTianyiShareUrl(url: string, passcode = ''): { shareCode: string; accessCode: string } {
|
||||
const decoded = decodeURIComponent(url);
|
||||
const patterns = [
|
||||
/https:\/\/cloud\.189\.cn\/web\/share\?code=([A-Za-z0-9]+)/i,
|
||||
/https:\/\/cloud\.189\.cn\/t\/([A-Za-z0-9]+)/i,
|
||||
/https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\/([A-Za-z0-9]+)/i,
|
||||
];
|
||||
let shareCode = '';
|
||||
let rawCode = '';
|
||||
for (const pattern of patterns) {
|
||||
const matched = decoded.match(pattern);
|
||||
if (matched?.[1]) {
|
||||
rawCode = matched[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!rawCode) {
|
||||
throw new Error('无法解析天翼云盘分享链接');
|
||||
}
|
||||
|
||||
const cleanMatch = rawCode.match(/^([A-Za-z0-9]+)/);
|
||||
shareCode = cleanMatch?.[1] || rawCode.trim();
|
||||
|
||||
const pwdMatch = decoded.match(/[?&]pwd=([^&]+)/i);
|
||||
const inlineAccessCodeMatch1 = rawCode.match(/[((]\s*访问码[::]\s*([A-Za-z0-9]+)\s*[))]/i);
|
||||
const inlineAccessCodeMatch2 = rawCode.match(/\s+访问码[::]\s*([A-Za-z0-9]+)/i);
|
||||
|
||||
return {
|
||||
shareCode,
|
||||
accessCode: passcode || pwdMatch?.[1] || inlineAccessCodeMatch1?.[1] || inlineAccessCodeMatch2?.[1] || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchLoginCookie(account: string, password: string) {
|
||||
const safeAccount = assertHeaderSafe(normalizeTianyiAccount(account), '天翼云盘账号');
|
||||
const safePassword = assertHeaderSafe(normalizeTianyiPassword(password), '天翼云盘密码');
|
||||
const cacheKey = makeLoginCacheKey(safeAccount, safePassword);
|
||||
pruneLoginCookies();
|
||||
const cached = loginCookieStore.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.cookie;
|
||||
}
|
||||
|
||||
const encryptConfResp = await fetch(`${LOGIN_URL}/api/logbox/config/encryptConf.do?appId=cloud`, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
});
|
||||
const encryptConf = await parseJsonResponse<any>(encryptConfResp);
|
||||
const pubKey = String(encryptConf?.data?.pubKey || '');
|
||||
if (!pubKey) {
|
||||
throw new Error('获取天翼云盘登录公钥失败');
|
||||
}
|
||||
|
||||
const loginUrlResp = await fetch(
|
||||
`${API_BASE}/portal/loginUrl.action?redirectURL=https://cloud.189.cn/web/redirect.html?returnURL=/main.action`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const finalUrl = loginUrlResp.url;
|
||||
const reqId = finalUrl.match(/reqId=(\w+)/)?.[1] || '';
|
||||
const lt = finalUrl.match(/lt=(\w+)/)?.[1] || '';
|
||||
if (!reqId || !lt) {
|
||||
throw new Error('获取天翼云盘登录参数失败');
|
||||
}
|
||||
|
||||
const loginHeaders: HeadersInit = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json;charset=UTF-8',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/76.0',
|
||||
Referer: 'https://open.e.189.cn/',
|
||||
Lt: lt,
|
||||
Reqid: reqId,
|
||||
};
|
||||
|
||||
const appConfResp = await fetch(`${LOGIN_URL}/api/logbox/oauth2/appConf.do`, {
|
||||
method: 'POST',
|
||||
headers: loginHeaders,
|
||||
body: toFormBody({ version: '2.0', appKey: 'cloud' }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const appConf = await parseJsonResponse<any>(appConfResp);
|
||||
const returnUrl = appConf?.data?.returnUrl;
|
||||
const paramId = appConf?.data?.paramId;
|
||||
if (!returnUrl || !paramId) {
|
||||
throw new Error('获取天翼云盘登录配置失败');
|
||||
}
|
||||
|
||||
const loginSubmitResp = await fetch(`${LOGIN_URL}/api/logbox/oauth2/loginSubmit.do`, {
|
||||
method: 'POST',
|
||||
headers: loginHeaders,
|
||||
body: toFormBody({
|
||||
appKey: 'cloud',
|
||||
version: '2.0',
|
||||
accountType: '01',
|
||||
mailSuffix: '@189.cn',
|
||||
validateCode: '',
|
||||
returnUrl,
|
||||
paramId,
|
||||
captchaToken: '',
|
||||
dynamicCheck: 'FALSE',
|
||||
clientType: '1',
|
||||
cb_SaveName: '0',
|
||||
isOauth2: false,
|
||||
userName: encryptCredential(safeAccount, pubKey),
|
||||
password: encryptCredential(safePassword, pubKey),
|
||||
}),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const loginSubmit = await parseJsonResponse<any>(loginSubmitResp);
|
||||
const toUrl = String(loginSubmit?.toUrl || '');
|
||||
if (!toUrl) {
|
||||
throw new Error(loginSubmit?.msg || loginSubmit?.message || '天翼云盘登录失败');
|
||||
}
|
||||
|
||||
const firstCookies = mergeCookies(extractSetCookies(loginSubmitResp));
|
||||
const redirectResp = await fetch(toUrl, {
|
||||
headers: {
|
||||
Cookie: firstCookies,
|
||||
Referer: 'https://m.cloud.189.cn/',
|
||||
},
|
||||
redirect: 'manual',
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const finalCookie = mergeCookies(firstCookies, extractSetCookies(redirectResp));
|
||||
if (!finalCookie) {
|
||||
throw new Error('天翼云盘登录未获取到 Cookie');
|
||||
}
|
||||
|
||||
loginCookieStore.set(cacheKey, {
|
||||
cookie: finalCookie,
|
||||
expiresAt: Date.now() + LOGIN_TTL_MS,
|
||||
});
|
||||
return finalCookie;
|
||||
}
|
||||
|
||||
async function fetchJsonWithRetry<T = any>(url: string, options?: RequestInit): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`天翼云盘接口请求失败 (${response.status}): ${url}`);
|
||||
}
|
||||
return await parseJsonResponse<T>(response);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error('天翼云盘接口请求失败');
|
||||
}
|
||||
|
||||
async function getShareInfo(input: { shareCode: string; accessCode: string }) {
|
||||
let info: any = null;
|
||||
|
||||
if (input.accessCode) {
|
||||
try {
|
||||
await fetchJsonWithRetry(
|
||||
`${API_BASE}/open/share/checkAccessCode.action?shareCode=${encodeURIComponent(input.shareCode)}&accessCode=${encodeURIComponent(input.accessCode)}`,
|
||||
{ headers: NORMAL_HEADERS }
|
||||
);
|
||||
} catch {
|
||||
// 部分分享即使带了提取码文本,checkAccessCode 也可能直接 400;
|
||||
// 这里不立刻失败,继续尝试直接取分享信息。
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const infoUrl = input.accessCode
|
||||
? `${API_BASE}/open/share/getShareInfoByCodeV2.action?key=noCache&shareCode=${encodeURIComponent(input.shareCode)}`
|
||||
: `${API_BASE}/open/share/getShareInfoByCodeV2.action?noCache=${Math.random()}&shareCode=${encodeURIComponent(input.shareCode)}`;
|
||||
info = await fetchJsonWithRetry<any>(infoUrl, { headers: NORMAL_HEADERS });
|
||||
} catch (error) {
|
||||
if (input.accessCode) {
|
||||
throw new Error(`获取天翼云盘分享信息失败,可能是访问码错误或链接已失效:${error instanceof Error ? error.message : 'unknown error'}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
fileId: String(info?.fileId || ''),
|
||||
shareId: String(info?.shareId || ''),
|
||||
shareMode: String(info?.shareMode || ''),
|
||||
isFolder: info?.isFolder,
|
||||
fileName: String(info?.fileName || ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function listShareDir(input: {
|
||||
fileId: string;
|
||||
shareId: string;
|
||||
shareMode: string;
|
||||
isFolder: string | number | boolean;
|
||||
accessCode: string;
|
||||
pageNum?: number;
|
||||
}) {
|
||||
const pageNum = input.pageNum || 1;
|
||||
const isFolderCandidates = Array.from(
|
||||
new Set([
|
||||
String(input.isFolder),
|
||||
input.isFolder === true ? '1' : '',
|
||||
input.isFolder === false ? '0' : '',
|
||||
].filter(Boolean))
|
||||
);
|
||||
|
||||
const accessCodeCandidates = Array.from(
|
||||
new Set(input.accessCode ? [input.accessCode, ''] : [''])
|
||||
);
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const isFolderValue of isFolderCandidates) {
|
||||
for (const accessCodeValue of accessCodeCandidates) {
|
||||
const baseParams = new URLSearchParams({
|
||||
pageNum: String(pageNum),
|
||||
pageSize: '60',
|
||||
fileId: input.fileId,
|
||||
shareDirFileId: input.fileId,
|
||||
isFolder: isFolderValue,
|
||||
shareId: input.shareId,
|
||||
shareMode: input.shareMode,
|
||||
iconOption: '5',
|
||||
orderBy: 'filename',
|
||||
descending: 'false',
|
||||
});
|
||||
if (accessCodeValue) {
|
||||
baseParams.set('accessCode', accessCodeValue);
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
`${API_BASE}/open/share/listShareDir.action?key=noCache&${baseParams.toString()}&noCache=${Math.random()}`,
|
||||
`${API_BASE}/open/share/listShareDir.action?${baseParams.toString()}`,
|
||||
`${API_BASE}/open/share/listShareDir.action?pageNum=${pageNum}&pageSize=60&fileId=${encodeURIComponent(input.fileId)}&shareDirFileId=${encodeURIComponent(input.fileId)}&isFolder=${encodeURIComponent(isFolderValue)}&shareId=${encodeURIComponent(input.shareId)}&shareMode=${encodeURIComponent(input.shareMode)}&iconOption=5&orderBy=lastOpTime&descending=true${accessCodeValue ? `&accessCode=${encodeURIComponent(accessCodeValue)}` : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
return await fetchJsonWithRetry<any>(url, { headers: NORMAL_HEADERS });
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
throw new Error(`获取天翼云盘目录失败:${errors[errors.length - 1] || 'unknown error'}`);
|
||||
}
|
||||
|
||||
async function collectShareFiles(input: {
|
||||
fileId: string;
|
||||
shareId: string;
|
||||
shareMode: string;
|
||||
isFolder: string | number | boolean;
|
||||
accessCode: string;
|
||||
}): Promise<TianyiShareVideoFile[]> {
|
||||
const result: TianyiShareVideoFile[] = [];
|
||||
const stack = [input.fileId];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentFileId = stack.pop();
|
||||
if (!currentFileId) {
|
||||
continue;
|
||||
}
|
||||
let pageNum = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const data = await listShareDir({
|
||||
...input,
|
||||
fileId: currentFileId,
|
||||
pageNum,
|
||||
});
|
||||
const fileListAO = data?.fileListAO || {};
|
||||
const folderList = Array.isArray(fileListAO.folderList) ? fileListAO.folderList : [];
|
||||
const fileList = Array.isArray(fileListAO.fileList) ? fileListAO.fileList : [];
|
||||
|
||||
folderList.forEach((item: any) => {
|
||||
if (item?.id) {
|
||||
stack.push(String(item.id));
|
||||
}
|
||||
});
|
||||
|
||||
fileList.forEach((item: any) => {
|
||||
const name = String(item?.name || '');
|
||||
const isVideo = item?.mediaType === 3 || isVideoFile(name);
|
||||
if (!isVideo) return;
|
||||
result.push({
|
||||
name,
|
||||
fileId: String(item?.id || ''),
|
||||
shareId: input.shareId,
|
||||
size: Number(item?.size || 0),
|
||||
});
|
||||
});
|
||||
|
||||
const totalCount = Number(fileListAO?.count || 0);
|
||||
if (totalCount <= pageNum * 60) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
pageNum += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
.filter((item) => item.fileId)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
export async function listTianyiShareVideos(shareUrl: string, account: string, password: string, passcode = ''): Promise<TianyiShareListResult> {
|
||||
normalizeTianyiAccount(account);
|
||||
normalizeTianyiPassword(password);
|
||||
const parsed = parseTianyiShareUrl(shareUrl, passcode);
|
||||
const info = await getShareInfo(parsed);
|
||||
if (!info.fileId || !info.shareId) {
|
||||
throw new Error('获取天翼云盘分享信息失败');
|
||||
}
|
||||
const files = await collectShareFiles({
|
||||
fileId: info.fileId,
|
||||
shareId: info.shareId,
|
||||
shareMode: info.shareMode,
|
||||
isFolder: info.isFolder,
|
||||
accessCode: parsed.accessCode,
|
||||
});
|
||||
if (files.length === 0) {
|
||||
throw new Error('天翼云盘分享中没有视频文件');
|
||||
}
|
||||
return {
|
||||
title: info.fileName || (files.length === 1 ? files[0].name.replace(/\.[^.]+$/, '') : '天翼云盘立即播放'),
|
||||
files,
|
||||
shareId: info.shareId,
|
||||
shareMode: info.shareMode,
|
||||
isFolder: info.isFolder,
|
||||
accessCode: parsed.accessCode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTianyiSharePlayUrl(
|
||||
fileId: string,
|
||||
shareId: string,
|
||||
account: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const cookie = await fetchLoginCookie(account, password);
|
||||
const response = await fetch(
|
||||
`${API_BASE}/portal/getNewVlcVideoPlayUrl.action?shareId=${encodeURIComponent(shareId)}&dt=1&fileId=${encodeURIComponent(fileId)}&type=4&key=noCache`,
|
||||
{
|
||||
headers: {
|
||||
...NORMAL_HEADERS,
|
||||
Cookie: cookie,
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
if (response.status === 400 && attempt < 2) {
|
||||
clearTianyiLoginCache(account, password);
|
||||
lastError = new Error(`获取天翼云盘播放地址失败 (${response.status})`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`获取天翼云盘播放地址失败 (${response.status})${errorText ? `: ${errorText.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
|
||||
const data = await parseJsonResponse<any>(response);
|
||||
const rawUrl = String(data?.normal?.url || data?.url || '');
|
||||
if (!rawUrl) {
|
||||
throw new Error('未获取到天翼云盘播放地址');
|
||||
}
|
||||
|
||||
const redirectResponse = await fetch(rawUrl, {
|
||||
redirect: 'manual',
|
||||
cache: 'no-store',
|
||||
});
|
||||
return redirectResponse.headers.get('location') || rawUrl;
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('获取天翼云盘播放地址失败');
|
||||
}
|
||||
|
||||
export async function validateTianyiCredentials(account: string, password: string): Promise<void> {
|
||||
await fetchLoginCookie(account, password);
|
||||
}
|
||||
Reference in New Issue
Block a user