diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2175885..db55eb6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -6357,7 +6357,9 @@ const VideoSourceConfig = ({ const [showValidationModal, setShowValidationModal] = useState(false); const [showWeightModal, setShowWeightModal] = useState(false); const [showSpecialSourcesModal, setShowSpecialSourcesModal] = useState(false); + const [showClientAdSourcesModal, setShowClientAdSourcesModal] = useState(false); const [specialSourceDraftApis, setSpecialSourceDraftApis] = useState([]); + const [clientAdSourceDraftApis, setClientAdSourceDraftApis] = useState([]); const [weightDraftSources, setWeightDraftSources] = useState( [] ); @@ -6514,6 +6516,28 @@ const VideoSourceConfig = ({ }); }; + const openClientAdSourcesModal = () => { + setClientAdSourceDraftApis(config?.ClientAdSourceApis || []); + setShowClientAdSourcesModal(true); + }; + + const closeClientAdSourcesModal = () => { + setShowClientAdSourcesModal(false); + setClientAdSourceDraftApis([]); + }; + + const handleSaveClientAdSources = async () => { + await withLoading('saveClientAdSources', async () => { + await callSourceApi({ + action: 'set_client_ad_sources', + keys: clientAdSourceDraftApis, + }); + closeClientAdSourcesModal(); + }).catch(() => { + console.error('操作失败', 'set_client_ad_sources'); + }); + }; + const handleSaveSpecialSources = async () => { const enabledSourceKeys = config?.SourceConfig?.filter((source) => !source.disabled).map( @@ -7375,52 +7399,73 @@ const VideoSourceConfig = ({
)} -
- - - - +
+
+
+ + + +
+
+
+
+ + +
+
@@ -7662,6 +7707,119 @@ const VideoSourceConfig = ({ document.body )} + {showClientAdSourcesModal && + createPortal( +
+
e.stopPropagation()} + > +
+
+

+ 客户端去广告配置 +

+

+ 勾选后,用户使用 MoonTVPlus APP 或 OrionTV 观看这些视频源时,会自动过滤片头/插播广告。 +

+
+ +
+ +
+
+ {config?.SourceConfig?.map((source) => ( + + ))} +
+
+ +
+
+ + +
+
+ + 已选择: + + {clientAdSourceDraftApis.length} 个源 + + + + +
+
+
+
, + document.body + )} + {showWeightModal && createPortal( <> diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index b04ff58..76ec7ca 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -21,6 +21,7 @@ type Action = | 'toggle_proxy_mode' | 'toggle_special_source' | 'set_special_sources' + | 'set_client_ad_sources' | 'update_weight' | 'batch_update_weights'; @@ -62,6 +63,7 @@ export async function POST(request: NextRequest) { 'toggle_proxy_mode', 'toggle_special_source', 'set_special_sources', + 'set_client_ad_sources', 'update_weight', 'batch_update_weights', ]; @@ -153,6 +155,9 @@ export async function POST(request: NextRequest) { adminConfig.SpecialSourceApis = (adminConfig.SpecialSourceApis || []).filter( (api) => api !== key ); + adminConfig.ClientAdSourceApis = (adminConfig.ClientAdSourceApis || []).filter( + (api) => api !== key + ); // 检查并清理用户组和用户的权限数组 // 清理用户组权限 @@ -236,6 +241,9 @@ export async function POST(request: NextRequest) { adminConfig.SpecialSourceApis = (adminConfig.SpecialSourceApis || []).filter( (api) => !keysToDelete.includes(api) ); + adminConfig.ClientAdSourceApis = (adminConfig.ClientAdSourceApis || []).filter( + (api) => !keysToDelete.includes(api) + ); // 检查并清理用户组和用户的权限数组 if (keysToDelete.length > 0) { @@ -334,6 +342,19 @@ export async function POST(request: NextRequest) { ); break; } + + case 'set_client_ad_sources': { + const { keys } = body as { keys?: string[] }; + if (!Array.isArray(keys)) { + return NextResponse.json({ error: 'keys 参数格式错误' }, { status: 400 }); + } + + const sourceKeySet = new Set(adminConfig.SourceConfig.map((source) => source.key)); + adminConfig.ClientAdSourceApis = Array.from(new Set(keys)).filter((key) => + sourceKeySet.has(key) + ); + break; + } case 'batch_update_weights': { const { weights, order } = body as { weights?: Array<{ key?: string; weight?: number }>; diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 403bf5d..ead372d 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -70,6 +70,78 @@ import { 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; @@ -1215,8 +1287,32 @@ export async function GET(request: NextRequest) { 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}`, diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 513c24f..f6f4a9f 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -95,6 +95,7 @@ export interface AdminConfig { }[]; }; SpecialSourceApis?: string[]; // 特殊源 key 列表,默认对普通入口隐藏 + ClientAdSourceApis?: string[]; // 客户端去广告源 key 列表:MoonTVPlus APP / OrionTV 请求 source-detail 时 m3u8 套 proxy-m3u8 SourceConfig: { key: string; name: string; diff --git a/src/lib/config.ts b/src/lib/config.ts index 6522bae..333d1f1 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -359,6 +359,7 @@ async function getInitConfig( : Array.isArray(cfgFile.specialSourceApis) ? cfgFile.specialSourceApis : [], + ClientAdSourceApis: [], }; // 用户信息已迁移到新版数据库,不再填充 UserConfig.Users @@ -674,6 +675,12 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { ) { adminConfig.SpecialSourceApis = []; } + if ( + !adminConfig.ClientAdSourceApis || + !Array.isArray(adminConfig.ClientAdSourceApis) + ) { + adminConfig.ClientAdSourceApis = []; + } adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours( adminConfig.LiveRefreshIntervalHours ); @@ -728,6 +735,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { adminConfig.SpecialSourceApis = Array.from( new Set((adminConfig.SpecialSourceApis || []).filter((key) => validSourceKeys.has(key))) ); + adminConfig.ClientAdSourceApis = Array.from( + new Set((adminConfig.ClientAdSourceApis || []).filter((key) => validSourceKeys.has(key))) + ); // 自定义分类去重 const seenCustomCategoryKeys = new Set();