From 2a0900ab692b8d2a674321567608d794ef7b9240 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 27 Apr 2026 13:52:51 +0800 Subject: [PATCH] =?UTF-8?q?123=E4=BA=91=E7=9B=98=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 112 +++++ src/app/api/admin/netdisk/route.ts | 27 +- src/app/api/netdisk/123/instant-play/route.ts | 55 +++ src/app/api/netdisk/123/play/route.ts | 79 +++ src/app/api/source-detail/route.ts | 127 ++++- src/app/page.tsx | 128 ++++- src/app/play/page.tsx | 1 + src/components/PansouSearch.tsx | 6 +- src/lib/admin.types.ts | 5 + src/lib/config.ts | 13 + src/lib/netdisk/pan123-session-cache.ts | 86 ++++ src/lib/netdisk/pan123-session-resolver.ts | 39 ++ src/lib/netdisk/pan123.client.ts | 451 ++++++++++++++++++ src/lib/netdisk/source.ts | 10 +- 14 files changed, 1110 insertions(+), 29 deletions(-) create mode 100644 src/app/api/netdisk/123/instant-play/route.ts create mode 100644 src/app/api/netdisk/123/play/route.ts create mode 100644 src/lib/netdisk/pan123-session-cache.ts create mode 100644 src/lib/netdisk/pan123-session-resolver.ts create mode 100644 src/lib/netdisk/pan123.client.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 3067f9f..4713dcb 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3711,6 +3711,9 @@ const NetDiskConfigComponent = ({ const [tianyiEnabled, setTianyiEnabled] = useState(false); const [tianyiAccount, setTianyiAccount] = useState(''); const [tianyiPassword, setTianyiPassword] = useState(''); + const [pan123Enabled, setPan123Enabled] = useState(false); + const [pan123Account, setPan123Account] = useState(''); + const [pan123Password, setPan123Password] = useState(''); useEffect(() => { const quark = config?.NetDiskConfig?.Quark; @@ -3725,6 +3728,9 @@ const NetDiskConfigComponent = ({ setTianyiEnabled(config?.NetDiskConfig?.Tianyi?.Enabled || false); setTianyiAccount(config?.NetDiskConfig?.Tianyi?.Account || ''); setTianyiPassword(config?.NetDiskConfig?.Tianyi?.Password || ''); + setPan123Enabled(config?.NetDiskConfig?.Pan123?.Enabled || false); + setPan123Account(config?.NetDiskConfig?.Pan123?.Account || ''); + setPan123Password(config?.NetDiskConfig?.Pan123?.Password || ''); }, [config]); const handleSave = async () => { @@ -3752,6 +3758,11 @@ const NetDiskConfigComponent = ({ Account: tianyiAccount, Password: tianyiPassword, }, + Pan123: { + Enabled: pan123Enabled, + Account: pan123Account, + Password: pan123Password, + }, }), }); @@ -3878,6 +3889,35 @@ const NetDiskConfigComponent = ({ }); }; + const handleValidatePan123 = async () => { + await withLoading('validatePan123NetDisk', async () => { + try { + const response = await fetch('/api/admin/netdisk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'validate', + provider: 'pan123', + Pan123: { + Account: pan123Account, + Password: pan123Password, + }, + }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '校验失败'); + } + + showSuccess(data.message || '123网盘账号密码可用', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '校验失败', showAlert); + throw error; + } + }); + }; + return (
@@ -4144,6 +4184,78 @@ const NetDiskConfigComponent = ({
+
+ + 123网盘 + +
+
+
+

+ 启用123网盘 +

+

+ 开启后,网盘搜索中的123网盘资源会显示“立即播放”按钮 +

+
+ +
+ +
+ + setPan123Account(e.target.value)} + disabled={!pan123Enabled} + placeholder='输入123网盘账号' + 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-teal-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+ +
+ + setPan123Password(e.target.value)} + disabled={!pan123Enabled} + placeholder='输入123网盘密码' + 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-teal-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> +
+ +
+ + +
+
+
+ item.name === quality)?.url || playInfo.url; + + if (format === 'json') { + return NextResponse.json({ + url: selectedUrl, + headers: {}, + qualities: playInfo.qualities, + }); + } + + return NextResponse.redirect(selectedUrl); + } catch (error) { + console.error('[netdisk-123][play] error', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : '获取播放地址失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 3bd8420..0b271c5 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -30,7 +30,13 @@ import { 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 { + createPan123NetdiskSession, + getPan123NetdiskSession, + parsePan123NetdiskId, + refreshPan123NetdiskSession, +} from '@/lib/netdisk/pan123-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 { executeSavedSourceScript, normalizeScriptDetailResult, @@ -40,6 +46,30 @@ import { export const runtime = 'nodejs'; +function formatNetdiskEpisodeTitle(parsed: { + season?: number; + episode?: number; +}, fallback: string) { + if (parsed.season && parsed.episode) { + const season = String(Math.trunc(parsed.season)).padStart(2, '0'); + const episodeValue = parsed.episode; + const episode = + Number.isInteger(episodeValue) + ? String(Math.trunc(episodeValue)).padStart(2, '0') + : String(episodeValue); + return `S${season}E${episode}`; + } + + if (parsed.episode) { + const episodeValue = parsed.episode; + return Number.isInteger(episodeValue) + ? `第${Math.trunc(episodeValue)}集` + : `第${episodeValue}集`; + } + + return fallback; +} + /** * 根据 source 和 id 直接获取视频详情 * 这个API专门用于play页面快速获取当前源的详情 @@ -327,16 +357,14 @@ export async function GET(request: NextRequest) { const { parseVideoFileName } = await import('@/lib/video-parser'); const parsedFiles = mobileSession.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) => { + return { + ...file, + originalIndex: index, + sortEpisode: parsed.episode || index + 1, + isOVA: parsed.isOVA, + displayTitle: formatNetdiskEpisodeTitle(parsed, file.name), + }; + }).sort((a, b) => { if (a.isOVA && !b.isOVA) return 1; if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode @@ -407,8 +435,7 @@ export async function GET(request: NextRequest) { originalIndex: index, sortEpisode: parsed.episode || index + 1, isOVA: parsed.isOVA, - displayTitle: - parsed.title || (parsed.episode ? `第${parsed.episode}集` : file.name), + displayTitle: formatNetdiskEpisodeTitle(parsed, file.name), }; }) .sort((a, b) => { @@ -485,8 +512,7 @@ export async function GET(request: NextRequest) { originalIndex: index, sortEpisode: parsed.episode || index + 1, isOVA: parsed.isOVA, - displayTitle: - parsed.title || (parsed.episode ? `第${parsed.episode}集` : file.name), + displayTitle: formatNetdiskEpisodeTitle(parsed, file.name), }; }) .sort((a, b) => { @@ -520,6 +546,75 @@ export async function GET(request: NextRequest) { } } + if (sourceCode === NETDISK_123_SOURCE) { + try { + const config = await getConfig(); + const pan123Config = config.NetDiskConfig?.Pan123; + if (!pan123Config?.Enabled || !pan123Config.Account || !pan123Config.Password) { + throw new Error('123网盘未配置或未启用'); + } + + let session = refreshPan123NetdiskSession(id) || getPan123NetdiskSession(id); + if (!session) { + const payload = parsePan123NetdiskId(id); + const { listPan123ShareVideos } = await import('@/lib/netdisk/pan123.client'); + const result = await listPan123ShareVideos(payload.shareUrl, payload.passcode || ''); + session = createPan123NetdiskSession({ + title: title || result.title, + shareUrl: payload.shareUrl, + passcode: payload.passcode, + files: result.files, + }); + } + if (!session) { + throw new Error('123网盘播放信息恢复失败'); + } + + const pan123Session = session; + const { parseVideoFileName } = await import('@/lib/video-parser'); + const parsedFiles = pan123Session.files + .map((file, index) => { + const parsed = parseVideoFileName(file.fileName); + return { + ...file, + originalIndex: index, + sortEpisode: parsed.episode || index + 1, + isOVA: parsed.isOVA, + displayTitle: formatNetdiskEpisodeTitle(parsed, file.fileName), + }; + }) + .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.fileName.localeCompare(b.fileName, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + }); + + return NextResponse.json({ + source: NETDISK_123_SOURCE, + source_name: '123网盘', + id: pan123Session.id, + title: title || pan123Session.title, + poster: '', + year: '', + douban_id: 0, + desc: `123网盘分享:${pan123Session.shareUrl}`, + episodes: parsedFiles.map((file) => ( + `/api/netdisk/123/play?id=${encodeURIComponent(pan123Session.id)}&episodeIndex=${file.originalIndex}` + )), + episodes_titles: parsedFiles.map((file) => file.displayTitle), + proxyMode: false, + }); + } catch (error) { + console.error('[netdisk-123][source-detail] error', 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(); @@ -555,7 +650,7 @@ export async function GET(request: NextRequest) { originalIndex: index, fileName: file.name, episode: parsed.episode || index + 1, - title: parsed.title || (parsed.episode ? `第${parsed.episode}集` : file.name), + title: formatNetdiskEpisodeTitle(parsed, file.name), isOVA: parsed.isOVA, }; }) diff --git a/src/app/page.tsx b/src/app/page.tsx index d1f43c1..1aa6ede 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,6 +24,7 @@ import HttpWarningDialog from '@/components/HttpWarningDialog'; import PageLayout from '@/components/PageLayout'; import ScrollableRow from '@/components/ScrollableRow'; import { useSite } from '@/components/SiteProvider'; +import Toast, { ToastProps } from '@/components/Toast'; import VideoCard from '@/components/VideoCard'; // 首页模块配置接口 @@ -70,20 +71,129 @@ function HomeClient() { const [mangaEnabled, setMangaEnabled] = useState(false); const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false); const [directPlayUrl, setDirectPlayUrl] = useState(''); + const [directPlaySubmitting, setDirectPlaySubmitting] = useState(false); + const [toast, setToast] = useState(null); + + const detectNetdiskLink = (url: string): { + provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123'; + shareUrl: string; + passcode?: string; + } | null => { + const trimmed = url.trim(); + + const pickPasscode = (...values: Array) => + values.map((item) => item?.trim()).find(Boolean); + + const inlinePasscode = (text: string) => + pickPasscode( + text.match(/(?:提取码|访问码|密码)\s*[::=]?\s*([a-zA-Z0-9]{4,8})/i)?.[1], + text.match(/[?&](?:pwd|passcode|accessCode)=([^&\s]+)/i)?.[1] + ); + + if (/https:\/\/(?:www\.)?123(?:684|865|912|pan)\.(?:com|cn)\/s\//i.test(trimmed)) { + return { + provider: '123', + shareUrl: trimmed, + passcode: pickPasscode( + trimmed.match(/[?&]pwd=([^&]+)/i)?.[1], + inlinePasscode(trimmed) + ), + }; + } + + if (/https:\/\/cloud\.189\.cn\/(web\/share\?code=|t\/)/i.test(trimmed) || /https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\//i.test(trimmed)) { + return { + provider: 'tianyi', + shareUrl: trimmed, + passcode: pickPasscode( + trimmed.match(/[?&]pwd=([^&]+)/i)?.[1], + inlinePasscode(trimmed) + ), + }; + } + + if (/pan\.baidu\.com\/(s\/|wap\/init\?surl=)/i.test(trimmed)) { + return { + provider: 'baidu', + shareUrl: trimmed, + passcode: pickPasscode( + trimmed.match(/[?&](?:pwd|accessCode)=([^&]+)/i)?.[1], + inlinePasscode(trimmed) + ), + }; + } + + if (/https:\/\/pan\.quark\.cn\/s\//i.test(trimmed)) { + return { + provider: 'quark', + shareUrl: trimmed, + passcode: pickPasscode( + trimmed.match(/[?&](?:pwd|passcode)=([^&]+)/i)?.[1], + inlinePasscode(trimmed) + ), + }; + } + + if (/https:\/\/(?:yun|caiyun)\.139\.com\//i.test(trimmed)) { + return { provider: 'mobile', shareUrl: trimmed }; + } + + return null; + }; const handleDirectPlay = () => { setDirectPlayUrl(''); setShowDirectPlayDialog(true); }; - const submitDirectPlay = () => { + const submitDirectPlay = async () => { const trimmed = directPlayUrl.trim(); if (!trimmed) return; - const encoded = base58Encode(trimmed); - if (!encoded) return; - setShowDirectPlayDialog(false); - setDirectPlayUrl(''); - router.push(`/play?source=directplay&id=${encodeURIComponent(encoded)}`); + setDirectPlaySubmitting(true); + try { + const netdisk = detectNetdiskLink(trimmed); + if (netdisk) { + const source = + netdisk.provider === 'mobile' + ? 'netdisk-mobile' + : netdisk.provider === 'baidu' + ? 'netdisk-baidu' + : netdisk.provider === 'tianyi' + ? 'netdisk-tianyi' + : netdisk.provider === '123' + ? 'netdisk-123' + : 'netdisk-quark'; + const id = base58Encode( + JSON.stringify({ + shareUrl: netdisk.shareUrl, + passcode: netdisk.passcode || '', + }) + ); + if (!id) { + throw new Error('网盘链接编码失败'); + } + const targetUrl = `/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(id)}&title=${encodeURIComponent('网盘直链播放')}`; + setShowDirectPlayDialog(false); + setDirectPlayUrl(''); + window.location.assign(targetUrl); + return; + } + + const encoded = base58Encode(trimmed); + if (!encoded) return; + const targetUrl = `/play?source=directplay&id=${encodeURIComponent(encoded)}`; + setShowDirectPlayDialog(false); + setDirectPlayUrl(''); + window.location.assign(targetUrl); + } catch (error) { + setToast({ + message: error instanceof Error ? error.message : '播放失败', + type: 'error', + onClose: () => setToast(null), + }); + } finally { + setDirectPlaySubmitting(false); + } }; const loadHomeLayoutSettings = () => { @@ -759,16 +869,18 @@ function HomeClient() { )} + + {toast && } ); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index eed02eb..2ef1f68 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2504,6 +2504,7 @@ function PlayPageClient() { const isSpecialLazyPlayUrl = isXiaoyaLazyPlayUrl || newUrl.startsWith('/api/openlist/play') || + newUrl.startsWith('/api/netdisk/123/play') || newUrl.startsWith('/api/netdisk/quark/play') || newUrl.startsWith('/api/netdisk/baidu/play') || newUrl.startsWith('/api/source-script/play'); diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx index 8bcda57..ea52caa 100644 --- a/src/components/PansouSearch.tsx +++ b/src/components/PansouSearch.tsx @@ -170,6 +170,8 @@ export default function PansouSearch({ ? '/api/netdisk/baidu/instant-play' : cloudType === 'tianyi' ? '/api/netdisk/tianyi/instant-play' + : cloudType === '123' + ? '/api/netdisk/123/instant-play' : '/api/netdisk/quark/instant-play'; const response = await fetch(instantPlayApi, { method: 'POST', @@ -189,7 +191,7 @@ export default function PansouSearch({ } router.push( - `/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)}` + `/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)}` ); } catch (err: any) { setToast({ @@ -347,7 +349,7 @@ export default function PansouSearch({ {/* 操作按钮 */}
- {(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi') && ( + {(cloudType === 'quark' || cloudType === 'mobile' || cloudType === 'baidu' || cloudType === 'tianyi' || cloudType === '123') && ( <>