From ae3a054e5654b839921436c47da6d5b6a1e95fc8 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 15 May 2026 10:19:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E7=BD=91=E7=9B=98?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=8F=AF=E4=BB=A5=E7=9B=B4=E9=93=BE=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E7=BD=91=E7=9B=98=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/source-detail/route.ts | 403 +++++++++++++++++++++-------- src/app/page.tsx | 207 +++++++++++---- 2 files changed, 454 insertions(+), 156 deletions(-) diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 4318b03..e9198e9 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -6,6 +6,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { getDetailFromApiV2 } from '@/lib/downstream'; import { getProxyToken } from '@/lib/emby-token'; +import { hasFeaturePermission } from '@/lib/permissions'; import { createBaiduNetdiskSession, getBaiduNetdiskSession, @@ -45,6 +46,7 @@ import { NETDISK_QUARK_SOURCE, NETDISK_TIANYI_SOURCE, NETDISK_UC_SOURCE, + isNetdiskSource, normalizeNetdiskSource, } from '@/lib/netdisk/source'; import { @@ -68,17 +70,19 @@ import { export const runtime = 'nodejs'; -function formatNetdiskEpisodeTitle(parsed: { - season?: number; - episode?: number; -}, fallback: string) { +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); + const episode = Number.isInteger(episodeValue) + ? String(Math.trunc(episodeValue)).padStart(2, '0') + : String(episodeValue); return `S${season}E${episode}`; } @@ -112,6 +116,19 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + if (isNetdiskSource(sourceCode)) { + const allowed = await hasFeaturePermission( + authInfo.username, + 'netdisk_temp_play' + ); + if (!allowed) { + return NextResponse.json( + { error: '无权限使用临时播放' }, + { status: 403 } + ); + } + } + const parsedScriptSource = parseScriptSourceValue(sourceCode); if (parsedScriptSource) { try { @@ -121,11 +138,12 @@ export async function GET(request: NextRequest) { payload: {}, }); const sources = normalizeScriptSources(sourcesExecution.result); - const sourceInfo = - sources.find((item) => item.id === parsedScriptSource.sourceId) || { - id: parsedScriptSource.sourceId, - name: parsedScriptSource.sourceId, - }; + const sourceInfo = sources.find( + (item) => item.id === parsedScriptSource.sourceId + ) || { + id: parsedScriptSource.sourceId, + name: parsedScriptSource.sourceId, + }; const detailExecution = await executeSavedSourceScript({ key: parsedScriptSource.scriptKey, @@ -161,7 +179,10 @@ export async function GET(request: NextRequest) { const config = await getConfig(); // 检查是否有启用的 Emby 源 - if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { + if ( + !config.EmbyConfig?.Sources || + config.EmbyConfig.Sources.length === 0 + ) { throw new Error('Emby 未配置或未启用'); } @@ -174,13 +195,15 @@ export async function GET(request: NextRequest) { // 使用 EmbyManager 获取客户端和配置 const { embyManager } = await import('@/lib/emby-manager'); const sources = await embyManager.getEnabledSources(); - const sourceConfig = sources.find(s => s.key === embyKey); + const sourceConfig = sources.find((s) => s.key === embyKey); const sourceName = sourceConfig?.name || 'Emby'; const client = await embyManager.getClient(embyKey); // 获取代理 token(如果启用了代理) - const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null; + const proxyToken = client.isProxyEnabled() + ? await getProxyToken(request) + : null; // 获取媒体详情 const item = await client.getItem(id); @@ -195,7 +218,12 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), + poster: client.getImageUrl( + item.Id, + 'Primary', + undefined, + proxyToken || undefined + ), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', @@ -229,15 +257,24 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), + poster: client.getImageUrl( + item.Id, + 'Primary', + undefined, + proxyToken || undefined + ), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', - episodes: await Promise.all(allEpisodes.map((ep) => client.getStreamUrl(ep.Id))), + episodes: await Promise.all( + allEpisodes.map((ep) => client.getStreamUrl(ep.Id)) + ), episodes_titles: allEpisodes.map((ep) => { const seasonNum = ep.ParentIndexNumber || 1; const episodeNum = ep.IndexNumber || 1; - return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; + return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum + .toString() + .padStart(2, '0')}`; }), subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)), proxyMode: false, @@ -261,16 +298,14 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const xiaoyaConfig = config.XiaoyaConfig; - if ( - !xiaoyaConfig || - !xiaoyaConfig.Enabled || - !xiaoyaConfig.ServerURL - ) { + if (!xiaoyaConfig || !xiaoyaConfig.Enabled || !xiaoyaConfig.ServerURL) { throw new Error('小雅未配置或未启用'); } const { XiaoyaClient } = await import('@/lib/xiaoya.client'); - const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import('@/lib/xiaoya-metadata'); + const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import( + '@/lib/xiaoya-metadata' + ); const { base58Decode, base58Encode } = await import('@/lib/utils'); const client = new XiaoyaClient( @@ -299,7 +334,9 @@ export async function GET(request: NextRequest) { let clickedFilePath: string | undefined; if (fileName) { // 拼接目录路径和文件名 - clickedFilePath = `${decodedDirPath}${decodedDirPath.endsWith('/') ? '' : '/'}${fileName}`; + clickedFilePath = `${decodedDirPath}${ + decodedDirPath.endsWith('/') ? '' : '/' + }${fileName}`; console.log('[xiaoya] 用户点击的文件路径:', clickedFilePath); } @@ -319,7 +356,9 @@ export async function GET(request: NextRequest) { // 如果有点击的文件路径,找到对应的集数索引 let clickedFileIndex = -1; if (clickedFilePath) { - clickedFileIndex = episodes.findIndex(ep => ep.path === clickedFilePath); + clickedFileIndex = episodes.findIndex( + (ep) => ep.path === clickedFilePath + ); console.log('[xiaoya] 文件在集数列表中的索引:', clickedFileIndex); } @@ -332,12 +371,16 @@ export async function GET(request: NextRequest) { year: metadata.year || '', douban_id: 0, desc: metadata.plot || '', - episodes: episodes.map(ep => `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}`), - episodes_titles: episodes.map(ep => ep.title), + episodes: episodes.map( + (ep) => + `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}` + ), + episodes_titles: episodes.map((ep) => ep.title), subtitles: [], proxyMode: false, // 返回用户点击的文件索引(如果找到的话) - initialEpisodeIndex: clickedFileIndex >= 0 ? clickedFileIndex : undefined, + initialEpisodeIndex: + clickedFileIndex >= 0 ? clickedFileIndex : undefined, // 返回元数据来源 metadataSource: metadata.source, }; @@ -360,11 +403,17 @@ export async function GET(request: NextRequest) { throw new Error('移动云盘未配置或未启用'); } - let session = refreshMobileNetdiskSession(id) || getMobileNetdiskSession(id); + let session = + refreshMobileNetdiskSession(id) || getMobileNetdiskSession(id); if (!session) { const payload = parseMobileNetdiskId(id); - const { listMobileShareVideos } = await import('@/lib/netdisk/mobile.client'); - const result = await listMobileShareVideos(payload.shareUrl, mobileConfig.Authorization); + const { listMobileShareVideos } = await import( + '@/lib/netdisk/mobile.client' + ); + const result = await listMobileShareVideos( + payload.shareUrl, + mobileConfig.Authorization + ); session = createMobileNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -377,8 +426,9 @@ export async function GET(request: NextRequest) { } const mobileSession = session; const { parseVideoFileName } = await import('@/lib/video-parser'); - const parsedFiles = mobileSession.files.map((file, index) => { - const parsed = parseVideoFileName(file.name); + const parsedFiles = mobileSession.files + .map((file, index) => { + const parsed = parseVideoFileName(file.name); return { ...file, originalIndex: index, @@ -386,20 +436,24 @@ export async function GET(request: NextRequest) { isOVA: parsed.isOVA, displayTitle: formatNetdiskEpisodeTitle(parsed, file.name), }; - }).sort((a, b) => { - if (a.isOVA && !b.isOVA) return 1; - if (!a.isOVA && b.isOVA) return -1; - return a.sortEpisode !== b.sortEpisode - ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { - numeric: true, - sensitivity: 'base', - }); - }); + }) + .sort((a, b) => { + if (a.isOVA && !b.isOVA) return 1; + if (!a.isOVA && b.isOVA) return -1; + return a.sortEpisode !== b.sortEpisode + ? a.sortEpisode - b.sortEpisode + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); + }); - const episodes = parsedFiles.map((file) => ( - `/api/netdisk/mobile/play?id=${encodeURIComponent(mobileSession.id)}&episodeIndex=${file.originalIndex}` - )); + const episodes = parsedFiles.map( + (file) => + `/api/netdisk/mobile/play?id=${encodeURIComponent( + mobileSession.id + )}&episodeIndex=${file.originalIndex}` + ); return NextResponse.json({ source: NETDISK_MOBILE_SOURCE, @@ -430,11 +484,18 @@ export async function GET(request: NextRequest) { throw new Error('百度网盘未配置或未启用'); } - let session = refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id); + let session = + refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id); if (!session) { const payload = parseBaiduNetdiskId(id); - const { listBaiduShareVideos } = await import('@/lib/netdisk/baidu.client'); - const result = await listBaiduShareVideos(payload.shareUrl, baiduConfig.Cookie, payload.passcode || ''); + const { listBaiduShareVideos } = await import( + '@/lib/netdisk/baidu.client' + ); + const result = await listBaiduShareVideos( + payload.shareUrl, + baiduConfig.Cookie, + payload.passcode || '' + ); session = createBaiduNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -465,7 +526,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -477,9 +541,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `百度网盘分享:${baiduSession.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/baidu/play?id=${encodeURIComponent(baiduSession.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/baidu/play?id=${encodeURIComponent( + baiduSession.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -495,14 +562,21 @@ export async function GET(request: NextRequest) { try { const config = await getConfig(); const tianyiConfig = config.NetDiskConfig?.Tianyi; - if (!tianyiConfig?.Enabled || !tianyiConfig.Account || !tianyiConfig.Password) { + if ( + !tianyiConfig?.Enabled || + !tianyiConfig.Account || + !tianyiConfig.Password + ) { throw new Error('天翼云盘未配置或未启用'); } - let session = refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id); + let session = + refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id); if (!session) { const payload = parseTianyiNetdiskId(id); - const { listTianyiShareVideos } = await import('@/lib/netdisk/tianyi.client'); + const { listTianyiShareVideos } = await import( + '@/lib/netdisk/tianyi.client' + ); const result = await listTianyiShareVideos( payload.shareUrl, tianyiConfig.Account, @@ -542,7 +616,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -554,9 +631,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `天翼云盘分享:${tianyiSession.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/tianyi/play?id=${encodeURIComponent(tianyiSession.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/tianyi/play?id=${encodeURIComponent( + tianyiSession.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -572,15 +652,25 @@ export async function GET(request: NextRequest) { try { const config = await getConfig(); const pan123Config = config.NetDiskConfig?.Pan123; - if (!pan123Config?.Enabled || !pan123Config.Account || !pan123Config.Password) { + if ( + !pan123Config?.Enabled || + !pan123Config.Account || + !pan123Config.Password + ) { throw new Error('123网盘未配置或未启用'); } - let session = refreshPan123NetdiskSession(id) || getPan123NetdiskSession(id); + 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 || ''); + 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, @@ -610,7 +700,10 @@ export async function GET(request: NextRequest) { 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' }); + : a.fileName.localeCompare(b.fileName, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -622,9 +715,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `123网盘分享:${pan123Session.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/123/play?id=${encodeURIComponent(pan123Session.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/123/play?id=${encodeURIComponent( + pan123Session.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -645,11 +741,17 @@ export async function GET(request: NextRequest) { throw new Error('115网盘未配置或未启用'); } - let session = refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id); + let session = + refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id); if (!session) { const payload = parsePan115NetdiskId(id); - const { listPan115ShareVideos } = await import('@/lib/netdisk/pan115.client'); - const result = await listPan115ShareVideos(payload.shareUrl, payload.passcode || ''); + const { listPan115ShareVideos } = await import( + '@/lib/netdisk/pan115.client' + ); + const result = await listPan115ShareVideos( + payload.shareUrl, + payload.passcode || '' + ); session = createPan115NetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -679,7 +781,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -691,9 +796,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `115网盘分享:${pan115Session.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/115/play?id=${encodeURIComponent(pan115Session.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/115/play?id=${encodeURIComponent( + pan115Session.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -705,7 +813,10 @@ export async function GET(request: NextRequest) { } } - if (sourceCode === NETDISK_QUARK_SOURCE || sourceCode === LEGACY_QUARK_TEMP_SOURCE) { + if ( + sourceCode === NETDISK_QUARK_SOURCE || + sourceCode === LEGACY_QUARK_TEMP_SOURCE + ) { try { const config = await getConfig(); const quarkConfig = config.NetDiskConfig?.Quark; @@ -714,11 +825,18 @@ export async function GET(request: NextRequest) { } const { parseVideoFileName } = await import('@/lib/video-parser'); - let session = refreshQuarkNetdiskSession(id) || getQuarkNetdiskSession(id); + let session = + refreshQuarkNetdiskSession(id) || getQuarkNetdiskSession(id); if (!session) { const payload = parseQuarkNetdiskId(id); - const { listQuarkShareVideos } = await import('@/lib/netdisk/quark.client'); - const result = await listQuarkShareVideos(payload.shareUrl, quarkConfig.Cookie, payload.passcode || ''); + const { listQuarkShareVideos } = await import( + '@/lib/netdisk/quark.client' + ); + const result = await listQuarkShareVideos( + payload.shareUrl, + quarkConfig.Cookie, + payload.passcode || '' + ); session = createQuarkNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -761,9 +879,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `夸克网盘分享:${quarkSession.shareUrl}`, - episodes: episodes.map((ep) => ( - `/api/netdisk/quark/play?id=${encodeURIComponent(quarkSession.id)}&episodeIndex=${ep.originalIndex}` - )), + episodes: episodes.map( + (ep) => + `/api/netdisk/quark/play?id=${encodeURIComponent( + quarkSession.id + )}&episodeIndex=${ep.originalIndex}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, }); @@ -788,7 +909,11 @@ export async function GET(request: NextRequest) { if (!session) { const payload = parseUCNetdiskId(id); const { listUCShareVideos } = await import('@/lib/netdisk/uc.client'); - const result = await listUCShareVideos(payload.shareUrl, ucConfig.Cookie, payload.passcode || ''); + const result = await listUCShareVideos( + payload.shareUrl, + ucConfig.Cookie, + payload.passcode || '' + ); session = createUCNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -831,9 +956,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `UC网盘分享:${ucSession.shareUrl}`, - episodes: episodes.map((ep) => ( - `/api/netdisk/uc/play?id=${encodeURIComponent(ucSession.id)}&episodeIndex=${ep.originalIndex}` - )), + episodes: episodes.map( + (ep) => + `/api/netdisk/uc/play?id=${encodeURIComponent( + ucSession.id + )}&episodeIndex=${ep.originalIndex}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, }); @@ -867,7 +995,9 @@ export async function GET(request: NextRequest) { let metaInfo: any = null; let folderMeta: any = null; try { - const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getCachedMetaInfo, setCachedMetaInfo } = await import( + '@/lib/openlist-cache' + ); const { db } = await import('@/lib/db'); metaInfo = getCachedMetaInfo(); @@ -891,11 +1021,15 @@ export async function GET(request: NextRequest) { // 使用 folderName 构建实际路径 const folderName = folderMeta.folderName; - const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + const folderPath = `${rootPath}${ + rootPath.endsWith('/') ? '' : '/' + }${folderName}`; // 2. 直接调用 OpenList 客户端获取视频列表 const { OpenListClient } = await import('@/lib/openlist.client'); - const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache'); + const { getCachedVideoInfo, setCachedVideoInfo } = await import( + '@/lib/openlist-cache' + ); const { parseVideoFileName } = await import('@/lib/video-parser'); const client = new OpenListClient( @@ -914,7 +1048,11 @@ export async function GET(request: NextRequest) { let hasMore = true; while (hasMore) { - const listResponse = await client.listDirectory(folderPath, currentPage, pageSize); + const listResponse = await client.listDirectory( + folderPath, + currentPage, + pageSize + ); if (listResponse.code !== 200) { throw new Error('OpenList 列表获取失败4'); @@ -927,10 +1065,35 @@ export async function GET(request: NextRequest) { currentPage++; } - const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob']; + const videoExtensions = [ + '.mp4', + '.mkv', + '.avi', + '.m3u8', + '.flv', + '.ts', + '.mov', + '.wmv', + '.webm', + '.rmvb', + '.rm', + '.mpg', + '.mpeg', + '.3gp', + '.f4v', + '.m4v', + '.vob', + ]; const videoFiles = allFiles.filter((item) => { - if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false; - return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)); + if ( + item.is_dir || + item.name.startsWith('.') || + item.name.endsWith('.json') + ) + return false; + return videoExtensions.some((ext) => + item.name.toLowerCase().endsWith(ext) + ); }); if (!videoInfo) { @@ -940,7 +1103,7 @@ export async function GET(request: NextRequest) { const file = videoFiles[i]; const parsed = parseVideoFileName(file.name); videoInfo.episodes[file.name] = { - episode: parsed.episode || (i + 1), + episode: parsed.episode || i + 1, season: parsed.season, title: parsed.title, parsed_from: 'filename', @@ -955,25 +1118,46 @@ export async function GET(request: NextRequest) { const parsed = parseVideoFileName(file.name); let episodeInfo; if (parsed.episode) { - episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA }; + episodeInfo = { + episode: parsed.episode, + season: parsed.season, + title: parsed.title, + parsed_from: 'filename', + isOVA: parsed.isOVA, + }; } else { - episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' }; + episodeInfo = videoInfo!.episodes[file.name] || { + episode: index + 1, + season: undefined, + title: undefined, + parsed_from: 'filename', + }; } let displayTitle = episodeInfo.title; if (!displayTitle && episodeInfo.episode) { - displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`; + displayTitle = episodeInfo.isOVA + ? `OVA ${episodeInfo.episode}` + : `第${episodeInfo.episode}集`; } if (!displayTitle) { displayTitle = file.name; } - return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA }; + return { + fileName: file.name, + episode: episodeInfo.episode || 0, + season: episodeInfo.season, + title: displayTitle, + isOVA: episodeInfo.isOVA, + }; }) .sort((a, b) => { // OVA 排在最后 if (a.isOVA && !b.isOVA) return 1; if (!a.isOVA && b.isOVA) return -1; // 都是 OVA 或都不是 OVA,按集数排序 - return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName); + return a.episode !== b.episode + ? a.episode - b.episode + : a.fileName.localeCompare(b.fileName); }); // 3. 从 metainfo 中获取元数据 @@ -984,11 +1168,20 @@ export async function GET(request: NextRequest) { source_name: '私人影库', id: id, title: folderMeta?.title || folderName, - poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '', - year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '', + poster: folderMeta?.poster_path + ? getTMDBImageUrl(folderMeta.poster_path) + : '', + year: folderMeta?.release_date + ? folderMeta.release_date.split('-')[0] + : '', douban_id: 0, desc: folderMeta?.overview || '', - episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`), + episodes: episodes.map( + (ep) => + `/api/openlist/play?folder=${encodeURIComponent( + folderName + )}&fileName=${encodeURIComponent(ep.fileName)}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, // openlist 源不使用代理模式 }; diff --git a/src/app/page.tsx b/src/app/page.tsx index f3251c8..f22b2b9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,15 @@ 'use client'; -import { BookMarked, BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react'; +import { + BookMarked, + BookOpen, + Bot, + ChevronRight, + Link as LinkIcon, + ListVideo, + Music, +} from 'lucide-react'; import Link from 'next/link'; import { Suspense, useEffect, useState } from 'react'; @@ -56,23 +64,29 @@ function HomeClient() { { id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 }, ]); const [homeBannerEnabled, setHomeBannerEnabled] = useState(true); - const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true); + const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = + useState(true); const [showAnnouncement, setShowAnnouncement] = useState(false); const [showHttpWarning, setShowHttpWarning] = useState(true); const [showAIChat, setShowAIChat] = useState(false); const [aiEnabled, setAiEnabled] = useState(false); - const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'); + const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState( + '你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?' + ); const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true); const [musicEnabled, setMusicEnabled] = useState(false); const [mangaEnabled, setMangaEnabled] = useState(false); const [booksEnabled, setBooksEnabled] = useState(false); + const [netdiskTempPlayEnabled, setNetdiskTempPlayEnabled] = 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): { + const detectNetdiskLink = ( + url: string + ): { provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc' | '115'; shareUrl: string; passcode?: string; @@ -84,11 +98,17 @@ function HomeClient() { const inlinePasscode = (text: string) => pickPasscode( - text.match(/(?:提取码|访问码|密码)\s*[::=]?\s*([a-zA-Z0-9]{4,8})/i)?.[1], + 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)) { + if ( + /https:\/\/(?:www\.)?123(?:684|865|912|pan)\.(?:com|cn)\/s\//i.test( + trimmed + ) + ) { return { provider: '123', shareUrl: trimmed, @@ -99,7 +119,10 @@ function HomeClient() { }; } - if (/https:\/\/cloud\.189\.cn\/(web\/share\?code=|t\/)/i.test(trimmed) || /https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\//i.test(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, @@ -172,21 +195,25 @@ function HomeClient() { setDirectPlaySubmitting(true); try { const netdisk = detectNetdiskLink(trimmed); + if (netdisk && !netdiskTempPlayEnabled) { + throw new Error('无权限使用临时播放'); + } + if (netdisk) { const source = netdisk.provider === 'mobile' ? 'netdisk-mobile' : netdisk.provider === 'baidu' - ? 'netdisk-baidu' - : netdisk.provider === 'tianyi' - ? 'netdisk-tianyi' - : netdisk.provider === '115' - ? 'netdisk-115' - : netdisk.provider === 'uc' - ? 'netdisk-uc' - : netdisk.provider === '123' - ? 'netdisk-123' - : 'netdisk-quark'; + ? 'netdisk-baidu' + : netdisk.provider === 'tianyi' + ? 'netdisk-tianyi' + : netdisk.provider === '115' + ? 'netdisk-115' + : netdisk.provider === 'uc' + ? 'netdisk-uc' + : netdisk.provider === '123' + ? 'netdisk-123' + : 'netdisk-quark'; const id = base58Encode( JSON.stringify({ shareUrl: netdisk.shareUrl, @@ -196,7 +223,11 @@ function HomeClient() { if (!id) { throw new Error('网盘链接编码失败'); } - const targetUrl = `/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(id)}&title=${encodeURIComponent('网盘直链播放')}`; + const targetUrl = `/play?source=${encodeURIComponent( + source + )}&id=${encodeURIComponent(id)}&title=${encodeURIComponent( + '网盘直链播放' + )}`; setShowDirectPlayDialog(false); setDirectPlayUrl(''); window.location.assign(targetUrl); @@ -205,7 +236,9 @@ function HomeClient() { const encoded = base58Encode(trimmed); if (!encoded) return; - const targetUrl = `/play?source=directplay&id=${encodeURIComponent(encoded)}`; + const targetUrl = `/play?source=directplay&id=${encodeURIComponent( + encoded + )}`; setShowDirectPlayDialog(false); setDirectPlayUrl(''); window.location.assign(targetUrl); @@ -237,9 +270,13 @@ function HomeClient() { setHomeBannerEnabled(savedHomeBannerEnabled === 'true'); } - const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled'); + const savedHomeContinueWatchingEnabled = localStorage.getItem( + 'homeContinueWatchingEnabled' + ); if (savedHomeContinueWatchingEnabled !== null) { - setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true'); + setHomeContinueWatchingEnabled( + savedHomeContinueWatchingEnabled === 'true' + ); } }; @@ -256,7 +293,10 @@ function HomeClient() { window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated); return () => { - window.removeEventListener('homeModulesUpdated', handleHomeModulesUpdated); + window.removeEventListener( + 'homeModulesUpdated', + handleHomeModulesUpdated + ); }; }, []); @@ -269,7 +309,8 @@ function HomeClient() { setAiEnabled(enabled); // 加载AI默认消息配置 - const defaultMsg = (window as any).RUNTIME_CONFIG?.AI_DEFAULT_MESSAGE_NO_VIDEO; + const defaultMsg = (window as any).RUNTIME_CONFIG + ?.AI_DEFAULT_MESSAGE_NO_VIDEO; if (defaultMsg) { setAiDefaultMessageNoVideo(defaultMsg); } @@ -279,7 +320,8 @@ function HomeClient() { // 检查源站寻片功能是否启用 useEffect(() => { if (typeof window !== 'undefined') { - const enabled = (window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false; + const enabled = + (window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false; setSourceSearchEnabled(enabled); } }, []); @@ -308,6 +350,15 @@ function HomeClient() { } }, []); + // 检查网盘临时播放权限,仅有权限时在直链播放弹窗展示网盘在线播放提示 + useEffect(() => { + if (typeof window !== 'undefined') { + const enabled = !!(window as any).RUNTIME_CONFIG + ?.NETDISK_TEMP_PLAY_ENABLED; + setNetdiskTempPlayEnabled(enabled); + } + }, []); + // 检查公告弹窗状态 useEffect(() => { if (typeof window !== 'undefined' && announcement) { @@ -336,7 +387,10 @@ function HomeClient() { const setCache = (key: string, data: any) => { try { - localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() })); + localStorage.setItem( + key, + JSON.stringify({ data, timestamp: Date.now() }) + ); } catch { // Ignore localStorage errors } @@ -356,18 +410,43 @@ function HomeClient() { if (duanjuCache?.data) setHotDuanju(duanjuCache.data); if (upcomingCache?.data) setUpcomingContent(upcomingCache.data); - const hasCache = moviesCache || tvShowsCache || varietyCache || bangumiCache || duanjuCache || upcomingCache; + const hasCache = + moviesCache || + tvShowsCache || + varietyCache || + bangumiCache || + duanjuCache || + upcomingCache; if (hasCache) setLoading(false); - const needsRefresh = !moviesCache || moviesCache.expired || !tvShowsCache || tvShowsCache.expired || - !varietyCache || varietyCache.expired || !bangumiCache || bangumiCache.expired || - !duanjuCache || duanjuCache.expired || !upcomingCache || upcomingCache.expired; + const needsRefresh = + !moviesCache || + moviesCache.expired || + !tvShowsCache || + tvShowsCache.expired || + !varietyCache || + varietyCache.expired || + !bangumiCache || + bangumiCache.expired || + !duanjuCache || + duanjuCache.expired || + !upcomingCache || + upcomingCache.expired; if (needsRefresh) { (async () => { try { - const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] = await Promise.all([ - getDoubanCategories({ kind: 'movie', category: '热门', type: '全部' }), + const [ + moviesData, + tvShowsData, + varietyShowsData, + bangumiCalendarData, + ] = await Promise.all([ + getDoubanCategories({ + kind: 'movie', + category: '热门', + type: '全部', + }), getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }), getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }), GetBangumiCalendarData(), @@ -400,7 +479,11 @@ function HomeClient() { const duanjuResponse = await fetch('/api/duanju/recommends'); if (duanjuResponse.ok) { const duanjuResult = await duanjuResponse.json(); - if (duanjuResult.code === 200 && duanjuResult.data && duanjuResult.data.length > 0) { + if ( + duanjuResult.code === 200 && + duanjuResult.data && + duanjuResult.data.length > 0 + ) { setHotDuanju(duanjuResult.data); setCache('homepage_duanju', duanjuResult.data); } @@ -413,10 +496,18 @@ function HomeClient() { const response = await fetch('/api/tmdb/upcoming'); if (response.ok) { const result = await response.json(); - if (result.code === 200 && result.data && result.data.length > 0) { + if ( + result.code === 200 && + result.data && + result.data.length > 0 + ) { const sorted = [...result.data].sort((a, b) => { - const dateA = new Date(a.release_date || '9999-12-31').getTime(); - const dateB = new Date(b.release_date || '9999-12-31').getTime(); + const dateA = new Date( + a.release_date || '9999-12-31' + ).getTime(); + const dateB = new Date( + b.release_date || '9999-12-31' + ).getTime(); return dateA - dateB; }); setUpcomingContent(sorted); @@ -436,8 +527,6 @@ function HomeClient() { } }, []); - - const handleCloseAnnouncement = (announcement: string) => { setShowAnnouncement(false); localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗 @@ -448,7 +537,7 @@ function HomeClient() { switch (moduleId) { case 'hotMovies': return ( -
+

热门电影 @@ -496,7 +585,7 @@ function HomeClient() { case 'hotDuanju': if (hotDuanju.length === 0) return null; return ( -
+

热播短剧 @@ -550,7 +639,7 @@ function HomeClient() { case 'bangumiCalendar': return ( -
+

新番放送 @@ -578,7 +667,15 @@ function HomeClient() { )) : (() => { const today = new Date(); - const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const weekdays = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + ]; const currentWeekday = weekdays[today.getDay()]; const todayAnimes = bangumiCalendarData @@ -615,7 +712,7 @@ function HomeClient() { case 'hotTvShows': return ( -
+

热门剧集 @@ -662,7 +759,7 @@ function HomeClient() { case 'hotVarietyShows': return ( -
+

热门综艺 @@ -699,7 +796,9 @@ function HomeClient() { rate={varietyShow.rate} type='tv' from='douban' - douban_id={varietyShow.id ? parseInt(varietyShow.id) : undefined} + douban_id={ + varietyShow.id ? parseInt(varietyShow.id) : undefined + } />

))} @@ -710,7 +809,7 @@ function HomeClient() { case 'upcomingContent': if (upcomingContent.length === 0) return null; return ( -
+

即将上映 @@ -762,7 +861,11 @@ function HomeClient() { {/* 首页内容 */} <> {/* 源站寻片和AI问片入口 */} -
+
@@ -899,9 +1002,11 @@ function HomeClient() {
请输入可直接播放的视频链接。
-
- 支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。 -
+ {netdiskTempPlayEnabled && ( +
+ 支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。 +
+ )} setDirectPlayUrl(event.target.value)}