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') && (
<>