diff --git a/README.md b/README.md index b05ff12..73996a6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ - [环境变量](#环境变量) - [弹幕后端部署](#弹幕后端部署) - [超分功能说明](#超分功能说明) -- [AndroidTV 使用](#AndroidTV-使用) +- [AndroidTV 使用](#androidtv-使用) +- [TVBOX 订阅功能](#tvbox-订阅功能) - [安全与隐私提醒](#安全与隐私提醒) - [License](#license) - [致谢](#致谢) @@ -226,7 +227,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 | ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 | | PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 | -| SITE_BASE | 站点 url | 形如 https://example.com | 空 | +| SITE_BASE | 站点 url | 形如 https://example.com | 空 | | NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV | | ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | | 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_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) | | 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 选项解释: @@ -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 应用中使用 + ## 安全与隐私提醒 ### 请设置密码保护并关闭公网注册 diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts new file mode 100644 index 0000000..2b29ef7 --- /dev/null +++ b/src/app/api/cms-proxy/route.ts @@ -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=&参数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; +} diff --git a/src/app/api/tvbox/config/route.ts b/src/app/api/tvbox/config/route.ts new file mode 100644 index 0000000..fa64adf --- /dev/null +++ b/src/app/api/tvbox/config/route.ts @@ -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', + }, + } + ); +} diff --git a/src/app/api/tvbox/subscribe/route.ts b/src/app/api/tvbox/subscribe/route.ts new file mode 100644 index 0000000..be72f69 --- /dev/null +++ b/src/app/api/tvbox/subscribe/route.ts @@ -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 } + ); + } +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 2fa2765..2672400 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -5,9 +5,11 @@ import { Check, ChevronDown, + Copy, ExternalLink, KeyRound, LogOut, + Rss, Settings, Shield, User, @@ -36,14 +38,21 @@ export const UserMenu: React.FC = () => { const [isOpen, setIsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); + const [isSubscribeOpen, setIsSubscribeOpen] = useState(false); const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false); const [authInfo, setAuthInfo] = useState(null); const [storageType, setStorageType] = useState('localstorage'); 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 方式避免布局问题 useEffect(() => { - if (isSettingsOpen || isChangePasswordOpen) { + if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen) { const body = document.body; const html = document.documentElement; @@ -62,7 +71,7 @@ export const UserMenu: React.FC = () => { html.style.overflow = originalHtmlOverflow; }; } - }, [isSettingsOpen, isChangePasswordOpen]); + }, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen]); // 设置相关状态 const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true); @@ -117,6 +126,28 @@ export const UserMenu: React.FC = () => { 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(() => { if (typeof window !== 'undefined') { @@ -275,6 +306,29 @@ export const UserMenu: React.FC = () => { 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 () => { setPasswordError(''); @@ -560,6 +614,17 @@ export const UserMenu: React.FC = () => { )} + {/* 订阅按钮 */} + {subscribeEnabled && ( + + )} + {/* 分割线 */}
@@ -1026,6 +1091,113 @@ export const UserMenu: React.FC = () => { ); + // 订阅面板内容 + const subscribePanel = ( + <> + {/* 背景遮罩 */} +
{ + e.preventDefault(); + }} + onWheel={(e) => { + e.preventDefault(); + }} + style={{ + touchAction: 'none', + }} + /> + + {/* 订阅面板 */} +
+
{ + e.stopPropagation(); + }} + style={{ + touchAction: 'auto', + }} + > + {/* 标题栏 */} +
+

+ 订阅 +

+ +
+ + {/* 内容 */} +
+ {/* 去广告开关 */} +
+
+

+ 去广告 +

+

+ 开启后自动过滤视频广告 +

+
+ +
+ + {/* TVBOX订阅 */} +
+

+ TVBOX订阅 +

+
+ + +
+
+
+ + {/* 底部说明 */} +
+

+ 将订阅链接复制到TVBOX应用中使用 +

+
+
+
+ + ); + // 修改密码面板内容 const changePasswordPanel = ( <> @@ -1171,6 +1343,11 @@ export const UserMenu: React.FC = () => { mounted && createPortal(changePasswordPanel, document.body)} + {/* 使用 Portal 将订阅面板渲染到 document.body */} + {isSubscribeOpen && + mounted && + createPortal(subscribePanel, document.body)} + {/* 版本面板 */}