增加客户端去广告配置

This commit is contained in:
mtvpls
2026-07-17 19:14:01 +08:00
parent bd779b237c
commit 1bbea13c62
5 changed files with 332 additions and 46 deletions
+204 -46
View File
@@ -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<string[]>([]);
const [clientAdSourceDraftApis, setClientAdSourceDraftApis] = useState<string[]>([]);
const [weightDraftSources, setWeightDraftSources] = useState<DataSource[]>(
[]
);
@@ -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 = ({
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></div>
</>
)}
<div className='flex items-center gap-2 overflow-x-auto whitespace-nowrap order-1 sm:order-2'>
<button
onClick={openSpecialSourcesModal}
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
title='批量选择哪些视频源属于特殊源'
>
<Settings size={14} />
<span></span>
{(config?.SpecialSourceApis?.length || 0) > 0 && (
<span className='rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 dark:bg-rose-900/30 dark:text-rose-300'>
{config?.SpecialSourceApis?.length}
</span>
)}
</button>
<button
onClick={openWeightModal}
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
title='拖动排序并批量生成推荐权重'
>
<Settings size={14} />
<span></span>
</button>
<button
onClick={() => setShowValidationModal(true)}
disabled={isValidating}
className={`px-3 py-1 text-sm rounded-lg transition-colors flex shrink-0 items-center space-x-1 whitespace-nowrap ${
isValidating ? buttonStyles.disabled : buttonStyles.primary
}`}
>
{isValidating ? (
<>
<div className='w-3 h-3 border border-white border-t-transparent rounded-full animate-spin'></div>
<span>...</span>
</>
) : (
'有效性检测'
)}
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={`${
showAddForm ? buttonStyles.secondary : buttonStyles.success
} shrink-0 whitespace-nowrap`}
>
{showAddForm ? '取消' : '添加视频源'}
</button>
<div className='flex w-full flex-col gap-2 order-1 sm:order-2 sm:w-auto sm:flex-row sm:items-center sm:gap-2'>
<div className='w-full overflow-x-auto sm:w-auto'>
<div className='ml-auto flex w-max items-center gap-2 whitespace-nowrap'>
<button
onClick={openSpecialSourcesModal}
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
title='批量选择哪些视频源属于特殊源'
>
<Settings size={14} />
<span></span>
{(config?.SpecialSourceApis?.length || 0) > 0 && (
<span className='rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 dark:bg-rose-900/30 dark:text-rose-300'>
{config?.SpecialSourceApis?.length}
</span>
)}
</button>
<button
onClick={openWeightModal}
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
title='拖动排序并批量生成推荐权重'
>
<Settings size={14} />
<span></span>
</button>
<button
onClick={openClientAdSourcesModal}
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
title='选择在手机/电视客户端播放时自动去广告的视频源'
>
<Settings size={14} />
<span>广</span>
{(config?.ClientAdSourceApis?.length || 0) > 0 && (
<span className='rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'>
{config?.ClientAdSourceApis?.length}
</span>
)}
</button>
</div>
</div>
<div className='w-full overflow-x-auto sm:w-auto'>
<div className='ml-auto flex w-max items-center gap-2 whitespace-nowrap'>
<button
onClick={() => setShowValidationModal(true)}
disabled={isValidating}
className={`px-3 py-1 text-sm rounded-lg transition-colors flex shrink-0 items-center space-x-1 whitespace-nowrap ${
isValidating ? buttonStyles.disabled : buttonStyles.primary
}`}
>
{isValidating ? (
<>
<div className='w-3 h-3 border border-white border-t-transparent rounded-full animate-spin'></div>
<span>...</span>
</>
) : (
'有效性检测'
)}
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={`${
showAddForm ? buttonStyles.secondary : buttonStyles.success
} shrink-0 whitespace-nowrap`}
>
{showAddForm ? '取消' : '添加视频源'}
</button>
</div>
</div>
</div>
</div>
</div>
@@ -7662,6 +7707,119 @@ const VideoSourceConfig = ({
document.body
)}
{showClientAdSourcesModal &&
createPortal(
<div
className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm'
onClick={closeClientAdSourcesModal}
>
<div
className='flex max-h-[84vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800'
onClick={(e) => e.stopPropagation()}
>
<div className='flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-5 dark:border-gray-700'>
<div>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
广
</h3>
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
使 MoonTVPlus APP OrionTV /广
</p>
</div>
<button
onClick={closeClientAdSourcesModal}
className='text-2xl leading-none text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300'
aria-label='关闭客户端去广告配置弹窗'
>
×
</button>
</div>
<div className='min-h-0 flex-1 overflow-y-auto px-6 py-5'>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
{config?.SourceConfig?.map((source) => (
<label
key={source.key}
className='flex cursor-pointer items-center space-x-3 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-900/50'
>
<input
type='checkbox'
checked={clientAdSourceDraftApis.includes(source.key)}
onChange={(e) => {
if (e.target.checked) {
setClientAdSourceDraftApis((prev) =>
prev.includes(source.key) ? prev : [...prev, source.key]
);
} else {
setClientAdSourceDraftApis((prev) =>
prev.filter((api) => api !== source.key)
);
}
}}
className='rounded border-gray-300 text-amber-600 focus:ring-amber-500 dark:border-gray-600 dark:bg-gray-700'
/>
<div className='min-w-0 flex-1'>
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
{source.name}
</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
{source.key}
</div>
</div>
</label>
))}
</div>
</div>
<div className='flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-900/30'>
<div className='flex flex-wrap gap-2'>
<button
onClick={() => setClientAdSourceDraftApis([])}
className={buttonStyles.quickAction}
>
</button>
<button
onClick={() => {
const allApis =
config?.SourceConfig?.filter((source) => !source.disabled).map(
(source) => source.key
) || [];
setClientAdSourceDraftApis(allApis);
}}
className={buttonStyles.quickAction}
>
</button>
</div>
<div className='flex items-center gap-3'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
<span className='font-medium text-amber-600 dark:text-amber-400'>
{clientAdSourceDraftApis.length}
</span>
</span>
<button onClick={closeClientAdSourcesModal} className={buttonStyles.secondary}>
</button>
<button
onClick={handleSaveClientAdSources}
disabled={isLoading('saveClientAdSources')}
className={`px-4 py-2 ${
isLoading('saveClientAdSources')
? buttonStyles.disabled
: buttonStyles.success
}`}
>
{isLoading('saveClientAdSources') ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{showWeightModal &&
createPortal(
<>
+21
View File
@@ -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 }>;
+96
View File
@@ -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}`,
+1
View File
@@ -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;
+10
View File
@@ -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<string>();