增加tvbox订阅功能,自带去广告

This commit is contained in:
mtvpls
2025-12-05 22:33:57 +08:00
parent cc99f4c945
commit e028ba60d7
6 changed files with 640 additions and 5 deletions
+239
View File
@@ -0,0 +1,239 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
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 }
);
}
// 构建完整的 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;
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
/**
* 获取TVBOX订阅配置
*/
export async function GET(request: NextRequest) {
// 验证用户登录
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// 检查是否开启订阅功能
const enableSubscribe = process.env.ENABLE_TVBOX_SUBSCRIBE === 'true';
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
if (!enableSubscribe || !subscribeToken) {
return NextResponse.json(
{
enabled: false,
url: '',
},
{
headers: {
'Cache-Control': 'no-store',
},
}
);
}
// 构建订阅链接
// 优先使用 SITE_BASE 环境变量,如果没有则使用前端传来的 origin
const siteBase = process.env.SITE_BASE;
const searchParams = request.nextUrl.searchParams;
const clientOrigin = searchParams.get('origin');
const adFilter = searchParams.get('adFilter') === 'true'; // 获取去广告参数
const baseUrl = siteBase || clientOrigin || request.nextUrl.origin;
// 构建订阅链接,包含 adFilter 参数
const subscribeUrl = `${baseUrl}/api/tvbox/subscribe?token=${encodeURIComponent(subscribeToken)}&adFilter=${adFilter}`;
return NextResponse.json(
{
enabled: true,
url: subscribeUrl,
},
{
headers: {
'Cache-Control': 'no-store',
},
}
);
}
+137
View File
@@ -0,0 +1,137 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getAvailableApiSites, getConfig } from '@/lib/config';
import { getCachedLiveChannels } from '@/lib/live';
export const runtime = 'nodejs';
/**
* TVBOX订阅API
* 根据视频源和直播源生成TVBOX订阅
*/
export async function GET(request: NextRequest) {
// 检查是否开启订阅功能
const enableSubscribe = process.env.ENABLE_TVBOX_SUBSCRIBE === 'true';
if (!enableSubscribe) {
return NextResponse.json(
{ error: '订阅功能未开启' },
{ status: 403 }
);
}
// 验证token
const searchParams = request.nextUrl.searchParams;
const token = searchParams.get('token');
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
const adFilter = searchParams.get('adFilter') === 'true'; // 获取去广告参数
if (!subscribeToken || token !== subscribeToken) {
return NextResponse.json(
{ error: '无效的订阅token' },
{ status: 401 }
);
}
try {
// 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
const username = authInfo?.username;
// 获取配置
const config = await getConfig();
// 获取视频源
const apiSites = await getAvailableApiSites(username);
// 获取直播源
const liveConfig = config.LiveConfig?.filter(live => !live.disabled) || [];
// 获取当前请求的 origin,用于构建代理链接
// 优先级:SITE_BASE 环境变量 > origin 参数 > 从请求头构建
let baseUrl = process.env.SITE_BASE || searchParams.get('origin');
if (!baseUrl) {
// 从请求头中获取 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');
baseUrl = `${proto}://${host}`;
}
console.log('TVBOX 订阅 baseUrl:', baseUrl, 'adFilter:', adFilter);
// 构建TVBOX订阅数据
const tvboxSubscription = {
// 站点配置
spider: '',
wallpaper: '',
// 视频源站点 - 根据 adFilter 参数决定是否使用代理
sites: apiSites.map(site => ({
key: site.key,
name: site.name,
type: 1,
// 如果开启去广告,使用 CMS 代理;否则使用原始 API
api: adFilter
? `${baseUrl}/api/cms-proxy?api=${encodeURIComponent(site.api)}`
: site.api,
searchable: 1,
quickSearch: 1,
filterable: 1,
ext: site.detail || '',
})),
// 直播源
lives: await Promise.all(
liveConfig.map(async (live) => {
try {
const liveChannels = await getCachedLiveChannels(live.key);
return {
name: live.name,
type: 0,
url: live.url,
epg: live.epg || (liveChannels?.epgUrl || ''),
logo: '',
};
} catch (error) {
return {
name: live.name,
type: 0,
url: live.url,
epg: live.epg || '',
logo: '',
};
}
})
),
// 解析器
parses: [],
// 规则
rules: [],
// 广告配置
ads: [],
};
return NextResponse.json(tvboxSubscription, {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
});
} catch (error) {
console.error('生成TVBOX订阅失败:', error);
return NextResponse.json(
{
error: '生成订阅失败',
details: (error as Error).message,
},
{ status: 500 }
);
}
}