uc网盘在线播放
This commit is contained in:
@@ -3714,6 +3714,10 @@ const NetDiskConfigComponent = ({
|
|||||||
const [pan123Enabled, setPan123Enabled] = useState(false);
|
const [pan123Enabled, setPan123Enabled] = useState(false);
|
||||||
const [pan123Account, setPan123Account] = useState('');
|
const [pan123Account, setPan123Account] = useState('');
|
||||||
const [pan123Password, setPan123Password] = useState('');
|
const [pan123Password, setPan123Password] = useState('');
|
||||||
|
const [ucEnabled, setUcEnabled] = useState(false);
|
||||||
|
const [ucCookie, setUcCookie] = useState('');
|
||||||
|
const [ucToken, setUcToken] = useState('');
|
||||||
|
const [ucSavePath, setUcSavePath] = useState('/');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const quark = config?.NetDiskConfig?.Quark;
|
const quark = config?.NetDiskConfig?.Quark;
|
||||||
@@ -3731,6 +3735,10 @@ const NetDiskConfigComponent = ({
|
|||||||
setPan123Enabled(config?.NetDiskConfig?.Pan123?.Enabled || false);
|
setPan123Enabled(config?.NetDiskConfig?.Pan123?.Enabled || false);
|
||||||
setPan123Account(config?.NetDiskConfig?.Pan123?.Account || '');
|
setPan123Account(config?.NetDiskConfig?.Pan123?.Account || '');
|
||||||
setPan123Password(config?.NetDiskConfig?.Pan123?.Password || '');
|
setPan123Password(config?.NetDiskConfig?.Pan123?.Password || '');
|
||||||
|
setUcEnabled(config?.NetDiskConfig?.UC?.Enabled || false);
|
||||||
|
setUcCookie(config?.NetDiskConfig?.UC?.Cookie || '');
|
||||||
|
setUcToken(config?.NetDiskConfig?.UC?.Token || '');
|
||||||
|
setUcSavePath(config?.NetDiskConfig?.UC?.SavePath || '/');
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -3763,6 +3771,12 @@ const NetDiskConfigComponent = ({
|
|||||||
Account: pan123Account,
|
Account: pan123Account,
|
||||||
Password: pan123Password,
|
Password: pan123Password,
|
||||||
},
|
},
|
||||||
|
UC: {
|
||||||
|
Enabled: ucEnabled,
|
||||||
|
Cookie: ucCookie,
|
||||||
|
Token: ucToken,
|
||||||
|
SavePath: ucSavePath,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3918,6 +3932,36 @@ const NetDiskConfigComponent = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleValidateUC = async () => {
|
||||||
|
await withLoading('validateUCNetDisk', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/netdisk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'validate',
|
||||||
|
provider: 'uc',
|
||||||
|
UC: {
|
||||||
|
Cookie: ucCookie,
|
||||||
|
Token: ucToken,
|
||||||
|
SavePath: ucSavePath,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || '校验失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(data.message || 'UC Cookie 可读', showAlert);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '校验失败', showAlert);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
<details className='pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
@@ -4256,6 +4300,92 @@ const NetDiskConfigComponent = ({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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'>
|
||||||
|
UC网盘
|
||||||
|
</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'>
|
||||||
|
启用UC网盘
|
||||||
|
</h3>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
开启后,网盘搜索中的UC网盘资源会显示“立即播放”按钮
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='relative inline-flex items-center cursor-pointer'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
checked={ucEnabled}
|
||||||
|
onChange={(e) => setUcEnabled(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-green-300 dark:peer-focus:ring-green-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-green-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={ucCookie}
|
||||||
|
onChange={(e) => setUcCookie(e.target.value)}
|
||||||
|
disabled={!ucEnabled}
|
||||||
|
rows={5}
|
||||||
|
placeholder='粘贴 UC 网盘 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-green-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'>
|
||||||
|
Open API Token(可选)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={ucToken}
|
||||||
|
onChange={(e) => setUcToken(e.target.value)}
|
||||||
|
disabled={!ucEnabled}
|
||||||
|
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-green-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='text'
|
||||||
|
value={ucSavePath}
|
||||||
|
onChange={(e) => setUcSavePath(e.target.value)}
|
||||||
|
disabled={!ucEnabled}
|
||||||
|
placeholder='/影视/UC临时转存'
|
||||||
|
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-green-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-3'>
|
||||||
|
<button
|
||||||
|
onClick={handleValidateUC}
|
||||||
|
disabled={!ucEnabled || !ucCookie || isLoading('validateUCNetDisk')}
|
||||||
|
className={buttonStyles.primary}
|
||||||
|
>
|
||||||
|
{isLoading('validateUCNetDisk') ? '校验中...' : '校验UC配置'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading('saveNetDisk')}
|
||||||
|
className={buttonStyles.success}
|
||||||
|
>
|
||||||
|
{isLoading('saveNetDisk') ? '保存中...' : '保存配置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={alertModal.isOpen}
|
isOpen={alertModal.isOpen}
|
||||||
onClose={hideAlert}
|
onClose={hideAlert}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
validateQuarkCookieReadable,
|
validateQuarkCookieReadable,
|
||||||
} from '@/lib/netdisk/quark.client';
|
} from '@/lib/netdisk/quark.client';
|
||||||
import { normalizeTianyiAccount, normalizeTianyiPassword, validateTianyiCredentials } from '@/lib/netdisk/tianyi.client';
|
import { normalizeTianyiAccount, normalizeTianyiPassword, validateTianyiCredentials } from '@/lib/netdisk/tianyi.client';
|
||||||
|
import {
|
||||||
|
assertUCCookieHeaderSafe,
|
||||||
|
normalizeUCCookie,
|
||||||
|
validateUCCookieReadable,
|
||||||
|
} from '@/lib/netdisk/uc.client';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action, Quark, Mobile, Baidu, Tianyi, Pan123, provider } = body;
|
const { action, Quark, Mobile, Baidu, Tianyi, Pan123, UC, provider } = body;
|
||||||
const adminConfig = await getConfig();
|
const adminConfig = await getConfig();
|
||||||
|
|
||||||
if (action === 'save') {
|
if (action === 'save') {
|
||||||
@@ -64,6 +69,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const normalizedTianyiPassword = Tianyi?.Password ? normalizeTianyiPassword(Tianyi.Password) : '';
|
const normalizedTianyiPassword = Tianyi?.Password ? normalizeTianyiPassword(Tianyi.Password) : '';
|
||||||
const normalizedPan123Account = Pan123?.Account ? normalizePan123Account(Pan123.Account) : '';
|
const normalizedPan123Account = Pan123?.Account ? normalizePan123Account(Pan123.Account) : '';
|
||||||
const normalizedPan123Password = Pan123?.Password ? normalizePan123Password(Pan123.Password) : '';
|
const normalizedPan123Password = Pan123?.Password ? normalizePan123Password(Pan123.Password) : '';
|
||||||
|
const normalizedUCCookie = UC?.Cookie ? assertUCCookieHeaderSafe(UC.Cookie) : '';
|
||||||
|
const normalizedUCToken = UC?.Token ? String(UC.Token).trim() : '';
|
||||||
|
|
||||||
adminConfig.NetDiskConfig = adminConfig.NetDiskConfig || {};
|
adminConfig.NetDiskConfig = adminConfig.NetDiskConfig || {};
|
||||||
adminConfig.NetDiskConfig.Quark = {
|
adminConfig.NetDiskConfig.Quark = {
|
||||||
@@ -89,6 +96,12 @@ export async function POST(request: NextRequest) {
|
|||||||
Account: normalizedPan123Account,
|
Account: normalizedPan123Account,
|
||||||
Password: normalizedPan123Password,
|
Password: normalizedPan123Password,
|
||||||
};
|
};
|
||||||
|
adminConfig.NetDiskConfig.UC = {
|
||||||
|
Enabled: Boolean(UC?.Enabled),
|
||||||
|
Cookie: normalizedUCCookie,
|
||||||
|
Token: normalizedUCToken,
|
||||||
|
SavePath: UC?.SavePath || '/',
|
||||||
|
};
|
||||||
|
|
||||||
await db.saveAdminConfig(adminConfig);
|
await db.saveAdminConfig(adminConfig);
|
||||||
await setCachedConfig(adminConfig);
|
await setCachedConfig(adminConfig);
|
||||||
@@ -144,6 +157,16 @@ export async function POST(request: NextRequest) {
|
|||||||
message: '123网盘账号密码可用',
|
message: '123网盘账号密码可用',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (provider === 'uc') {
|
||||||
|
if (!UC?.Cookie) {
|
||||||
|
return NextResponse.json({ error: '请先填写UC Cookie' }, { status: 400 });
|
||||||
|
}
|
||||||
|
await validateUCCookieReadable(normalizeUCCookie(UC.Cookie));
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'UC Cookie 可读',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!Quark?.Cookie) {
|
if (!Quark?.Cookie) {
|
||||||
return NextResponse.json({ error: '请先填写夸克 Cookie' }, { status: 400 });
|
return NextResponse.json({ error: '请先填写夸克 Cookie' }, { status: 400 });
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { NETDISK_UC_SOURCE } from '@/lib/netdisk/source';
|
||||||
|
import { listUCShareVideos } from '@/lib/netdisk/uc.client';
|
||||||
|
import { createUCNetdiskSession } from '@/lib/netdisk/uc-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 ucConfig = config.NetDiskConfig?.UC;
|
||||||
|
if (!ucConfig?.Enabled || !ucConfig.Cookie) {
|
||||||
|
return NextResponse.json({ error: 'UC网盘未配置或未启用' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await listUCShareVideos(shareUrl, ucConfig.Cookie, passcode || '');
|
||||||
|
const session = createUCNetdiskSession({
|
||||||
|
title: title || result.title,
|
||||||
|
shareUrl,
|
||||||
|
passcode,
|
||||||
|
shareId: result.shareId,
|
||||||
|
shareToken: result.shareToken,
|
||||||
|
files: result.files,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
source: NETDISK_UC_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,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getUCNetdiskSession, refreshUCNetdiskSession } from '@/lib/netdisk/uc-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 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/uc/proxy?id=${encodeURIComponent(id)}&episodeIndex=${episodeIndex}`;
|
||||||
|
refreshUCNetdiskSession(id) || getUCNetdiskSession(id);
|
||||||
|
|
||||||
|
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,144 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { ensureUCPlayFolder, getUCPlayUrls, saveUCShareFile } from '@/lib/netdisk/uc.client';
|
||||||
|
import { refreshUCNetdiskSession } from '@/lib/netdisk/uc-session-cache';
|
||||||
|
import { resolveUCSession } from '@/lib/netdisk/uc-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');
|
||||||
|
const quality = searchParams.get('quality') || '';
|
||||||
|
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, token, savePath } = await resolveUCSession(id);
|
||||||
|
const file = session.files[episodeIndex];
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: '播放文件不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.playFolderFid || !session.playFolderPath) {
|
||||||
|
const folder = await ensureUCPlayFolder(cookie, savePath, session.shareId, session.title);
|
||||||
|
session.playFolderFid = folder.folderFid;
|
||||||
|
session.playFolderPath = folder.folderPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let savedFileId = session.savedFileIds[file.fid];
|
||||||
|
if (!savedFileId) {
|
||||||
|
savedFileId = await saveUCShareFile(cookie, {
|
||||||
|
shareId: session.shareId,
|
||||||
|
shareToken: session.shareToken,
|
||||||
|
fileId: file.fid,
|
||||||
|
shareFileToken: file.shareFidToken,
|
||||||
|
playFolderFid: session.playFolderFid,
|
||||||
|
});
|
||||||
|
session.savedFileIds[file.fid] = savedFileId;
|
||||||
|
}
|
||||||
|
refreshUCNetdiskSession(id);
|
||||||
|
|
||||||
|
const playUrls = await getUCPlayUrls(cookie, savedFileId, token);
|
||||||
|
const selected = playUrls.find((item) => item.name === quality) || playUrls[0];
|
||||||
|
if (!selected) {
|
||||||
|
return NextResponse.json({ error: '未获取到 UC 播放地址' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => abortController.abort(), 300000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(selected.url, {
|
||||||
|
headers: {
|
||||||
|
...(selected.headers || {}),
|
||||||
|
...(range ? { Range: range } : {}),
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `UC视频代理失败 (${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');
|
||||||
|
|
||||||
|
const { readable, writable } = new TransformStream();
|
||||||
|
const reader = upstream.body.getReader();
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const writer = writable.getWriter();
|
||||||
|
try {
|
||||||
|
let streamDone = false;
|
||||||
|
while (!streamDone) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
streamDone = true;
|
||||||
|
} else {
|
||||||
|
await writer.write(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await reader.cancel();
|
||||||
|
} catch {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await writer.close();
|
||||||
|
} catch {
|
||||||
|
void 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new Response(readable, {
|
||||||
|
status: range && upstream.headers.get('content-range') ? 206 : upstream.status,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
return NextResponse.json({ error: 'UC网盘代理超时' }, { status: 504 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'UC网盘代理失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,12 +18,28 @@ import {
|
|||||||
parseMobileNetdiskId,
|
parseMobileNetdiskId,
|
||||||
refreshMobileNetdiskSession,
|
refreshMobileNetdiskSession,
|
||||||
} from '@/lib/netdisk/mobile-session-cache';
|
} from '@/lib/netdisk/mobile-session-cache';
|
||||||
|
import {
|
||||||
|
createPan123NetdiskSession,
|
||||||
|
getPan123NetdiskSession,
|
||||||
|
parsePan123NetdiskId,
|
||||||
|
refreshPan123NetdiskSession,
|
||||||
|
} from '@/lib/netdisk/pan123-session-cache';
|
||||||
import {
|
import {
|
||||||
createQuarkNetdiskSession,
|
createQuarkNetdiskSession,
|
||||||
getQuarkNetdiskSession,
|
getQuarkNetdiskSession,
|
||||||
parseQuarkNetdiskId,
|
parseQuarkNetdiskId,
|
||||||
refreshQuarkNetdiskSession,
|
refreshQuarkNetdiskSession,
|
||||||
} from '@/lib/netdisk/quark-session-cache';
|
} from '@/lib/netdisk/quark-session-cache';
|
||||||
|
import {
|
||||||
|
LEGACY_QUARK_TEMP_SOURCE,
|
||||||
|
NETDISK_123_SOURCE,
|
||||||
|
NETDISK_BAIDU_SOURCE,
|
||||||
|
NETDISK_MOBILE_SOURCE,
|
||||||
|
NETDISK_QUARK_SOURCE,
|
||||||
|
NETDISK_TIANYI_SOURCE,
|
||||||
|
NETDISK_UC_SOURCE,
|
||||||
|
normalizeNetdiskSource,
|
||||||
|
} from '@/lib/netdisk/source';
|
||||||
import {
|
import {
|
||||||
createTianyiNetdiskSession,
|
createTianyiNetdiskSession,
|
||||||
getTianyiNetdiskSession,
|
getTianyiNetdiskSession,
|
||||||
@@ -31,12 +47,11 @@ import {
|
|||||||
refreshTianyiNetdiskSession,
|
refreshTianyiNetdiskSession,
|
||||||
} from '@/lib/netdisk/tianyi-session-cache';
|
} from '@/lib/netdisk/tianyi-session-cache';
|
||||||
import {
|
import {
|
||||||
createPan123NetdiskSession,
|
createUCNetdiskSession,
|
||||||
getPan123NetdiskSession,
|
getUCNetdiskSession,
|
||||||
parsePan123NetdiskId,
|
parseUCNetdiskId,
|
||||||
refreshPan123NetdiskSession,
|
refreshUCNetdiskSession,
|
||||||
} from '@/lib/netdisk/pan123-session-cache';
|
} from '@/lib/netdisk/uc-session-cache';
|
||||||
import { LEGACY_QUARK_TEMP_SOURCE, NETDISK_123_SOURCE, NETDISK_BAIDU_SOURCE, NETDISK_MOBILE_SOURCE, NETDISK_QUARK_SOURCE, NETDISK_TIANYI_SOURCE, normalizeNetdiskSource } from '@/lib/netdisk/source';
|
|
||||||
import {
|
import {
|
||||||
executeSavedSourceScript,
|
executeSavedSourceScript,
|
||||||
normalizeScriptDetailResult,
|
normalizeScriptDetailResult,
|
||||||
@@ -685,6 +700,76 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceCode === NETDISK_UC_SOURCE) {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
const ucConfig = config.NetDiskConfig?.UC;
|
||||||
|
if (!ucConfig?.Enabled || !ucConfig.Cookie) {
|
||||||
|
throw new Error('UC网盘未配置或未启用');
|
||||||
|
}
|
||||||
|
const { parseVideoFileName } = await import('@/lib/video-parser');
|
||||||
|
|
||||||
|
let session = refreshUCNetdiskSession(id) || getUCNetdiskSession(id);
|
||||||
|
if (!session) {
|
||||||
|
const payload = parseUCNetdiskId(id);
|
||||||
|
const { listUCShareVideos } = await import('@/lib/netdisk/uc.client');
|
||||||
|
const result = await listUCShareVideos(payload.shareUrl, ucConfig.Cookie, payload.passcode || '');
|
||||||
|
session = createUCNetdiskSession({
|
||||||
|
title: title || result.title,
|
||||||
|
shareUrl: payload.shareUrl,
|
||||||
|
passcode: payload.passcode,
|
||||||
|
shareId: result.shareId,
|
||||||
|
shareToken: result.shareToken,
|
||||||
|
files: result.files,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('UC网盘播放信息恢复失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ucSession = session;
|
||||||
|
const episodes = ucSession.files
|
||||||
|
.map((file, index) => {
|
||||||
|
const parsed = parseVideoFileName(file.name);
|
||||||
|
return {
|
||||||
|
originalIndex: index,
|
||||||
|
fileName: file.name,
|
||||||
|
episode: parsed.episode || index + 1,
|
||||||
|
title: formatNetdiskEpisodeTitle(parsed, file.name),
|
||||||
|
isOVA: parsed.isOVA,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isOVA && !b.isOVA) return 1;
|
||||||
|
if (!a.isOVA && b.isOVA) return -1;
|
||||||
|
return a.episode !== b.episode
|
||||||
|
? a.episode - b.episode
|
||||||
|
: a.fileName.localeCompare(b.fileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
source: NETDISK_UC_SOURCE,
|
||||||
|
source_name: 'UC网盘',
|
||||||
|
id: ucSession.id,
|
||||||
|
title: title || ucSession.title,
|
||||||
|
poster: '',
|
||||||
|
year: '',
|
||||||
|
douban_id: 0,
|
||||||
|
desc: `UC网盘分享:${ucSession.shareUrl}`,
|
||||||
|
episodes: episodes.map((ep) => (
|
||||||
|
`/api/netdisk/uc/play?id=${encodeURIComponent(ucSession.id)}&episodeIndex=${ep.originalIndex}`
|
||||||
|
)),
|
||||||
|
episodes_titles: episodes.map((ep) => ep.title),
|
||||||
|
proxyMode: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
||||||
if (sourceCode === 'openlist') {
|
if (sourceCode === 'openlist') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+18
-5
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
|
import { BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -47,8 +46,6 @@ function HomeClient() {
|
|||||||
>([]);
|
>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { announcement } = useSite();
|
const { announcement } = useSite();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// 首页模块配置状态
|
// 首页模块配置状态
|
||||||
const [homeModules, setHomeModules] = useState<HomeModule[]>([
|
const [homeModules, setHomeModules] = useState<HomeModule[]>([
|
||||||
{ id: 'hotMovies', name: '热门电影', enabled: true, order: 0 },
|
{ id: 'hotMovies', name: '热门电影', enabled: true, order: 0 },
|
||||||
@@ -75,7 +72,7 @@ function HomeClient() {
|
|||||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||||
|
|
||||||
const detectNetdiskLink = (url: string): {
|
const detectNetdiskLink = (url: string): {
|
||||||
provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123';
|
provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc';
|
||||||
shareUrl: string;
|
shareUrl: string;
|
||||||
passcode?: string;
|
passcode?: string;
|
||||||
} | null => {
|
} | null => {
|
||||||
@@ -134,6 +131,17 @@ function HomeClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (/https:\/\/drive\.uc\.cn\/s\//i.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
provider: 'uc',
|
||||||
|
shareUrl: trimmed,
|
||||||
|
passcode: pickPasscode(
|
||||||
|
trimmed.match(/[?&](?:pwd|passcode)=([^&]+)/i)?.[1],
|
||||||
|
inlinePasscode(trimmed)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (/https:\/\/(?:yun|caiyun)\.139\.com\//i.test(trimmed)) {
|
if (/https:\/\/(?:yun|caiyun)\.139\.com\//i.test(trimmed)) {
|
||||||
return { provider: 'mobile', shareUrl: trimmed };
|
return { provider: 'mobile', shareUrl: trimmed };
|
||||||
}
|
}
|
||||||
@@ -157,9 +165,11 @@ function HomeClient() {
|
|||||||
netdisk.provider === 'mobile'
|
netdisk.provider === 'mobile'
|
||||||
? 'netdisk-mobile'
|
? 'netdisk-mobile'
|
||||||
: netdisk.provider === 'baidu'
|
: netdisk.provider === 'baidu'
|
||||||
? 'netdisk-baidu'
|
? 'netdisk-baidu'
|
||||||
: netdisk.provider === 'tianyi'
|
: netdisk.provider === 'tianyi'
|
||||||
? 'netdisk-tianyi'
|
? 'netdisk-tianyi'
|
||||||
|
: netdisk.provider === 'uc'
|
||||||
|
? 'netdisk-uc'
|
||||||
: netdisk.provider === '123'
|
: netdisk.provider === '123'
|
||||||
? 'netdisk-123'
|
? 'netdisk-123'
|
||||||
: 'netdisk-quark';
|
: 'netdisk-quark';
|
||||||
@@ -849,6 +859,9 @@ function HomeClient() {
|
|||||||
<div className='text-sm text-gray-600 dark:text-gray-300'>
|
<div className='text-sm text-gray-600 dark:text-gray-300'>
|
||||||
请输入可直接播放的视频链接。
|
请输入可直接播放的视频链接。
|
||||||
</div>
|
</div>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
支持夸克、UC、百度、天翼、移动、123 网盘在线播放。
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
value={directPlayUrl}
|
value={directPlayUrl}
|
||||||
onChange={(event) => setDirectPlayUrl(event.target.value)}
|
onChange={(event) => setDirectPlayUrl(event.target.value)}
|
||||||
|
|||||||
@@ -2506,6 +2506,7 @@ function PlayPageClient() {
|
|||||||
newUrl.startsWith('/api/openlist/play') ||
|
newUrl.startsWith('/api/openlist/play') ||
|
||||||
newUrl.startsWith('/api/netdisk/123/play') ||
|
newUrl.startsWith('/api/netdisk/123/play') ||
|
||||||
newUrl.startsWith('/api/netdisk/quark/play') ||
|
newUrl.startsWith('/api/netdisk/quark/play') ||
|
||||||
|
newUrl.startsWith('/api/netdisk/uc/play') ||
|
||||||
newUrl.startsWith('/api/netdisk/baidu/play') ||
|
newUrl.startsWith('/api/netdisk/baidu/play') ||
|
||||||
newUrl.startsWith('/api/source-script/play');
|
newUrl.startsWith('/api/source-script/play');
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export default function PansouSearch({
|
|||||||
? '/api/netdisk/baidu/instant-play'
|
? '/api/netdisk/baidu/instant-play'
|
||||||
: cloudType === 'tianyi'
|
: cloudType === 'tianyi'
|
||||||
? '/api/netdisk/tianyi/instant-play'
|
? '/api/netdisk/tianyi/instant-play'
|
||||||
|
: cloudType === 'uc'
|
||||||
|
? '/api/netdisk/uc/instant-play'
|
||||||
: cloudType === '123'
|
: cloudType === '123'
|
||||||
? '/api/netdisk/123/instant-play'
|
? '/api/netdisk/123/instant-play'
|
||||||
: '/api/netdisk/quark/instant-play';
|
: '/api/netdisk/quark/instant-play';
|
||||||
@@ -181,7 +183,7 @@ export default function PansouSearch({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
shareUrl: link.url,
|
shareUrl: link.url,
|
||||||
passcode: link.password || '',
|
passcode: link.password || '',
|
||||||
title: link.note || keyword,
|
title: keyword,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,7 +193,7 @@ export default function PansouSearch({
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.push(
|
router.push(
|
||||||
`/play?source=${encodeURIComponent(data.source || (cloudType === 'mobile' ? 'netdisk-mobile' : cloudType === 'baidu' ? 'netdisk-baidu' : cloudType === 'tianyi' ? 'netdisk-tianyi' : cloudType === '123' ? 'netdisk-123' : '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' : cloudType === 'uc' ? 'netdisk-uc' : cloudType === '123' ? 'netdisk-123' : 'netdisk-quark'))}&id=${encodeURIComponent(data.id)}&title=${encodeURIComponent(keyword)}`
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setToast({
|
setToast({
|
||||||
@@ -349,7 +351,7 @@ export default function PansouSearch({
|
|||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className='flex items-center gap-1 flex-shrink-0'>
|
<div className='flex items-center gap-1 flex-shrink-0'>
|
||||||
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123') && (
|
{(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123' || cloudType === 'uc') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleNetdiskInstantPlay(cloudType, link)}
|
onClick={() => handleNetdiskInstantPlay(cloudType, link)}
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ export interface AdminConfig {
|
|||||||
Account: string;
|
Account: string;
|
||||||
Password: string;
|
Password: string;
|
||||||
};
|
};
|
||||||
|
UC?: {
|
||||||
|
Enabled: boolean;
|
||||||
|
Cookie: string;
|
||||||
|
Token?: string;
|
||||||
|
SavePath: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
AIConfig?: {
|
AIConfig?: {
|
||||||
Enabled: boolean; // 是否启用AI问片功能
|
Enabled: boolean; // 是否启用AI问片功能
|
||||||
|
|||||||
@@ -688,6 +688,12 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
Account: '',
|
Account: '',
|
||||||
Password: '',
|
Password: '',
|
||||||
},
|
},
|
||||||
|
UC: {
|
||||||
|
Enabled: false,
|
||||||
|
Cookie: '',
|
||||||
|
Token: '',
|
||||||
|
SavePath: '/',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,6 +735,15 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!adminConfig.NetDiskConfig.UC) {
|
||||||
|
adminConfig.NetDiskConfig.UC = {
|
||||||
|
Enabled: false,
|
||||||
|
Cookie: '',
|
||||||
|
Token: '',
|
||||||
|
SavePath: '/',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 确保音乐配置存在
|
// 确保音乐配置存在
|
||||||
if (!adminConfig.MusicConfig) {
|
if (!adminConfig.MusicConfig) {
|
||||||
adminConfig.MusicConfig = {
|
adminConfig.MusicConfig = {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ export const NETDISK_MOBILE_SOURCE = 'netdisk-mobile';
|
|||||||
export const NETDISK_BAIDU_SOURCE = 'netdisk-baidu';
|
export const NETDISK_BAIDU_SOURCE = 'netdisk-baidu';
|
||||||
export const NETDISK_TIANYI_SOURCE = 'netdisk-tianyi';
|
export const NETDISK_TIANYI_SOURCE = 'netdisk-tianyi';
|
||||||
export const NETDISK_123_SOURCE = 'netdisk-123';
|
export const NETDISK_123_SOURCE = 'netdisk-123';
|
||||||
|
export const NETDISK_UC_SOURCE = 'netdisk-uc';
|
||||||
|
|
||||||
export type NetdiskProvider = 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123';
|
export type NetdiskProvider = 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc';
|
||||||
|
|
||||||
export function normalizeNetdiskSource(source?: string | null): string {
|
export function normalizeNetdiskSource(source?: string | null): string {
|
||||||
if (!source) return '';
|
if (!source) return '';
|
||||||
@@ -15,7 +16,7 @@ export function normalizeNetdiskSource(source?: string | null): string {
|
|||||||
|
|
||||||
export function isNetdiskSource(source?: string | null): boolean {
|
export function isNetdiskSource(source?: string | null): boolean {
|
||||||
const normalized = normalizeNetdiskSource(source);
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNetdiskProvider(source?: string | null): NetdiskProvider | null {
|
export function getNetdiskProvider(source?: string | null): NetdiskProvider | null {
|
||||||
@@ -25,6 +26,7 @@ export function getNetdiskProvider(source?: string | null): NetdiskProvider | nu
|
|||||||
if (normalized === NETDISK_BAIDU_SOURCE) return 'baidu';
|
if (normalized === NETDISK_BAIDU_SOURCE) return 'baidu';
|
||||||
if (normalized === NETDISK_TIANYI_SOURCE) return 'tianyi';
|
if (normalized === NETDISK_TIANYI_SOURCE) return 'tianyi';
|
||||||
if (normalized === NETDISK_123_SOURCE) return '123';
|
if (normalized === NETDISK_123_SOURCE) return '123';
|
||||||
|
if (normalized === NETDISK_UC_SOURCE) return 'uc';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,3 +49,7 @@ export function isNetdiskTianyiSource(source?: string | null): boolean {
|
|||||||
export function isNetdisk123Source(source?: string | null): boolean {
|
export function isNetdisk123Source(source?: string | null): boolean {
|
||||||
return normalizeNetdiskSource(source) === NETDISK_123_SOURCE;
|
return normalizeNetdiskSource(source) === NETDISK_123_SOURCE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNetdiskUCSource(source?: string | null): boolean {
|
||||||
|
return normalizeNetdiskSource(source) === NETDISK_UC_SOURCE;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { base58Decode, base58Encode } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface UCNetdiskSessionFile {
|
||||||
|
fid: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
shareFidToken?: string;
|
||||||
|
pdirFid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UCNetdiskSession {
|
||||||
|
id: string;
|
||||||
|
provider: 'uc';
|
||||||
|
title: string;
|
||||||
|
shareUrl: string;
|
||||||
|
passcode?: string;
|
||||||
|
shareId: string;
|
||||||
|
shareToken: string;
|
||||||
|
files: UCNetdiskSessionFile[];
|
||||||
|
savedFileIds: Record<string, string>;
|
||||||
|
playFolderFid?: string;
|
||||||
|
playFolderPath?: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 30 * 60 * 1000;
|
||||||
|
const sessionStore = new Map<string, UCNetdiskSession>();
|
||||||
|
|
||||||
|
export function buildUCNetdiskId(input: { shareUrl: string; passcode?: string }) {
|
||||||
|
return base58Encode(JSON.stringify({ shareUrl: input.shareUrl, passcode: input.passcode || '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUCNetdiskId(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 uc netdisk id');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shareUrl: parsed.shareUrl,
|
||||||
|
passcode: typeof parsed.passcode === 'string' ? parsed.passcode : '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new Error('无效的UC网盘播放 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 createUCNetdiskSession(input: {
|
||||||
|
title: string;
|
||||||
|
shareUrl: string;
|
||||||
|
passcode?: string;
|
||||||
|
shareId: string;
|
||||||
|
shareToken: string;
|
||||||
|
files: UCNetdiskSessionFile[];
|
||||||
|
}): UCNetdiskSession {
|
||||||
|
pruneExpiredSessions();
|
||||||
|
const now = Date.now();
|
||||||
|
const id = buildUCNetdiskId({ shareUrl: input.shareUrl, passcode: input.passcode });
|
||||||
|
const session: UCNetdiskSession = {
|
||||||
|
id,
|
||||||
|
provider: 'uc',
|
||||||
|
title: input.title,
|
||||||
|
shareUrl: input.shareUrl,
|
||||||
|
passcode: input.passcode,
|
||||||
|
shareId: input.shareId,
|
||||||
|
shareToken: input.shareToken,
|
||||||
|
files: input.files,
|
||||||
|
savedFileIds: {},
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + TTL_MS,
|
||||||
|
};
|
||||||
|
sessionStore.set(id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUCNetdiskSession(id: string): UCNetdiskSession | 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 refreshUCNetdiskSession(id: string): UCNetdiskSession | null {
|
||||||
|
const session = getUCNetdiskSession(id);
|
||||||
|
if (!session) return null;
|
||||||
|
session.expiresAt = Date.now() + TTL_MS;
|
||||||
|
sessionStore.set(id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
import { listUCShareVideos } from './uc.client';
|
||||||
|
import {
|
||||||
|
createUCNetdiskSession,
|
||||||
|
getUCNetdiskSession,
|
||||||
|
parseUCNetdiskId,
|
||||||
|
refreshUCNetdiskSession,
|
||||||
|
} from './uc-session-cache';
|
||||||
|
|
||||||
|
export async function resolveUCSession(id: string) {
|
||||||
|
const config = await getConfig();
|
||||||
|
const ucConfig = config.NetDiskConfig?.UC;
|
||||||
|
if (!ucConfig?.Enabled || !ucConfig.Cookie) {
|
||||||
|
throw new Error('UC网盘未配置或未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = refreshUCNetdiskSession(id) || getUCNetdiskSession(id);
|
||||||
|
if (!session) {
|
||||||
|
const payload = parseUCNetdiskId(id);
|
||||||
|
const result = await listUCShareVideos(payload.shareUrl, ucConfig.Cookie, payload.passcode || '');
|
||||||
|
session = createUCNetdiskSession({
|
||||||
|
title: result.title,
|
||||||
|
shareUrl: payload.shareUrl,
|
||||||
|
passcode: payload.passcode,
|
||||||
|
shareId: result.shareId,
|
||||||
|
shareToken: result.shareToken,
|
||||||
|
files: result.files,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('UC网盘播放信息恢复失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
cookie: ucConfig.Cookie,
|
||||||
|
token: ucConfig.Token || '',
|
||||||
|
savePath: ucConfig.SavePath || '/',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const UC_SHARE_API_BASE = 'https://pc-api.uc.cn/1/clouddrive';
|
||||||
|
const UC_DRIVE_API_BASE = 'https://pc-api.uc.cn/1/clouddrive';
|
||||||
|
const UC_OPEN_API_BASE = 'https://open-api-drive.uc.cn';
|
||||||
|
const UC_QUERY_TEMPLATE = 'pr=UCBrowser&fr=pc&sys=darwin&ve=1.8.6&ut={ut}';
|
||||||
|
const UC_OPEN_API_CLIENT_ID = '5acf882d27b74502b7040b0c65519aa7';
|
||||||
|
const UC_OPEN_API_SIGN_KEY = 'l3srvtd7p42l0d0x1u8d7yc8ye9kki4d';
|
||||||
|
const UC_OPEN_API_APP_VER = '1.6.8';
|
||||||
|
const UC_OPEN_API_CHANNEL = 'UCTVOFFICIALWEB';
|
||||||
|
|
||||||
|
export interface UCShareLinkInfo {
|
||||||
|
pwdId: string;
|
||||||
|
passcode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UCShareItem {
|
||||||
|
fid: string;
|
||||||
|
fileName: string;
|
||||||
|
dir: boolean;
|
||||||
|
shareFidToken?: string;
|
||||||
|
pdirFid?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UCShareVideoListResult {
|
||||||
|
title: string;
|
||||||
|
shareId: string;
|
||||||
|
shareToken: string;
|
||||||
|
files: Array<{
|
||||||
|
fid: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
shareFidToken?: string;
|
||||||
|
pdirFid?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIDEO_EXTENSIONS = [
|
||||||
|
'.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob',
|
||||||
|
];
|
||||||
|
|
||||||
|
const utCache = new Map<string, string>();
|
||||||
|
|
||||||
|
function buildApiUrl(base: string, path: string, ut: string, query = '') {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const queryPrefix = ut
|
||||||
|
? UC_QUERY_TEMPLATE.replace('{ut}', encodeURIComponent(ut))
|
||||||
|
: 'pr=UCBrowser&fr=pc&sys=darwin&ve=1.8.6';
|
||||||
|
return `${base}${normalizedPath}?${queryPrefix}${query ? `&${query}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaders(cookie: string): HeadersInit {
|
||||||
|
return {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
cookie,
|
||||||
|
referer: 'https://drive.uc.cn',
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) uc-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUCPlayHeaders(cookie: string): Record<string, string> {
|
||||||
|
const keepKeys = ['_UP_A4A_11_', 'tfstk', '__uid', '__pus', '__kp', '__puus'];
|
||||||
|
const filteredCookie = cookie
|
||||||
|
.split(';')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => keepKeys.some((key) => item.includes(key)))
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookie: filteredCookie,
|
||||||
|
referer: 'https://drive.uc.cn/',
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) uc-cloud-drive/1.8.6 Chrome/100.0.4896.160 Electron/18.3.5.16-b62cf9c50d Safari/537.36 Channel/ucpan_other_ch',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUCCookie(cookie: string): string {
|
||||||
|
return cookie
|
||||||
|
.replace(/;/g, ';')
|
||||||
|
.replace(/:/g, ':')
|
||||||
|
.replace(/,/g, ',')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertUCCookieHeaderSafe(cookie: string): string {
|
||||||
|
const normalized = normalizeUCCookie(cookie);
|
||||||
|
for (let i = 0; i < normalized.length; i += 1) {
|
||||||
|
if (normalized.charCodeAt(i) > 255) {
|
||||||
|
throw new Error('UC Cookie 含有非法字符,请确认没有中文标点、中文空格或说明文字');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (!trimmed || trimmed === '/') return '/';
|
||||||
|
return `/${trimmed.replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(...parts: string[]) {
|
||||||
|
const joined = parts.filter(Boolean).join('/').replace(/\/+/g, '/');
|
||||||
|
return normalizePath(joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFolderName(name: string) {
|
||||||
|
return (name || 'uc-temp')
|
||||||
|
.replace(/[<>:"/\\|?*]/g, ' ')
|
||||||
|
.replace(/[\r\n\t]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJson(response: Response) {
|
||||||
|
const text = await response.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`UC 接口返回异常:${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOk(data: any, fallbackMessage: string) {
|
||||||
|
if (data?.code === 0 || data?.code === 200 || data?.status === 200) return;
|
||||||
|
throw new Error(data?.message || data?.msg || fallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUCUt(cookie: string) {
|
||||||
|
const safeCookie = assertUCCookieHeaderSafe(cookie);
|
||||||
|
const cached = utCache.get(safeCookie);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const tryFetchUt = async (headers?: HeadersInit) => {
|
||||||
|
const response = await fetch(`${UC_DRIVE_API_BASE}/file`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const text = (await response.text()).trim();
|
||||||
|
if (!response.ok || !text) return '';
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
let ut = await tryFetchUt();
|
||||||
|
if (!ut) {
|
||||||
|
ut = await tryFetchUt(getHeaders(safeCookie));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ut) {
|
||||||
|
utCache.set(safeCookie, '');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
utCache.set(safeCookie, ut);
|
||||||
|
return ut;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDriveItemName(item: any): string {
|
||||||
|
return String(item?.file_name || item?.name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInstantPlayFolderName(pwdId: string, title?: string) {
|
||||||
|
const baseName = sanitizeFolderName(title || 'uc-temp') || 'uc-temp';
|
||||||
|
return `${baseName}_${pwdId}`.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUCShareUrl(url: string, passcode = ''): UCShareLinkInfo {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const pwdId = parsed.pathname.match(/\/s\/([A-Za-z0-9_-]+)/)?.[1] || '';
|
||||||
|
if (!pwdId) {
|
||||||
|
throw new Error('无法解析 UC 分享链接');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pwdId,
|
||||||
|
passcode: passcode || parsed.searchParams.get('pwd') || parsed.searchParams.get('passcode') || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchShareToken(cookie: string, share: UCShareLinkInfo, ut: string) {
|
||||||
|
const response = await fetch(buildApiUrl(UC_SHARE_API_BASE, '/share/sharepage/token', ut), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(cookie),
|
||||||
|
body: JSON.stringify({
|
||||||
|
pwd_id: share.pwdId,
|
||||||
|
passcode: share.passcode,
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '获取 UC 分享 token 失败');
|
||||||
|
const stoken = data?.data?.stoken || data?.data?.share_token || data?.data?.token;
|
||||||
|
if (!stoken) {
|
||||||
|
throw new Error('UC 分享 token 缺失');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stoken,
|
||||||
|
shareTitle: data?.data?.title || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchShareFolderItems(cookie: string, pwdId: string, stoken: string, ut: string, pdirFid = '0', page = 1): Promise<{ items: UCShareItem[]; total?: number }> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
pwd_id: pwdId,
|
||||||
|
stoken,
|
||||||
|
pdir_fid: pdirFid,
|
||||||
|
force: '0',
|
||||||
|
_page: String(page),
|
||||||
|
_size: '100',
|
||||||
|
_sort: 'file_type:asc,file_name:asc',
|
||||||
|
});
|
||||||
|
const response = await fetch(buildApiUrl(UC_SHARE_API_BASE, '/share/sharepage/detail', ut, query.toString()), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getHeaders(cookie),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '获取 UC 分享详情失败');
|
||||||
|
const list = data?.data?.list || [];
|
||||||
|
return {
|
||||||
|
items: list.map((item: any) => ({
|
||||||
|
fid: String(item.fid || item.file_id || ''),
|
||||||
|
fileName: String(item.file_name || item.name || ''),
|
||||||
|
dir: Boolean(item.dir || item.is_dir || item.file_type === 0),
|
||||||
|
shareFidToken: item.share_fid_token || item.fid_token || item.share_token || undefined,
|
||||||
|
pdirFid: String(item.pdir_fid || pdirFid || '0'),
|
||||||
|
size: Number(item.size || 0),
|
||||||
|
})),
|
||||||
|
total: Number(data?.metadata?._total || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectShareItemsRecursive(cookie: string, pwdId: string, stoken: string, ut: string, pdirFid = '0'): Promise<UCShareItem[]> {
|
||||||
|
const result: UCShareItem[] = [];
|
||||||
|
const pageSize = 100;
|
||||||
|
for (let page = 1; page < 100; page += 1) {
|
||||||
|
const { items, total } = await fetchShareFolderItems(cookie, pwdId, stoken, ut, pdirFid, page);
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.dir) {
|
||||||
|
const children = await collectShareItemsRecursive(cookie, pwdId, stoken, ut, item.fid);
|
||||||
|
result.push(...children);
|
||||||
|
} else {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length < pageSize || page >= Math.ceil((total || 0) / pageSize)) break;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoFile(fileName: string) {
|
||||||
|
const lower = fileName.toLowerCase();
|
||||||
|
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDriveFolderItems(cookie: string, ut: string, pdirFid = '0', page = 1, size = 200): Promise<any[]> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
pdir_fid: pdirFid,
|
||||||
|
_page: String(page),
|
||||||
|
_size: String(size),
|
||||||
|
_sort: 'file_type:asc,file_name:asc',
|
||||||
|
});
|
||||||
|
const response = await fetch(buildApiUrl(UC_DRIVE_API_BASE, '/file/sort', ut, query.toString()), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getHeaders(cookie),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '获取 UC 目录列表失败');
|
||||||
|
return data?.data?.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllDriveFolderItems(cookie: string, ut: string, pdirFid = '0'): Promise<any[]> {
|
||||||
|
const allItems: any[] = [];
|
||||||
|
const pageSize = 200;
|
||||||
|
for (let page = 1; page < 100; page += 1) {
|
||||||
|
const items = await fetchDriveFolderItems(cookie, ut, pdirFid, page, pageSize);
|
||||||
|
allItems.push(...items);
|
||||||
|
if (items.length < pageSize) break;
|
||||||
|
}
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDriveFolder(cookie: string, ut: string, parentFid: string, folderName: string) {
|
||||||
|
const response = await fetch(buildApiUrl(UC_DRIVE_API_BASE, '/file', ut), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(cookie),
|
||||||
|
body: JSON.stringify({
|
||||||
|
pdir_fid: parentFid,
|
||||||
|
file_name: folderName,
|
||||||
|
dir_path: '',
|
||||||
|
dir_init_lock: false,
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, `创建 UC 目录失败:${folderName}`);
|
||||||
|
const fid = data?.data?.fid || data?.data?.file_id || data?.metadata?.fid;
|
||||||
|
if (!fid) throw new Error(`UC 目录创建成功但未返回 fid:${folderName}`);
|
||||||
|
return String(fid);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDirectoryByName(cookie: string, ut: string, parentFid: string, folderName: string): Promise<any | null> {
|
||||||
|
const items = await fetchAllDriveFolderItems(cookie, ut, parentFid);
|
||||||
|
return items.find((item: any) => Boolean(item.dir || item.is_dir) && getDriveItemName(item) === folderName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateUCCookieReadable(cookie: string): Promise<void> {
|
||||||
|
const safeCookie = assertUCCookieHeaderSafe(cookie);
|
||||||
|
const ut = await resolveUCUt(safeCookie);
|
||||||
|
await fetchDriveFolderItems(safeCookie, ut, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUCShareVideos(shareUrl: string, cookie: string, passcode = ''): Promise<UCShareVideoListResult> {
|
||||||
|
const safeCookie = assertUCCookieHeaderSafe(cookie);
|
||||||
|
const ut = await resolveUCUt(safeCookie);
|
||||||
|
const share = parseUCShareUrl(shareUrl, passcode);
|
||||||
|
const { stoken, shareTitle } = await fetchShareToken(safeCookie, share, ut);
|
||||||
|
const allItems = await collectShareItemsRecursive(safeCookie, share.pwdId, stoken, ut, '0');
|
||||||
|
const files = allItems
|
||||||
|
.filter((item) => !item.dir && isVideoFile(item.fileName) && Number(item.size || 0) >= 5 * 1024 * 1024)
|
||||||
|
.map((item) => ({
|
||||||
|
fid: item.fid,
|
||||||
|
name: item.fileName,
|
||||||
|
size: item.size,
|
||||||
|
shareFidToken: item.shareFidToken,
|
||||||
|
pdirFid: item.pdirFid,
|
||||||
|
}));
|
||||||
|
if (files.length === 0) throw new Error('分享中没有可播放的视频文件');
|
||||||
|
return {
|
||||||
|
title: shareTitle || 'UC网盘立即播放',
|
||||||
|
shareId: share.pwdId,
|
||||||
|
shareToken: stoken,
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureUCDrivePath(cookie: string, inputPath: string): Promise<{ fid: string; path: string }> {
|
||||||
|
const safeCookie = assertUCCookieHeaderSafe(cookie);
|
||||||
|
const ut = await resolveUCUt(safeCookie);
|
||||||
|
const normalized = normalizePath(inputPath);
|
||||||
|
if (normalized === '/') return { fid: '0', path: normalized };
|
||||||
|
|
||||||
|
const segments = normalized.split('/').filter(Boolean);
|
||||||
|
let currentFid = '0';
|
||||||
|
let currentPath = '';
|
||||||
|
for (const segment of segments) {
|
||||||
|
const items = await fetchDriveFolderItems(safeCookie, ut, currentFid);
|
||||||
|
const existed = items.find((item: any) => Boolean(item.dir || item.is_dir) && getDriveItemName(item) === segment);
|
||||||
|
currentPath = joinPath(currentPath, segment);
|
||||||
|
if (existed) {
|
||||||
|
currentFid = String(existed.fid || existed.file_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currentFid = await createDriveFolder(safeCookie, ut, currentFid, segment);
|
||||||
|
}
|
||||||
|
return { fid: currentFid, path: currentPath || '/' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSaveTask(cookie: string, ut: string, share: UCShareLinkInfo, stoken: string, toPdirFid: string, items: UCShareItem[]) {
|
||||||
|
if (items.length === 0) throw new Error('没有可保存的文件');
|
||||||
|
const response = await fetch(buildApiUrl(UC_SHARE_API_BASE, '/share/sharepage/save', ut), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(cookie),
|
||||||
|
body: JSON.stringify({
|
||||||
|
pwd_id: share.pwdId,
|
||||||
|
stoken,
|
||||||
|
pdir_fid: '0',
|
||||||
|
to_pdir_fid: toPdirFid,
|
||||||
|
scene: 'link',
|
||||||
|
filelist: items.map((item) => item.fid),
|
||||||
|
fid_list: items.map((item) => item.fid),
|
||||||
|
fid_token_list: items.map((item) => item.shareFidToken || ''),
|
||||||
|
share_fid_token_list: items.map((item) => item.shareFidToken || ''),
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '提交 UC 转存任务失败');
|
||||||
|
return data?.data?.task_id ? String(data.data.task_id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _pollTask(cookie: string, ut: string, taskId: string) {
|
||||||
|
for (let i = 0; i < 25; i += 1) {
|
||||||
|
const query = new URLSearchParams({ task_id: taskId, retry_index: String(i) });
|
||||||
|
const response = await fetch(buildApiUrl(UC_SHARE_API_BASE, '/task', ut, query.toString()), {
|
||||||
|
method: 'GET', headers: getHeaders(cookie), cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '查询 UC 任务状态失败');
|
||||||
|
const task = data?.data || {};
|
||||||
|
if (task?.status === 2 || task?.status === 'finished' || task?.status === 'success' || task?.finished_at) return;
|
||||||
|
if (task?.status === -1 || task?.status === 'failed' || task?.err_code) {
|
||||||
|
throw new Error(task?.message || task?.err_msg || 'UC 任务执行失败');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
}
|
||||||
|
throw new Error('UC 任务处理超时');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureUCPlayFolder(cookie: string, playTempSavePath: string, shareId: string, title?: string): Promise<{ folderFid: string; folderPath: string; folderName: string }> {
|
||||||
|
const safeCookie = assertUCCookieHeaderSafe(cookie);
|
||||||
|
const ut = await resolveUCUt(safeCookie);
|
||||||
|
const tempRoot = await ensureUCDrivePath(safeCookie, playTempSavePath);
|
||||||
|
const folderName = buildInstantPlayFolderName(shareId, title);
|
||||||
|
const existedFolder = await findDirectoryByName(safeCookie, ut, tempRoot.fid, folderName);
|
||||||
|
if (existedFolder) {
|
||||||
|
return {
|
||||||
|
folderFid: String(existedFolder.fid || existedFolder.file_id),
|
||||||
|
folderPath: joinPath(tempRoot.path, folderName),
|
||||||
|
folderName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const folderFid = await createDriveFolder(safeCookie, ut, tempRoot.fid, folderName);
|
||||||
|
return { folderFid, folderPath: joinPath(tempRoot.path, folderName), folderName };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUCShareFile(cookie: string, input: { shareId: string; shareToken: string; fileId: string; shareFileToken?: string; playFolderFid: string; }): Promise<string> {
|
||||||
|
const safeCookie = assertUCCookieHeaderSafe(cookie);
|
||||||
|
const ut = await resolveUCUt(safeCookie);
|
||||||
|
const taskId = await submitSaveTask(safeCookie, ut, { pwdId: input.shareId, passcode: '' }, input.shareToken, input.playFolderFid, [{ fid: input.fileId, fileName: '', dir: false, shareFidToken: input.shareFileToken }]);
|
||||||
|
if (!taskId) throw new Error('UC 转存任务创建失败');
|
||||||
|
|
||||||
|
for (let i = 0; i < 25; i += 1) {
|
||||||
|
const query = new URLSearchParams({ task_id: taskId, retry_index: String(i) });
|
||||||
|
const response = await fetch(buildApiUrl(UC_SHARE_API_BASE, '/task', ut, query.toString()), { method: 'GET', headers: getHeaders(safeCookie), cache: 'no-store' });
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '查询 UC 任务状态失败');
|
||||||
|
const saveAsTopFids = data?.data?.save_as?.save_as_top_fids;
|
||||||
|
if (Array.isArray(saveAsTopFids) && saveAsTopFids.length > 0) {
|
||||||
|
return String(saveAsTopFids[0]);
|
||||||
|
}
|
||||||
|
const status = data?.data?.status;
|
||||||
|
if (status === -1 || status === 'failed' || data?.data?.err_code) {
|
||||||
|
throw new Error(data?.data?.message || data?.data?.err_msg || 'UC 任务执行失败');
|
||||||
|
}
|
||||||
|
if (status === 2 || status === 'finished' || status === 'success' || data?.data?.finished_at) break;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
}
|
||||||
|
throw new Error('UC 转存结果获取失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDeviceID(timestamp: string) {
|
||||||
|
return crypto.createHash('md5').update(timestamp).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReqId(deviceID: string, timestamp: string) {
|
||||||
|
return crypto.createHash('md5').update(deviceID + timestamp).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateXPanToken(method: string, pathname: string, timestamp: string, key: string) {
|
||||||
|
return crypto.createHash('sha256').update(`${method}&${pathname}&${timestamp}&${key}`).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUCPlayUrls(cookie: string, savedFileId: string, token = ''): Promise<Array<{ name: string; url: string; priority: number; headers?: Record<string, string> }>> {
|
||||||
|
const safeCookie = assertUCCookieHeaderSafe(cookie);
|
||||||
|
const ut = await resolveUCUt(safeCookie);
|
||||||
|
const urls: Array<{ name: string; url: string; priority: number; headers?: Record<string, string> }> = [];
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const pathname = '/file';
|
||||||
|
const timestamp = `${Math.floor(Date.now() / 1000)}000`;
|
||||||
|
const deviceId = generateDeviceID(timestamp);
|
||||||
|
const reqId = generateReqId(deviceId, timestamp);
|
||||||
|
const xPanToken = generateXPanToken('GET', pathname, timestamp, UC_OPEN_API_SIGN_KEY);
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
req_id: reqId,
|
||||||
|
access_token: token,
|
||||||
|
app_ver: UC_OPEN_API_APP_VER,
|
||||||
|
device_id: deviceId,
|
||||||
|
device_brand: 'Xiaomi',
|
||||||
|
platform: 'tv',
|
||||||
|
device_name: 'M2004J7AC',
|
||||||
|
device_model: 'M2004J7AC',
|
||||||
|
build_device: 'M2004J7AC',
|
||||||
|
build_product: 'M2004J7AC',
|
||||||
|
device_gpu: 'Adreno (TM) 550',
|
||||||
|
activity_rect: '{}',
|
||||||
|
channel: UC_OPEN_API_CHANNEL,
|
||||||
|
method: 'streaming',
|
||||||
|
group_by: 'source',
|
||||||
|
fid: savedFileId,
|
||||||
|
resolution: 'low,normal,high,super,2k,4k',
|
||||||
|
support: 'dolby_vision',
|
||||||
|
});
|
||||||
|
const response = await fetch(`${UC_OPEN_API_BASE}${pathname}?${query.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'user-agent': 'Mozilla/5.0 (Linux; Android 9) AppleWebKit/533.1 (KHTML, like Gecko) Mobile Safari/533.1',
|
||||||
|
connection: 'Keep-Alive',
|
||||||
|
'accept-encoding': 'gzip',
|
||||||
|
'x-pan-tm': timestamp,
|
||||||
|
'x-pan-token': xPanToken,
|
||||||
|
'content-type': 'text/plain;charset=UTF-8',
|
||||||
|
'x-pan-client-id': UC_OPEN_API_CLIENT_ID,
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
const openVideoInfo = Array.isArray(data?.data?.video_info)
|
||||||
|
? data.data.video_info.find((item: any) => item?.accessable && item?.url)
|
||||||
|
: null;
|
||||||
|
if (openVideoInfo?.url) {
|
||||||
|
urls.push({ name: '原画', url: String(openVideoInfo.url), priority: 9999, headers: {} });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore token failure, fallback to cookie mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildApiUrl(UC_DRIVE_API_BASE, '/file/download', ut), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(safeCookie),
|
||||||
|
body: JSON.stringify({ fids: [savedFileId] }),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '获取 UC 下载地址失败');
|
||||||
|
const downloadUrl = data?.data?.[0]?.download_url;
|
||||||
|
if (downloadUrl) {
|
||||||
|
urls.push({ name: '原画', url: String(downloadUrl), priority: 9999, headers: getUCPlayHeaders(safeCookie) });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(buildApiUrl(UC_DRIVE_API_BASE, '/file/v2/play', ut), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(safeCookie),
|
||||||
|
body: JSON.stringify({
|
||||||
|
fid: savedFileId,
|
||||||
|
resolutions: 'normal,low,high,super,2k,4k',
|
||||||
|
supports: 'fmp4',
|
||||||
|
}),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const data = await parseJson(response);
|
||||||
|
ensureOk(data, '获取 UC 转码地址失败');
|
||||||
|
const nameMap: Record<string, string> = { FOUR_K: '4K', SUPER: '超清', HIGH: '高清', NORMAL: '流畅', LOW: '低清' };
|
||||||
|
if (Array.isArray(data?.data?.video_list)) {
|
||||||
|
for (const video of data.data.video_list) {
|
||||||
|
const resolution = video?.video_info?.resoultion;
|
||||||
|
const playUrl = video?.video_info?.url;
|
||||||
|
const priority = Number(video?.video_info?.width || 0);
|
||||||
|
if (resolution && playUrl) {
|
||||||
|
urls.push({ name: nameMap[String(resolution)] || String(resolution), url: String(playUrl), priority, headers: getUCPlayHeaders(safeCookie) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = urls.filter((item, index, array) => array.findIndex((v) => v.url === item.url) === index);
|
||||||
|
deduped.sort((a, b) => b.priority - a.priority);
|
||||||
|
if (deduped.length === 0) throw new Error('未获取到 UC 播放地址');
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user