增加tvbox订阅功能,自带去广告
This commit is contained in:
25
README.md
25
README.md
@@ -57,7 +57,8 @@
|
|||||||
- [环境变量](#环境变量)
|
- [环境变量](#环境变量)
|
||||||
- [弹幕后端部署](#弹幕后端部署)
|
- [弹幕后端部署](#弹幕后端部署)
|
||||||
- [超分功能说明](#超分功能说明)
|
- [超分功能说明](#超分功能说明)
|
||||||
- [AndroidTV 使用](#AndroidTV-使用)
|
- [AndroidTV 使用](#androidtv-使用)
|
||||||
|
- [TVBOX 订阅功能](#tvbox-订阅功能)
|
||||||
- [安全与隐私提醒](#安全与隐私提醒)
|
- [安全与隐私提醒](#安全与隐私提醒)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
- [致谢](#致谢)
|
- [致谢](#致谢)
|
||||||
@@ -226,7 +227,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
||||||
@@ -243,6 +244,8 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||||
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) |
|
||||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||||
|
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||||
|
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
||||||
|
|
||||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||||
|
|
||||||
@@ -288,6 +291,24 @@ NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
|
|||||||
|
|
||||||
已实现播放记录和网页端同步
|
已实现播放记录和网页端同步
|
||||||
|
|
||||||
|
## TVBOX 订阅功能
|
||||||
|
|
||||||
|
本项目支持生成 TVBOX 格式的订阅链接,方便在 TVBOX 应用中使用。
|
||||||
|
|
||||||
|
### 配置步骤
|
||||||
|
|
||||||
|
1. 在环境变量中设置以下配置:
|
||||||
|
```env
|
||||||
|
# 启用 TVBOX 订阅功能
|
||||||
|
ENABLE_TVBOX_SUBSCRIBE=true
|
||||||
|
# 设置订阅访问 Token(请使用强密码)
|
||||||
|
TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮
|
||||||
|
|
||||||
|
3. 复制生成的订阅链接到 TVBOX 应用中使用
|
||||||
|
|
||||||
## 安全与隐私提醒
|
## 安全与隐私提醒
|
||||||
|
|
||||||
### 请设置密码保护并关闭公网注册
|
### 请设置密码保护并关闭公网注册
|
||||||
|
|||||||
239
src/app/api/cms-proxy/route.ts
Normal file
239
src/app/api/cms-proxy/route.ts
Normal 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
src/app/api/tvbox/config/route.ts
Normal file
61
src/app/api/tvbox/config/route.ts
Normal 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
src/app/api/tvbox/subscribe/route.ts
Normal file
137
src/app/api/tvbox/subscribe/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Rss,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
User,
|
User,
|
||||||
@@ -36,14 +38,21 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||||
|
const [isSubscribeOpen, setIsSubscribeOpen] = useState(false);
|
||||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// 订阅相关状态
|
||||||
|
const [subscribeEnabled, setSubscribeEnabled] = useState(false);
|
||||||
|
const [subscribeUrl, setSubscribeUrl] = useState('');
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
const [adFilterEnabled, setAdFilterEnabled] = useState(true); // 去广告开关,默认开启
|
||||||
|
|
||||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSettingsOpen || isChangePasswordOpen) {
|
if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen) {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
|
|
||||||
@@ -62,7 +71,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
html.style.overflow = originalHtmlOverflow;
|
html.style.overflow = originalHtmlOverflow;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isSettingsOpen, isChangePasswordOpen]);
|
}, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen]);
|
||||||
|
|
||||||
// 设置相关状态
|
// 设置相关状态
|
||||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||||
@@ -117,6 +126,28 @@ export const UserMenu: React.FC = () => {
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 获取订阅配置
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSubscribeConfig = async () => {
|
||||||
|
try {
|
||||||
|
// 获取当前浏览器地址
|
||||||
|
const currentOrigin = window.location.origin;
|
||||||
|
const response = await fetch(`/api/tvbox/config?origin=${encodeURIComponent(currentOrigin)}&adFilter=${adFilterEnabled}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSubscribeEnabled(data.enabled);
|
||||||
|
setSubscribeUrl(data.url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取订阅配置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
fetchSubscribeConfig();
|
||||||
|
}
|
||||||
|
}, [adFilterEnabled]); // 依赖 adFilterEnabled,当开关改变时重新获取
|
||||||
|
|
||||||
// 获取认证信息和存储类型
|
// 获取认证信息和存储类型
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -275,6 +306,29 @@ export const UserMenu: React.FC = () => {
|
|||||||
setPasswordError('');
|
setPasswordError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubscribe = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsSubscribeOpen(true);
|
||||||
|
setCopySuccess(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSubscribe = () => {
|
||||||
|
setIsSubscribeOpen(false);
|
||||||
|
setCopySuccess(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopySubscribeUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(subscribeUrl);
|
||||||
|
setCopySuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopySuccess(false);
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmitChangePassword = async () => {
|
const handleSubmitChangePassword = async () => {
|
||||||
setPasswordError('');
|
setPasswordError('');
|
||||||
|
|
||||||
@@ -560,6 +614,17 @@ export const UserMenu: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 订阅按钮 */}
|
||||||
|
{subscribeEnabled && (
|
||||||
|
<button
|
||||||
|
onClick={handleSubscribe}
|
||||||
|
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||||
|
>
|
||||||
|
<Rss className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||||
|
<span className='font-medium'>订阅</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 分割线 */}
|
{/* 分割线 */}
|
||||||
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
@@ -1026,6 +1091,113 @@ export const UserMenu: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 订阅面板内容
|
||||||
|
const subscribePanel = (
|
||||||
|
<>
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||||
|
onClick={handleCloseSubscribe}
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 订阅面板 */}
|
||||||
|
<div
|
||||||
|
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='h-full p-6'
|
||||||
|
data-panel-content
|
||||||
|
onTouchMove={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
touchAction: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
订阅
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseSubscribe}
|
||||||
|
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||||
|
aria-label='Close'
|
||||||
|
>
|
||||||
|
<X className='w-full h-full' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容 */}
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* 去广告开关 */}
|
||||||
|
<div className='flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
去广告
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
开启后自动过滤视频广告
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={adFilterEnabled}
|
||||||
|
onChange={(e) => setAdFilterEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||||
|
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TVBOX订阅 */}
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
TVBOX订阅
|
||||||
|
</h4>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 cursor-not-allowed'
|
||||||
|
value={subscribeUrl}
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCopySubscribeUrl}
|
||||||
|
className='px-4 py-2 bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 text-white text-sm font-medium rounded-md transition-colors flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<Copy className='w-4 h-4' />
|
||||||
|
{copySuccess ? '已复制' : '复制'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部说明 */}
|
||||||
|
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||||
|
将订阅链接复制到TVBOX应用中使用
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
// 修改密码面板内容
|
// 修改密码面板内容
|
||||||
const changePasswordPanel = (
|
const changePasswordPanel = (
|
||||||
<>
|
<>
|
||||||
@@ -1171,6 +1343,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
mounted &&
|
mounted &&
|
||||||
createPortal(changePasswordPanel, document.body)}
|
createPortal(changePasswordPanel, document.body)}
|
||||||
|
|
||||||
|
{/* 使用 Portal 将订阅面板渲染到 document.body */}
|
||||||
|
{isSubscribeOpen &&
|
||||||
|
mounted &&
|
||||||
|
createPortal(subscribePanel, document.body)}
|
||||||
|
|
||||||
{/* 版本面板 */}
|
{/* 版本面板 */}
|
||||||
<VersionPanel
|
<VersionPanel
|
||||||
isOpen={isVersionPanelOpen}
|
isOpen={isVersionPanelOpen}
|
||||||
|
|||||||
@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/proxy-m3u8).*)',
|
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user