增加客户端去广告配置
This commit is contained in:
+204
-46
@@ -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,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 }>;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user