1339 lines
41 KiB
TypeScript
1339 lines
41 KiB
TypeScript
/* 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 }
|
||
);
|
||
}
|
||
}
|