diff --git a/CHANGELOG b/CHANGELOG index a81719c..1f32e4a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,14 @@ +## [200.3.0] - 2025-12-05 + +### Added +- 增加自定义去广告功能 + +### Changed +- 现在外部播放器支持去广告了 + +### Fixed +- 修复首页/api/favorites接口重复请求 + ## [200.2.0] - 2025-12-04 ### Added diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts new file mode 100644 index 0000000..f1a86cc --- /dev/null +++ b/src/app/api/proxy-m3u8/route.ts @@ -0,0 +1,214 @@ +import { NextResponse } from 'next/server'; + +import { getConfig } from '@/lib/config'; + +export const runtime = 'nodejs'; + +/** + * M3U8 代理接口 + * 用于外部播放器访问,会执行去广告逻辑并处理相对链接 + * GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源> + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const m3u8Url = searchParams.get('url'); + const source = searchParams.get('source') || ''; + + if (!m3u8Url) { + return NextResponse.json( + { error: '缺少必要参数: url' }, + { status: 400 } + ); + } + + // 获取当前请求的 origin + const requestUrl = new URL(request.url); + const origin = `${requestUrl.protocol}//${requestUrl.host}`; + + // 获取原始 m3u8 内容 + const response = await fetch(m3u8Url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (!response.ok) { + return NextResponse.json( + { error: '获取 m3u8 文件失败' }, + { status: response.status } + ); + } + + let m3u8Content = await response.text(); + + // 执行去广告逻辑 + const config = await getConfig(); + const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || ''; + + if (customAdFilterCode && customAdFilterCode.trim()) { + try { + // 移除 TypeScript 类型注解,转换为纯 JavaScript + const jsCode = customAdFilterCode + .replace(/(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*([,)])/g, '$1$3') + .replace(/\)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*\{/g, ') {') + .replace(/(const|let|var)\s+(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*=/g, '$1 $2 ='); + + // 创建并执行自定义函数 + const customFunction = new Function('type', 'm3u8Content', + jsCode + '\nreturn filterAdsFromM3U8(type, m3u8Content);' + ); + m3u8Content = customFunction(source, m3u8Content); + } catch (err) { + console.error('执行自定义去广告代码失败,使用默认规则:', err); + // 继续使用默认规则 + m3u8Content = filterAdsFromM3U8Default(source, m3u8Content); + } + } else { + // 使用默认去广告规则 + m3u8Content = filterAdsFromM3U8Default(source, m3u8Content); + } + + // 处理 m3u8 中的相对链接 + m3u8Content = resolveM3u8Links(m3u8Content, m3u8Url, source, origin); + + // 返回处理后的 m3u8 内容 + return new NextResponse(m3u8Content, { + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-cache', + }, + }); + } catch (error) { + console.error('代理 m3u8 失败:', error); + return NextResponse.json( + { error: '代理失败', details: (error as Error).message }, + { status: 500 } + ); + } +} + +/** + * 默认去广告规则 + */ +function filterAdsFromM3U8Default(type: string, m3u8Content: string): string { + if (!m3u8Content) return ''; + + // 按行分割M3U8内容 + const lines = m3u8Content.split('\n'); + const filteredLines = []; + + let nextdelete = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (nextdelete) { + nextdelete = false; + continue; + } + + // 只过滤#EXT-X-DISCONTINUITY标识 + if (!line.includes('#EXT-X-DISCONTINUITY')) { + if ( + type === 'ruyi' && + (line.includes('EXTINF:5.640000') || + line.includes('EXTINF:2.960000') || + line.includes('EXTINF:3.480000') || + line.includes('EXTINF:4.000000') || + line.includes('EXTINF:0.960000') || + line.includes('EXTINF:10.000000') || + line.includes('EXTINF:1.266667')) + ) { + nextdelete = true; + continue; + } + + filteredLines.push(line); + } + } + + return filteredLines.join('\n'); +} + +/** + * 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接 + */ +function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string): string { + const lines = m3u8Content.split('\n'); + const resolvedLines = []; + + // 解析基础URL + const base = new URL(baseUrl); + const baseDir = base.href.substring(0, base.href.lastIndexOf('/') + 1); + + let isNextLineUrl = false; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + + // 处理 EXT-X-KEY 标签中的 URI + if (line.startsWith('#EXT-X-KEY:')) { + // 提取 URI 部分 + const uriMatch = line.match(/URI="([^"]+)"/); + if (uriMatch && uriMatch[1]) { + let keyUri = uriMatch[1]; + + // 转换为绝对路径 + if (!keyUri.startsWith('http://') && !keyUri.startsWith('https://')) { + if (keyUri.startsWith('/')) { + keyUri = `${base.protocol}//${base.host}${keyUri}`; + } else { + keyUri = new URL(keyUri, baseDir).href; + } + + // 替换原来的 URI + line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`); + } + } + resolvedLines.push(line); + continue; + } + + // 注释行直接保留 + if (line.startsWith('#')) { + resolvedLines.push(line); + // 检查是否是 EXT-X-STREAM-INF,下一行将是子 m3u8 + if (line.startsWith('#EXT-X-STREAM-INF:')) { + isNextLineUrl = true; + } + continue; + } + + // 空行直接保留 + if (line.trim() === '') { + resolvedLines.push(line); + continue; + } + + // 处理 URL 行 + let url = line.trim(); + + // 1. 先转换为绝对 URL + if (!url.startsWith('http://') && !url.startsWith('https://')) { + if (url.startsWith('/')) { + // 以 / 开头,相对于域名根目录 + url = `${base.protocol}//${base.host}${url}`; + } else { + // 相对于当前目录 + url = new URL(url, baseDir).href; + } + } + + // 2. 检查是否是子 m3u8,如果是,转换为代理链接 + const isM3u8 = url.includes('.m3u8') || isNextLineUrl; + if (isM3u8) { + url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}`; + } + + resolvedLines.push(url); + isNextLineUrl = false; + } + + return resolvedLines.join('\n'); +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 1d870f5..3abf6ff 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -112,6 +112,20 @@ function PlayPageClient() { blockAdEnabledRef.current = blockAdEnabled; }, [blockAdEnabled]); + // 外部播放器去广告开关(独立状态,默认 false) + const [externalPlayerAdBlock, setExternalPlayerAdBlock] = useState(() => { + if (typeof window !== 'undefined') { + const v = localStorage.getItem('external_player_adblock'); + if (v !== null) return v === 'true'; + } + return false; + }); + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('external_player_adblock', String(externalPlayerAdBlock)); + } + }, [externalPlayerAdBlock]); + // 自定义去广告代码(从服务器获取并缓存) const customAdFilterCodeRef = useRef(''); @@ -3399,84 +3413,101 @@ function PlayPageClient() { {videoUrl && (
-
- {/* 下载按钮 */} - + + + + + 下载 + + - {/* PotPlayer */} - + {/* PotPlayer */} + {/* VLC */} +
+ + {/* 去广告开关 */} +
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index d8f1115..8134351 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -10,6 +10,17 @@ export interface ChangelogEntry { } export const changelog: ChangelogEntry[] = [ + { + version: '200.3.0', + date: '2025-12-05', + added: [ + '增加自定义去广告功能' + ], + changed: [ + '现在外部播放器支持去广告了' + ], + fixed: ['修复首页/api/favorites接口重复请求'], + }, { version: '200.2.0', date: '2025-12-04', diff --git a/src/lib/version.ts b/src/lib/version.ts index d5a2943..bc22c5b 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -const CURRENT_VERSION = '200.2.0'; +const CURRENT_VERSION = '200.3.0'; // 导出当前版本号供其他地方使用 export { CURRENT_VERSION }; diff --git a/src/middleware.ts b/src/middleware.ts index b672d79..5991606 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean { // 配置middleware匹配规则 export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)', + '/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config|api/proxy-m3u8).*)', ], };