424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
|
||
import { getConfig } from '@/lib/config';
|
||
import { db } from '@/lib/db';
|
||
import { OpenListClient } from '@/lib/openlist.client';
|
||
import {
|
||
getCachedMetaInfo,
|
||
MetaInfo,
|
||
setCachedMetaInfo,
|
||
} from '@/lib/openlist-cache';
|
||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||
|
||
export const runtime = 'nodejs';
|
||
|
||
/**
|
||
* CMS 采集站代理接口
|
||
* 用于代理 CMS API 请求,并自动将播放链接替换为带去广告的代理链接
|
||
* GET /api/cms-proxy?api=<CMS API地址>&参数1=值1&参数2=值2...
|
||
*/
|
||
export async function GET(request: NextRequest) {
|
||
try {
|
||
const { searchParams } = new URL(request.url);
|
||
const apiUrl = searchParams.get('api');
|
||
|
||
if (!apiUrl) {
|
||
return NextResponse.json(
|
||
{ error: '缺少必要参数: api' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 特殊处理 openlist
|
||
if (apiUrl === 'openlist') {
|
||
return handleOpenListProxy(request);
|
||
}
|
||
|
||
// 构建完整的 API 请求 URL,包含所有查询参数
|
||
const targetUrl = new URL(apiUrl);
|
||
|
||
// 将所有查询参数(除了 api)转发到目标 API
|
||
searchParams.forEach((value, key) => {
|
||
if (key !== 'api') {
|
||
targetUrl.searchParams.append(key, value);
|
||
}
|
||
});
|
||
|
||
// 请求原始 CMS API
|
||
console.log('CMS 代理请求:', targetUrl.toString());
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时
|
||
|
||
try {
|
||
const response = await fetch(targetUrl.toString(), {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||
'Accept': 'application/json',
|
||
},
|
||
signal: controller.signal,
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
console.error('CMS API 请求失败:', response.status, response.statusText);
|
||
return NextResponse.json(
|
||
{ error: '请求 CMS API 失败' },
|
||
{ status: response.status }
|
||
);
|
||
}
|
||
|
||
const data = await response.json();
|
||
console.log('CMS API 返回数据:', {
|
||
code: data.code,
|
||
msg: data.msg,
|
||
page: data.page,
|
||
pagecount: data.pagecount,
|
||
limit: data.limit,
|
||
total: data.total,
|
||
listCount: data.list?.length || 0,
|
||
});
|
||
|
||
// 获取当前请求的 origin
|
||
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||
let origin = process.env.SITE_BASE;
|
||
|
||
if (!origin) {
|
||
// 从请求头中获取 Host 和协议
|
||
const host = request.headers.get('host') || request.headers.get('x-forwarded-host');
|
||
const proto = request.headers.get('x-forwarded-proto') ||
|
||
(host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https');
|
||
origin = `${proto}://${host}`;
|
||
}
|
||
|
||
console.log('CMS 代理 origin:', origin);
|
||
|
||
// 处理返回数据,替换播放链接为代理链接
|
||
const processedData = processPlayUrls(data, origin);
|
||
|
||
// 输出处理后的第一个视频的播放信息(用于调试)
|
||
if (processedData.list && processedData.list.length > 0) {
|
||
const firstItem = processedData.list[0];
|
||
console.log('第一个视频处理后的播放信息:', {
|
||
vod_name: firstItem.vod_name,
|
||
vod_play_from: firstItem.vod_play_from,
|
||
vod_play_url_length: firstItem.vod_play_url?.length || 0,
|
||
vod_play_url_preview: firstItem.vod_play_url?.substring(0, 200) || '',
|
||
});
|
||
}
|
||
|
||
return NextResponse.json(processedData, {
|
||
headers: {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||
},
|
||
});
|
||
} catch (fetchError: any) {
|
||
clearTimeout(timeoutId);
|
||
|
||
if (fetchError.name === 'AbortError') {
|
||
console.error('CMS API 请求超时:', targetUrl.toString());
|
||
return NextResponse.json(
|
||
{ error: '请求超时' },
|
||
{ status: 504 }
|
||
);
|
||
}
|
||
|
||
throw fetchError;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('CMS 代理失败:', error);
|
||
return NextResponse.json(
|
||
{ error: '代理失败', details: (error as Error).message },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理 CMS API 返回数据,将播放链接替换为代理链接
|
||
*/
|
||
function processPlayUrls(data: any, proxyOrigin: string): any {
|
||
if (!data || typeof data !== 'object') {
|
||
return data;
|
||
}
|
||
|
||
// 深拷贝数据,避免修改原始对象
|
||
const processedData = JSON.parse(JSON.stringify(data));
|
||
|
||
// 获取 M3U8 代理 token
|
||
const proxyToken = process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN || '';
|
||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||
|
||
// 处理列表数据
|
||
if (processedData.list && Array.isArray(processedData.list)) {
|
||
processedData.list = processedData.list.map((item: any, index: number) => {
|
||
// 只处理有播放地址的项目
|
||
if (item.vod_play_url && typeof item.vod_play_url === 'string') {
|
||
try {
|
||
const originalUrl = item.vod_play_url;
|
||
item.vod_play_url = processPlayUrlString(item.vod_play_url, item.vod_play_from || '', proxyOrigin, tokenParam);
|
||
|
||
// 只为第一个视频输出详细日志
|
||
if (index === 0) {
|
||
console.log('播放地址处理:', {
|
||
vod_name: item.vod_name,
|
||
vod_play_from: item.vod_play_from,
|
||
original_length: originalUrl.length,
|
||
processed_length: item.vod_play_url.length,
|
||
original_preview: originalUrl.substring(0, 100),
|
||
processed_preview: item.vod_play_url.substring(0, 150),
|
||
});
|
||
}
|
||
} catch (error) {
|
||
// 如果处理失败,保持原样
|
||
console.error('处理播放地址失败:', error, item.vod_name);
|
||
}
|
||
}
|
||
return item;
|
||
});
|
||
}
|
||
|
||
return processedData;
|
||
}
|
||
|
||
/**
|
||
* 处理播放地址字符串
|
||
* 格式: 第01集$url1#第02集$url2#...
|
||
*/
|
||
function processPlayUrlString(playUrl: string, playFrom: string, proxyOrigin: string, tokenParam: string): string {
|
||
if (!playUrl) return playUrl;
|
||
|
||
// 按 $ 分割,分别处理每个播放源
|
||
const playSources = playUrl.split('$$$');
|
||
|
||
return playSources.map(source => {
|
||
// 处理每个播放源的剧集列表
|
||
const episodes = source.split('#');
|
||
|
||
return episodes.map(episode => {
|
||
// 格式: 第01集$url 或 url
|
||
// 使用 indexOf 找到第一个 $ 的位置
|
||
const dollarIndex = episode.indexOf('$');
|
||
|
||
if (dollarIndex > 0) {
|
||
// 有标题的格式: 第01集$url 或 第01集$url$其他
|
||
const title = episode.substring(0, dollarIndex);
|
||
const rest = episode.substring(dollarIndex + 1);
|
||
|
||
// 检查后面是否还有 $,如果有就保留
|
||
const nextDollarIndex = rest.indexOf('$');
|
||
if (nextDollarIndex > 0) {
|
||
// 格式: 第01集$url$其他
|
||
const url = rest.substring(0, nextDollarIndex);
|
||
const other = rest.substring(nextDollarIndex);
|
||
const processedUrl = processUrl(url.trim(), playFrom, proxyOrigin, tokenParam);
|
||
return `${title}$${processedUrl}${other}`;
|
||
} else {
|
||
// 格式: 第01集$url
|
||
const processedUrl = processUrl(rest.trim(), playFrom, proxyOrigin, tokenParam);
|
||
return `${title}$${processedUrl}`;
|
||
}
|
||
} else if (episode.trim()) {
|
||
// 只有 URL 的格式
|
||
const processedUrl = processUrl(episode.trim(), playFrom, proxyOrigin, tokenParam);
|
||
return processedUrl;
|
||
}
|
||
|
||
return episode;
|
||
}).join('#');
|
||
}).join('$$$');
|
||
}
|
||
|
||
/**
|
||
* 处理单个播放地址
|
||
*/
|
||
function processUrl(url: string, playFrom: string, proxyOrigin: string, tokenParam: string): string {
|
||
if (!url) return url;
|
||
|
||
// 只处理 m3u8 链接
|
||
if (url.includes('.m3u8')) {
|
||
// 提取播放源类型(如果有的话)
|
||
const source = playFrom ? `&source=${encodeURIComponent(playFrom)}` : '';
|
||
|
||
// 将 m3u8 链接替换为代理链接
|
||
return `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source}${tokenParam}`;
|
||
}
|
||
|
||
// 非 m3u8 链接不处理
|
||
return url;
|
||
}
|
||
|
||
/**
|
||
* 处理 OpenList 代理请求
|
||
*/
|
||
async function handleOpenListProxy(request: NextRequest) {
|
||
const { searchParams } = new URL(request.url);
|
||
const wd = searchParams.get('wd'); // 搜索关键词
|
||
const ids = searchParams.get('ids'); // 详情ID
|
||
|
||
const config = await getConfig();
|
||
const openListConfig = config.OpenListConfig;
|
||
|
||
if (!openListConfig || !openListConfig.URL || !openListConfig.Username || !openListConfig.Password) {
|
||
return NextResponse.json(
|
||
{ code: 0, msg: 'OpenList 未配置', list: [] },
|
||
{ status: 200 }
|
||
);
|
||
}
|
||
|
||
const rootPath = openListConfig.RootPath || '/';
|
||
const client = new OpenListClient(
|
||
openListConfig.URL,
|
||
openListConfig.Username,
|
||
openListConfig.Password
|
||
);
|
||
|
||
// 读取 metainfo (从数据库或缓存)
|
||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||
|
||
if (!metaInfo) {
|
||
try {
|
||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||
if (metainfoJson) {
|
||
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
||
setCachedMetaInfo(rootPath, metaInfo);
|
||
}
|
||
} catch (error) {
|
||
return NextResponse.json(
|
||
{ code: 0, msg: 'metainfo 不存在', list: [] },
|
||
{ status: 200 }
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!metaInfo) {
|
||
return NextResponse.json(
|
||
{ code: 0, msg: '无数据', list: [] },
|
||
{ status: 200 }
|
||
);
|
||
}
|
||
|
||
// 搜索模式
|
||
if (wd) {
|
||
const results = Object.entries(metaInfo.folders)
|
||
.filter(
|
||
([key, info]) =>
|
||
info.folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
||
info.title.toLowerCase().includes(wd.toLowerCase())
|
||
)
|
||
.map(([key, info]) => ({
|
||
vod_id: key,
|
||
vod_name: info.title,
|
||
vod_pic: getTMDBImageUrl(info.poster_path),
|
||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||
vod_year: info.release_date.split('-')[0] || '',
|
||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||
}));
|
||
|
||
return NextResponse.json({
|
||
code: 1,
|
||
msg: '数据列表',
|
||
page: 1,
|
||
pagecount: 1,
|
||
limit: results.length,
|
||
total: results.length,
|
||
list: results,
|
||
});
|
||
}
|
||
|
||
// 详情模式
|
||
if (ids) {
|
||
const key = ids;
|
||
const info = metaInfo.folders[key];
|
||
|
||
if (!info) {
|
||
return NextResponse.json(
|
||
{ code: 0, msg: '视频不存在', list: [] },
|
||
{ status: 200 }
|
||
);
|
||
}
|
||
|
||
const folderName = info.folderName;
|
||
|
||
// 获取视频详情
|
||
try {
|
||
const detailResponse = await fetch(
|
||
`${request.headers.get('x-forwarded-proto') || 'http'}://${request.headers.get('host')}/api/openlist/detail?folder=${encodeURIComponent(folderName)}`
|
||
);
|
||
|
||
if (!detailResponse.ok) {
|
||
throw new Error('获取视频详情失败');
|
||
}
|
||
|
||
const detailData = await detailResponse.json();
|
||
|
||
if (!detailData.success) {
|
||
throw new Error('获取视频详情失败');
|
||
}
|
||
|
||
// 构建播放列表
|
||
const playUrls = detailData.episodes
|
||
.map((ep: any) => {
|
||
const title = ep.title || `第${ep.episode}集`;
|
||
return `${title}$${ep.playUrl}`;
|
||
})
|
||
.join('#');
|
||
|
||
return NextResponse.json({
|
||
code: 1,
|
||
msg: '数据列表',
|
||
page: 1,
|
||
pagecount: 1,
|
||
limit: 1,
|
||
total: 1,
|
||
list: [
|
||
{
|
||
vod_id: key,
|
||
vod_name: info.title,
|
||
vod_pic: getTMDBImageUrl(info.poster_path),
|
||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||
vod_year: info.release_date.split('-')[0] || '',
|
||
vod_content: info.overview,
|
||
vod_play_from: 'OpenList',
|
||
vod_play_url: playUrls,
|
||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||
},
|
||
],
|
||
});
|
||
} catch (error) {
|
||
console.error('获取 OpenList 视频详情失败:', error);
|
||
return NextResponse.json(
|
||
{ code: 0, msg: '获取详情失败', list: [] },
|
||
{ status: 200 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// 默认返回所有视频
|
||
const results = Object.entries(metaInfo.folders).map(
|
||
([key, info]) => ({
|
||
vod_id: key,
|
||
vod_name: info.title,
|
||
vod_pic: getTMDBImageUrl(info.poster_path),
|
||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||
vod_year: info.release_date.split('-')[0] || '',
|
||
type_name: info.media_type === 'movie' ? '电影' : '电视剧',
|
||
})
|
||
);
|
||
|
||
return NextResponse.json({
|
||
code: 1,
|
||
msg: '数据列表',
|
||
page: 1,
|
||
pagecount: 1,
|
||
limit: results.length,
|
||
total: results.length,
|
||
list: results,
|
||
});
|
||
}
|