Files
MoonTVPlus/src/app/api/source-detail/route.ts
T

1339 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
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,
parseBaiduNetdiskId,
refreshBaiduNetdiskSession,
} from '@/lib/netdisk/baidu-session-cache';
import {
createMobileNetdiskSession,
getMobileNetdiskSession,
parseMobileNetdiskId,
refreshMobileNetdiskSession,
} from '@/lib/netdisk/mobile-session-cache';
import {
createPan123NetdiskSession,
getPan123NetdiskSession,
parsePan123NetdiskId,
refreshPan123NetdiskSession,
} from '@/lib/netdisk/pan123-session-cache';
import {
createPan115NetdiskSession,
getPan115NetdiskSession,
parsePan115NetdiskId,
refreshPan115NetdiskSession,
} from '@/lib/netdisk/pan115-session-cache';
import {
createQuarkNetdiskSession,
getQuarkNetdiskSession,
parseQuarkNetdiskId,
refreshQuarkNetdiskSession,
} from '@/lib/netdisk/quark-session-cache';
import {
LEGACY_QUARK_TEMP_SOURCE,
NETDISK_115_SOURCE,
NETDISK_123_SOURCE,
NETDISK_BAIDU_SOURCE,
NETDISK_MOBILE_SOURCE,
NETDISK_QUARK_SOURCE,
NETDISK_TIANYI_SOURCE,
NETDISK_UC_SOURCE,
isNetdiskSource,
normalizeNetdiskSource,
} from '@/lib/netdisk/source';
import {
createTianyiNetdiskSession,
getTianyiNetdiskSession,
parseTianyiNetdiskId,
refreshTianyiNetdiskSession,
} from '@/lib/netdisk/tianyi-session-cache';
import {
createUCNetdiskSession,
getUCNetdiskSession,
parseUCNetdiskId,
refreshUCNetdiskSession,
} from '@/lib/netdisk/uc-session-cache';
import {
executeSavedSourceScript,
normalizeScriptDetailResult,
normalizeScriptSources,
parseScriptSourceValue,
} from '@/lib/source-script';
export const runtime = 'nodejs';
/**
* 解析站点 origin。
* 优先级:SITE_BASE(站点 url 环境变量)> NEXT_PUBLIC_SITE_URL > 请求头 Host。
*/
function getRequestSiteOrigin(request: NextRequest): string {
const fromEnv =
(process.env.SITE_BASE || '').trim() ||
(process.env.NEXT_PUBLIC_SITE_URL || '').trim();
if (fromEnv) {
return fromEnv.replace(/\/$/, '');
}
let host =
request.headers.get('host') || request.headers.get('x-forwarded-host');
if (host && !/^[a-zA-Z0-9.-]+(:\d+)?$/.test(host)) {
host = null;
}
if (!host) {
try {
host = new URL(request.url).host;
} catch {
host = 'localhost';
}
}
const proto =
request.headers.get('x-forwarded-proto') ||
(host.includes('localhost') || host.includes('127.0.0.1')
? 'http'
: 'https');
return `${proto}://${host}`.replace(/\/$/, '');
}
/**
* MoonTVPlus APP / OrionTV 客户端:对配置的视频源 m3u8 套一层去广告代理。
* UA 小写包含 "moontvplus app" 或 "oriontv" 时生效(不匹配仅含 moontvplus 的其它客户端)。
*/
function applyClientAdProxyToEpisodes(
request: NextRequest,
sourceCode: string,
episodes: string[] | undefined,
clientAdSourceApis: string[] | undefined
): string[] | undefined {
if (!episodes || episodes.length === 0) return episodes;
if (!clientAdSourceApis || !clientAdSourceApis.includes(sourceCode)) {
return episodes;
}
const ua = (request.headers.get('user-agent') || '').toLowerCase();
if (!ua.includes('moontvplus app') && !ua.includes('oriontv')) {
return episodes;
}
const origin = getRequestSiteOrigin(request);
return episodes.map((episode) => {
if (!episode || typeof episode !== 'string') return episode;
if (
episode.includes('/api/proxy-m3u8') ||
episode.includes('/api/proxy/vod/m3u8')
) {
return episode;
}
// 仅处理 http(s) 直链 m3u8,站内相对播放地址不改写
if (!/^https?:\/\//i.test(episode)) return episode;
return `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episode)}`;
});
}
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页面快速获取当前源的详情
*/
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const sourceCode = normalizeNetdiskSource(searchParams.get('source'));
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
const title = searchParams.get('title');
const includeSpecialSources = searchParams.get('special') === '1';
if (!id || !sourceCode) {
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 {
const sourcesExecution = await executeSavedSourceScript({
key: parsedScriptSource.scriptKey,
hook: 'getSources',
payload: {},
});
const sources = normalizeScriptSources(sourcesExecution.result);
const sourceInfo = sources.find(
(item) => item.id === parsedScriptSource.sourceId
) || {
id: parsedScriptSource.sourceId,
name: parsedScriptSource.sourceId,
};
const detailExecution = await executeSavedSourceScript({
key: parsedScriptSource.scriptKey,
hook: 'detail',
payload: {
id,
sourceId: parsedScriptSource.sourceId,
},
});
const normalized = normalizeScriptDetailResult({
source: sourceCode,
scriptKey: parsedScriptSource.scriptKey,
scriptName: detailExecution.meta?.name || parsedScriptSource.scriptKey,
sourceId: parsedScriptSource.sourceId,
sourceName: sourceInfo.name,
detailId: id,
result: detailExecution.result,
});
return NextResponse.json(normalized);
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// 特殊处理 emby 源(支持多源)
if (sourceCode === 'emby' || sourceCode.startsWith('emby_')) {
try {
const config = await getConfig();
// 检查是否有启用的 Emby 源
if (
!config.EmbyConfig?.Sources ||
config.EmbyConfig.Sources.length === 0
) {
throw new Error('Emby 未配置或未启用');
}
// 解析 embyKey
let embyKey: string | undefined;
if (sourceCode.startsWith('emby_')) {
embyKey = sourceCode.substring(5); // 'emby_'.length = 5
}
// 使用 EmbyManager 获取客户端和配置
const { embyManager } = await import('@/lib/emby-manager');
const sources = await embyManager.getEnabledSources();
const sourceConfig = sources.find((s) => s.key === embyKey);
const sourceName = sourceConfig?.name || 'Emby';
const client = await embyManager.getClient(embyKey);
// 获取代理 token(图片/字幕代理使用;没有 token 时会回退到登录态校验)
const proxyToken = await getProxyToken(request);
// 获取媒体详情
const item = await client.getItem(id);
// 根据类型处理
if (item.Type === 'Movie') {
// 电影
const subtitles = client.getSubtitles(item, proxyToken);
const result = {
source: sourceCode, // 保持与请求一致(emby 或 emby_key
source_name: sourceName,
id: item.Id,
title: item.Name,
poster: client.getImageUrl(
item.Id,
'Primary',
undefined,
proxyToken || undefined
),
year: item.ProductionYear?.toString() || '',
douban_id: 0,
desc: item.Overview || '',
episodes: [await client.getStreamUrl(item.Id)],
episodes_titles: [item.Name],
subtitles: subtitles.length > 0 ? [subtitles] : [],
proxyMode: false,
};
return NextResponse.json(result);
} else if (item.Type === 'Series') {
// 剧集 - 获取所有季和集
const seasons = await client.getSeasons(item.Id);
const allEpisodes: any[] = [];
for (const season of seasons) {
const episodes = await client.getEpisodes(item.Id, season.Id);
allEpisodes.push(...episodes);
}
// 按季和集排序
allEpisodes.sort((a, b) => {
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
}
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
});
const result = {
source: sourceCode, // 保持与请求一致(emby 或 emby_key
source_name: sourceName,
id: item.Id,
title: item.Name,
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_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')}`;
}),
subtitles: allEpisodes.map((ep) => client.getSubtitles(ep, proxyToken)),
proxyMode: false,
};
return NextResponse.json(result);
} else {
throw new Error('不支持的媒体类型');
}
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// 特殊处理 xiaoya 源
if (sourceCode === 'xiaoya') {
try {
const config = await getConfig();
const xiaoyaConfig = config.XiaoyaConfig;
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 { base58Decode, base58Encode } = await import('@/lib/utils');
const client = new XiaoyaClient(
xiaoyaConfig.ServerURL,
xiaoyaConfig.Username,
xiaoyaConfig.Password,
xiaoyaConfig.Token
);
// 对id进行base58解码得到目录路径
let decodedDirPath: string;
try {
decodedDirPath = base58Decode(id);
console.log('[xiaoya] 解码目录路径:', decodedDirPath);
} catch (decodeError) {
console.error('[xiaoya] Base58解码失败:', decodeError);
throw new Error('无效的视频ID');
}
// 验证解码后的路径
if (!decodedDirPath || decodedDirPath.trim() === '') {
throw new Error('解码后的路径为空');
}
// 如果有fileName参数,拼接完整文件路径
let clickedFilePath: string | undefined;
if (fileName) {
// 拼接目录路径和文件名
clickedFilePath = `${decodedDirPath}${
decodedDirPath.endsWith('/') ? '' : '/'
}${fileName}`;
console.log('[xiaoya] 用户点击的文件路径:', clickedFilePath);
}
// 获取元数据(使用目录路径或点击的文件路径)
const metadataPath = clickedFilePath || decodedDirPath;
const metadata = await getXiaoyaMetadata(
client,
metadataPath,
config.SiteConfig.TMDBApiKey,
config.SiteConfig.TMDBProxy,
config.SiteConfig.TMDBReverseProxy
);
// 获取集数列表(使用目录路径或点击的文件路径)
const episodes = await getXiaoyaEpisodes(client, metadataPath);
// 如果有点击的文件路径,找到对应的集数索引
let clickedFileIndex = -1;
if (clickedFilePath) {
clickedFileIndex = episodes.findIndex(
(ep) => ep.path === clickedFilePath
);
console.log('[xiaoya] 文件在集数列表中的索引:', clickedFileIndex);
}
const result = {
source: 'xiaoya',
source_name: '小雅',
id: id, // 保持编码后的目录id
title: metadata.title,
poster: metadata.poster || '',
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),
subtitles: [],
proxyMode: false,
// 返回用户点击的文件索引(如果找到的话)
initialEpisodeIndex:
clickedFileIndex >= 0 ? clickedFileIndex : undefined,
// 返回元数据来源
metadataSource: metadata.source,
};
return NextResponse.json(result);
} catch (error) {
console.error('[xiaoya] 获取详情失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
if (sourceCode === NETDISK_MOBILE_SOURCE) {
try {
const config = await getConfig();
const mobileConfig = config.NetDiskConfig?.Mobile;
if (!mobileConfig?.Enabled || !mobileConfig.Authorization) {
throw new Error('移动云盘未配置或未启用');
}
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
);
session = createMobileNetdiskSession({
title: title || result.title,
shareUrl: payload.shareUrl,
passcode: payload.passcode,
files: result.files,
});
}
if (!session) {
throw new Error('移动云盘播放信息恢复失败');
}
const mobileSession = session;
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: 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',
});
});
const episodes = parsedFiles.map(
(file) =>
`/api/netdisk/mobile/play?id=${encodeURIComponent(
mobileSession.id
)}&episodeIndex=${file.originalIndex}`
);
return NextResponse.json({
source: NETDISK_MOBILE_SOURCE,
source_name: '移动云盘',
id: mobileSession.id,
title: title || mobileSession.title,
poster: '',
year: '',
douban_id: 0,
desc: `移动云盘分享:${mobileSession.shareUrl}`,
episodes,
episodes_titles: parsedFiles.map((file) => file.displayTitle),
proxyMode: false,
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
if (sourceCode === NETDISK_BAIDU_SOURCE) {
try {
const config = await getConfig();
const baiduConfig = config.NetDiskConfig?.Baidu;
if (!baiduConfig?.Enabled || !baiduConfig.Cookie) {
throw new Error('百度网盘未配置或未启用');
}
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 || ''
);
session = createBaiduNetdiskSession({
title: title || result.title,
shareUrl: payload.shareUrl,
passcode: payload.passcode,
files: result.files,
meta: result.meta,
cookie: result.cookie,
});
}
if (!session) {
throw new Error('百度网盘播放信息恢复失败');
}
const baiduSession = session;
const { parseVideoFileName } = await import('@/lib/video-parser');
const parsedFiles = baiduSession.files
.map((file, index) => {
const parsed = parseVideoFileName(file.name);
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
? a.sortEpisode - b.sortEpisode
: a.name.localeCompare(b.name, 'zh-Hans-CN', {
numeric: true,
sensitivity: 'base',
});
});
return NextResponse.json({
source: NETDISK_BAIDU_SOURCE,
source_name: '百度网盘',
id: baiduSession.id,
title: title || baiduSession.title,
poster: '',
year: '',
douban_id: 0,
desc: `百度网盘分享:${baiduSession.shareUrl}`,
episodes: parsedFiles.map(
(file) =>
`/api/netdisk/baidu/play?id=${encodeURIComponent(
baiduSession.id
)}&episodeIndex=${file.originalIndex}`
),
episodes_titles: parsedFiles.map((file) => file.displayTitle),
proxyMode: false,
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
if (sourceCode === NETDISK_TIANYI_SOURCE) {
try {
const config = await getConfig();
const tianyiConfig = config.NetDiskConfig?.Tianyi;
if (
!tianyiConfig?.Enabled ||
!tianyiConfig.Account ||
!tianyiConfig.Password
) {
throw new Error('天翼云盘未配置或未启用');
}
let session =
refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id);
if (!session) {
const payload = parseTianyiNetdiskId(id);
const { listTianyiShareVideos } = await import(
'@/lib/netdisk/tianyi.client'
);
const result = await listTianyiShareVideos(
payload.shareUrl,
tianyiConfig.Account,
tianyiConfig.Password,
payload.passcode || ''
);
session = createTianyiNetdiskSession({
title: title || result.title,
shareUrl: payload.shareUrl,
passcode: payload.passcode,
shareId: result.shareId,
shareMode: result.shareMode,
isFolder: result.isFolder,
accessCode: result.accessCode,
files: result.files,
});
}
if (!session) {
throw new Error('天翼云盘播放信息恢复失败');
}
const tianyiSession = session;
const { parseVideoFileName } = await import('@/lib/video-parser');
const parsedFiles = tianyiSession.files
.map((file, index) => {
const parsed = parseVideoFileName(file.name);
return {
...file,
originalIndex: index,
sortEpisode: parsed.episode || index + 1,
isOVA: parsed.isOVA,
displayTitle: 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',
});
});
return NextResponse.json({
source: NETDISK_TIANYI_SOURCE,
source_name: '天翼云盘',
id: tianyiSession.id,
title: title || tianyiSession.title,
poster: '',
year: '',
douban_id: 0,
desc: `天翼云盘分享:${tianyiSession.shareUrl}`,
episodes: parsedFiles.map(
(file) =>
`/api/netdisk/tianyi/play?id=${encodeURIComponent(
tianyiSession.id
)}&episodeIndex=${file.originalIndex}`
),
episodes_titles: parsedFiles.map((file) => file.displayTitle),
proxyMode: false,
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
if (sourceCode === NETDISK_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_115_SOURCE) {
try {
const config = await getConfig();
const pan115Config = config.NetDiskConfig?.Pan115;
if (!pan115Config?.Enabled || !pan115Config.Cookie) {
throw new Error('115网盘未配置或未启用');
}
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 || ''
);
session = createPan115NetdiskSession({
title: title || result.title,
shareUrl: payload.shareUrl,
passcode: payload.passcode,
files: result.files,
});
}
if (!session) {
throw new Error('115网盘播放信息恢复失败');
}
const pan115Session = session;
const { parseVideoFileName } = await import('@/lib/video-parser');
const parsedFiles = pan115Session.files
.map((file, index) => {
const parsed = parseVideoFileName(file.name);
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
? a.sortEpisode - b.sortEpisode
: a.name.localeCompare(b.name, 'zh-Hans-CN', {
numeric: true,
sensitivity: 'base',
});
});
return NextResponse.json({
source: NETDISK_115_SOURCE,
source_name: '115网盘',
id: pan115Session.id,
title: title || pan115Session.title,
poster: '',
year: '',
douban_id: 0,
desc: `115网盘分享:${pan115Session.shareUrl}`,
episodes: parsedFiles.map(
(file) =>
`/api/netdisk/115/play?id=${encodeURIComponent(
pan115Session.id
)}&episodeIndex=${file.originalIndex}`
),
episodes_titles: parsedFiles.map((file) => file.displayTitle),
proxyMode: false,
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
if (
sourceCode === NETDISK_QUARK_SOURCE ||
sourceCode === LEGACY_QUARK_TEMP_SOURCE
) {
try {
const config = await getConfig();
const quarkConfig = config.NetDiskConfig?.Quark;
if (!quarkConfig?.Enabled || !quarkConfig.Cookie) {
throw new Error('夸克网盘未配置或未启用');
}
const { parseVideoFileName } = await import('@/lib/video-parser');
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 || ''
);
session = createQuarkNetdiskSession({
title: title || result.title,
shareUrl: payload.shareUrl,
passcode: payload.passcode,
shareId: result.shareId,
shareToken: result.shareToken,
files: result.files,
});
}
if (!session) {
throw new Error('夸克网盘播放信息恢复失败');
}
const quarkSession = session;
const episodes = quarkSession.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_QUARK_SOURCE,
source_name: '夸克网盘',
id: quarkSession.id,
title: title || quarkSession.title,
poster: '',
year: '',
douban_id: 0,
desc: `夸克网盘分享:${quarkSession.shareUrl}`,
episodes: episodes.map(
(ep) =>
`/api/netdisk/quark/play?id=${encodeURIComponent(
quarkSession.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 }
);
}
}
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
if (sourceCode === 'openlist') {
try {
const config = await getConfig();
const openListConfig = config.OpenListConfig;
if (
!openListConfig ||
!openListConfig.Enabled ||
!openListConfig.URL ||
!openListConfig.Username ||
!openListConfig.Password
) {
throw new Error('OpenList 未配置或未启用');
}
const rootPath = openListConfig.RootPath || '/';
// 1. 读取 metainfo 获取元数据
let metaInfo: any = null;
let folderMeta: any = null;
try {
const { getCachedMetaInfo, setCachedMetaInfo } = await import(
'@/lib/openlist-cache'
);
const { db } = await import('@/lib/db');
metaInfo = getCachedMetaInfo();
if (!metaInfo) {
const metainfoJson = await db.getGlobalValue('video.metainfo');
if (metainfoJson) {
metaInfo = JSON.parse(metainfoJson);
setCachedMetaInfo(metaInfo);
}
}
// 使用 key 查找文件夹信息
folderMeta = metaInfo?.folders?.[id];
if (!folderMeta) {
throw new Error('未找到该视频信息');
}
} catch (error) {
throw new Error('读取视频信息失败: ' + (error as Error).message);
}
// 使用 folderName 构建实际路径
const folderName = folderMeta.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 { parseVideoFileName } = await import('@/lib/video-parser');
const client = new OpenListClient(
openListConfig.URL,
openListConfig.Username,
openListConfig.Password
);
let videoInfo = getCachedVideoInfo(folderPath);
// 获取所有分页的视频文件
const allFiles: any[] = [];
let currentPage = 1;
const pageSize = 100;
let total = 0;
let hasMore = true;
while (hasMore) {
const listResponse = await client.listDirectory(
folderPath,
currentPage,
pageSize
);
if (listResponse.code !== 200) {
throw new Error('OpenList 列表获取失败4');
}
total = listResponse.data.total;
allFiles.push(...listResponse.data.content);
hasMore = allFiles.length < total;
currentPage++;
}
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 (!videoInfo) {
videoInfo = { episodes: {}, last_updated: Date.now() };
videoFiles.sort((a, b) => a.name.localeCompare(b.name));
for (let i = 0; i < videoFiles.length; i++) {
const file = videoFiles[i];
const parsed = parseVideoFileName(file.name);
videoInfo.episodes[file.name] = {
episode: parsed.episode || i + 1,
season: parsed.season,
title: parsed.title,
parsed_from: 'filename',
isOVA: parsed.isOVA,
};
}
setCachedVideoInfo(folderPath, videoInfo);
}
const episodes = videoFiles
.map((file, index) => {
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,
};
} else {
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}集`;
}
if (!displayTitle) {
displayTitle = file.name;
}
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);
});
// 3. 从 metainfo 中获取元数据
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
const { resolvePathMeta } = await import('@/lib/openlist-path-meta');
// folderName 为 metainfo 完整路径,PathMeta 最长前缀匹配
const pathMetaResolved = resolvePathMeta(
folderName,
openListConfig.PathMeta
);
const result = {
source: 'openlist',
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]
: '',
douban_id: 0,
desc: folderMeta?.overview || '',
episodes: episodes.map(
(ep) =>
`/api/openlist/play?folder=${encodeURIComponent(
folderName
)}&fileName=${encodeURIComponent(ep.fileName)}`
),
episodes_titles: episodes.map((ep) => ep.title),
proxyMode: false, // openlist 源不使用代理模式
category: pathMetaResolved.category || undefined,
refresh14m: pathMetaResolved.refresh14m,
};
return NextResponse.json(result);
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
if (!/^[\w-]+$/.test(id)) {
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
}
// 对于其他采集源,直接按 id 获取详情。
try {
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
}
const result = await getDetailFromApiV2(apiSite, id);
// 添加 proxyMode 到返回结果
const resultWithProxy = {
...result,
proxyMode: apiSite.proxyMode || false,
};
// 客户端广告配置:指定源 + APP/OrionTV UA 时 m3u8 套 proxy-m3u8
const adminConfig = await getConfig();
const clientAdEnabled = (adminConfig.ClientAdSourceApis || []).includes(
sourceCode
);
resultWithProxy.episodes =
applyClientAdProxyToEpisodes(
request,
sourceCode,
resultWithProxy.episodes,
adminConfig.ClientAdSourceApis
) || resultWithProxy.episodes;
const cacheTime = await getCacheTime();
// 同一源在不同 UA 下 episodes 可能不同,避免 CDN/共享缓存串号
if (clientAdEnabled) {
return NextResponse.json(resultWithProxy, {
headers: {
'Cache-Control': 'private, no-store',
Vary: 'User-Agent',
'Netlify-Vary': 'query',
},
});
}
return NextResponse.json(resultWithProxy, {
headers: {
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
'Netlify-Vary': 'query',
},
});
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}