From c53801d3bfd8f88c79bdc2ed6142c0f5c7981612 Mon Sep 17 00:00:00 2001 From: Troray Date: Wed, 29 Jul 2026 13:38:31 +0800 Subject: [PATCH 1/2] feat(ad-filter): upgrade M3U8 ad filtering algorithm with dynamic script sandbox, zero false-positive framerate protections, and Edge API --- app/api/ad-filter/route.ts | 40 +++++ components/player/hooks/useHlsPlayer.ts | 12 +- components/player/hooks/usePlayerSettings.ts | 14 ++ lib/store/settings-store.ts | 4 + lib/utils/m3u8-ad-detector.ts | 89 +++++++++- lib/utils/m3u8-utils.ts | 60 ++++++- tests/m3u8-ad-detector.test.ts | 164 +++++++++++++++++++ 7 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 app/api/ad-filter/route.ts diff --git a/app/api/ad-filter/route.ts b/app/api/ad-filter/route.ts new file mode 100644 index 0000000..7b7a9e9 --- /dev/null +++ b/app/api/ad-filter/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/ad-filter + * Public endpoint to fetch custom ad filter code and versioning. + * Query params: + * - ?full=true : returns { version, code } + * - default : returns { version } + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const full = searchParams.get('full') === 'true'; + + const code = process.env.CUSTOM_AD_FILTER_CODE || ''; + const version = process.env.CUSTOM_AD_FILTER_VERSION + ? parseInt(process.env.CUSTOM_AD_FILTER_VERSION, 10) + : (code ? 1 : 0); + + if (full) { + return NextResponse.json({ + code, + version, + }); + } + + return NextResponse.json({ + version, + }); + } catch (error) { + console.error('[AdFilter API] Error fetching custom filter:', error); + return NextResponse.json( + { error: 'Failed to fetch ad filter config', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/components/player/hooks/useHlsPlayer.ts b/components/player/hooks/useHlsPlayer.ts index 4f4c194..426ce4d 100644 --- a/components/player/hooks/useHlsPlayer.ts +++ b/components/player/hooks/useHlsPlayer.ts @@ -22,7 +22,7 @@ export function useHlsPlayer({ onError }: UseHlsPlayerProps) { const hlsRef = useRef(null); - const { adFilterMode, adKeywords } = usePlayerSettings(isPremium); + const { adFilterMode, adKeywords, customAdFilterCode } = usePlayerSettings(isPremium); const { mediaProxyEnabled } = useRuntimeFeatures(); const isAdFilterEnabled = adFilterMode !== 'off'; @@ -59,7 +59,7 @@ export function useHlsPlayer({ if (typeof response.data === 'string') { try { // Filter the content - response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords); + response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords, customAdFilterCode); } catch (e) { console.warn('[HLS] Ad filter error:', e); } @@ -255,7 +255,7 @@ export function useHlsPlayer({ // If it's a simple playlist (no variants), just filter and play if (!masterContent.includes('#EXT-X-STREAM-INF')) { - const filtered = filterM3u8Ad(masterContent, absoluteMasterSrc, adFilterMode, adKeywords); + const filtered = filterM3u8Ad(masterContent, absoluteMasterSrc, adFilterMode, adKeywords, customAdFilterCode); const blob = new Blob([filtered], { type: 'application/vnd.apple.mpegurl' }); const blobUrl = URL.createObjectURL(blob); createdBlobs.push(blobUrl); @@ -283,7 +283,7 @@ export function useHlsPlayer({ try { const absoluteUrl = isRelative ? new URL(uri, absoluteMasterSrc).toString() : uri; const subContent = await fetchWithFallback(absoluteUrl); - const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords); + const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords, customAdFilterCode); const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' }); const subBlobUrl = URL.createObjectURL(subBlob); createdBlobs.push(subBlobUrl); @@ -306,7 +306,7 @@ export function useHlsPlayer({ try { const absoluteUrl = isRelative ? new URL(trimmedLine, absoluteMasterSrc).toString() : trimmedLine; const subContent = await fetchWithFallback(absoluteUrl); - const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords); + const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords, customAdFilterCode); const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' }); const subBlobUrl = URL.createObjectURL(subBlob); createdBlobs.push(subBlobUrl); @@ -424,5 +424,5 @@ export function useHlsPlayer({ } extraBlobs.forEach(url => URL.revokeObjectURL(url)); }; - }, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]); + }, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, customAdFilterCode, mediaProxyEnabled]); } diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts index 2834462..9f87bef 100644 --- a/components/player/hooks/usePlayerSettings.ts +++ b/components/player/hooks/usePlayerSettings.ts @@ -22,6 +22,8 @@ interface PlayerSettingsSnapshot { adFilter: boolean; adFilterMode: AdFilterMode; adKeywords: string[]; + customAdFilterCode: string; + customAdFilterVersion: number; fullscreenType: 'auto' | 'native' | 'window'; proxyMode: 'retry' | 'none' | 'always'; danmakuEnabled: boolean; @@ -45,6 +47,8 @@ function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolea adFilter: globalSettings.adFilter, adFilterMode: modeSettings.adFilterMode, adKeywords: globalSettings.adKeywords, + customAdFilterCode: globalSettings.customAdFilterCode || '', + customAdFilterVersion: globalSettings.customAdFilterVersion || 0, fullscreenType: modeSettings.fullscreenType, proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none', danmakuEnabled: modeSettings.danmakuEnabled, @@ -66,6 +70,8 @@ function playerSettingsEqual(a: PlayerSettingsSnapshot, b: PlayerSettingsSnapsho a.adFilter === b.adFilter && a.adFilterMode === b.adFilterMode && a.adKeywords === b.adKeywords && + a.customAdFilterCode === b.customAdFilterCode && + a.customAdFilterVersion === b.customAdFilterVersion && a.fullscreenType === b.fullscreenType && a.proxyMode === b.proxyMode && a.danmakuEnabled === b.danmakuEnabled && @@ -165,6 +171,13 @@ export function usePlayerSettings(isPremium: boolean = false) { updateGlobalSettings({ adKeywords: value }); }, [updateGlobalSettings]); + const setCustomAdFilterCode = useCallback((code: string, version?: number) => { + updateGlobalSettings({ + customAdFilterCode: code, + ...(version !== undefined ? { customAdFilterVersion: version } : {}) + }); + }, [updateGlobalSettings]); + const setFullscreenType = useCallback((value: 'auto' | 'native' | 'window') => { updateModeSettings({ fullscreenType: value }); }, [updateModeSettings]); @@ -204,6 +217,7 @@ export function usePlayerSettings(isPremium: boolean = false) { setAdFilter, setAdFilterMode, setAdKeywords, + setCustomAdFilterCode, setFullscreenType, setProxyMode, setDanmakuEnabled, diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index b89e2b5..5ecca5a 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -53,6 +53,8 @@ export interface AppSettings { adFilter: boolean; // Filter ad tags from m3u8 (legacy, kept for compatibility) adFilterMode: AdFilterMode; // 'off' | 'keyword' | 'heuristic' | 'aggressive' adKeywords: string[]; // Dynamically loaded ad keywords + customAdFilterCode?: string; // Custom dynamic ad filter JS/TS script + customAdFilterVersion?: number; // Custom dynamic ad filter version // Search & Display settings realtimeLatency: boolean; // Enable real-time latency ping updates searchDisplayMode: SearchDisplayMode; // 'normal' = individual cards, 'grouped' = group same-name videos @@ -241,6 +243,8 @@ export const settingsStore = { adFilter: parsed.adFilter !== undefined ? parsed.adFilter : false, adFilterMode: parsed.adFilterMode || 'heuristic', adKeywords: Array.isArray(parsed.adKeywords) ? parsed.adKeywords : [], + customAdFilterCode: typeof parsed.customAdFilterCode === 'string' ? parsed.customAdFilterCode : '', + customAdFilterVersion: typeof parsed.customAdFilterVersion === 'number' ? parsed.customAdFilterVersion : 0, realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false, searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal', episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false, diff --git a/lib/utils/m3u8-ad-detector.ts b/lib/utils/m3u8-ad-detector.ts index 0cdf293..6c3bdea 100644 --- a/lib/utils/m3u8-ad-detector.ts +++ b/lib/utils/m3u8-ad-detector.ts @@ -38,6 +38,8 @@ interface MainPattern { avgDuration: number; commonPrefix: string; pathPrefix: string; // Directory path prefix (e.g., "/20230907/73PWifvT/1392kb/hls/") + isGlobalNTSC: boolean; // Whether >30% of total playlist segments use 30fps NTSC frame fractions + isGlobal24fps: boolean; // Whether >35% of total playlist segments use 24fps/23.976fps frame fractions (.004, .002, .008) } /** @@ -181,7 +183,7 @@ export function learnMainPattern(blocks: Block[]): MainPattern { ) : null; if (!mainBlock || mainBlock.segments.length === 0) { - return { filenameRegex: null, avgDuration: 0, commonPrefix: '', pathPrefix: '' }; + return { filenameRegex: null, avgDuration: 0, commonPrefix: '', pathPrefix: '', isGlobalNTSC: false, isGlobal24fps: false }; } // Extract filenames @@ -208,7 +210,26 @@ export function learnMainPattern(blocks: Block[]): MainPattern { const firstUrl = mainBlock.segments[0].url; const pathPrefix = extractPathPrefix(firstUrl); - return { filenameRegex, avgDuration, commonPrefix, pathPrefix }; + // Calculate global NTSC 30fps and 24fps/23.976fps fraction ratios across all blocks in the playlist + const ntscFractions = new Set([33, 67, 133, 167, 233, 267, 333, 367, 433, 467, 533, 567, 633, 667, 733, 767, 833, 867, 933, 967]); + const fps24Fractions = new Set([2, 4, 6, 8, 12, 16, 20, 24]); + let totalSegCount = 0; + let ntscSegCount = 0; + let fps24SegCount = 0; + + blocks.forEach(b => { + b.segments.forEach(s => { + totalSegCount++; + const msFraction = Math.round((s.duration % 1) * 1000); + if (ntscFractions.has(msFraction)) ntscSegCount++; + if (fps24Fractions.has(msFraction)) fps24SegCount++; + }); + }); + + const isGlobalNTSC = totalSegCount > 0 && (ntscSegCount / totalSegCount) > 0.3; + const isGlobal24fps = totalSegCount > 0 && (fps24SegCount / totalSegCount) > 0.35; + + return { filenameRegex, avgDuration, commonPrefix, pathPrefix, isGlobalNTSC, isGlobal24fps }; } /** @@ -226,6 +247,7 @@ export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set // Find the largest block (assumed main content block) let mainBlockIndex = -1; let maxSegments = 0; + blocks.forEach((block, idx) => { if (block.segments.length > maxSegments) { maxSegments = block.segments.length; @@ -233,6 +255,12 @@ export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set } }); + let mainAvgDuration = 0; + if (mainBlockIndex >= 0 && blocks[mainBlockIndex].segments.length > 0) { + const mainSegs = blocks[mainBlockIndex].segments; + mainAvgDuration = mainSegs.reduce((sum, s) => sum + s.duration, 0) / mainSegs.length; + } + // Map signature -> array of block indices const signatureMap = new Map(); @@ -240,6 +268,15 @@ export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set // Require at least 3 segments to form a signature to prevent accidental single-segment collisions if (block.segments.length < 3) return; + const firstDur = block.segments[0].duration; + const isUniformBlock = block.segments.every(s => Math.abs(s.duration - firstDur) < 0.005); + + // If the block is uniform (e.g. 2.0s, 2.0s, 2.0s) AND its duration matches the main content's duration (e.g. zuida.m3u8 where main content is also 2.0s), + // it represents normal main video chunking and must NOT be flagged as an ad signature. + if (isUniformBlock && mainAvgDuration > 0 && Math.abs(firstDur - mainAvgDuration) < 0.05) { + return; + } + // Signature based on segment durations rounded to 3 decimal places (milliseconds precision) const signature = block.segments.map(s => s.duration.toFixed(3)).join(','); @@ -248,12 +285,12 @@ export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set signatureMap.set(signature, existing); }); - // Flag blocks whose signatures appear 2 or more times + // Flag blocks whose signatures appear 2 or more times (up to 30% of total blocks, at least 3) + const maxAllowedAdOccurrences = Math.max(3, blocks.length * 0.3); signatureMap.forEach((indices) => { - if (indices.length >= 2) { + if (indices.length >= 2 && indices.length <= maxAllowedAdOccurrences) { indices.forEach(idx => { - // Ensure we don't accidentally flag the main content block - if (idx !== mainBlockIndex && blocks[idx].segments.length < maxSegments * 0.8) { + if (idx !== mainBlockIndex) { duplicateIndices.add(idx); } }); @@ -322,6 +359,46 @@ export function scoreBlock( } } + // **ENHANCEMENT**: Duration anomaly heuristic for mid-roll inserted ad blocks + // If main content has a consistent segment duration (e.g., 6.0s or 10.0s), + // but this small block has a vastly different duration signature (e.g., 2.0s), + // add additional score when combined with filename or path mismatch. + if (mainPattern.avgDuration > 0 && block.segments.length > 0 && block.segments.length <= 6) { + const blockAvgDuration = block.segments.reduce((sum, s) => sum + s.duration, 0) / block.segments.length; + const durationRatio = blockAvgDuration / mainPattern.avgDuration; + if (durationRatio < 0.6 || durationRatio > 1.8) { + score += 1.5; + } + } + + // **KEY FEATURE**: Framerate fraction grid anomaly detection (30fps vs 25fps inserted ad detection) + // Spliced NTSC 30fps/60fps ads inserted into 25fps/50fps streams create distinct fractional ms (.333, .667, .867, .133, .733). + // Note: Only triggered if the main content itself is NOT a global NTSC 30fps stream. + if (!mainPattern.isGlobalNTSC && block.segments.length > 0 && block.segments.length <= 10) { + const ntscFractions = new Set([33, 67, 133, 167, 233, 267, 333, 367, 433, 467, 533, 567, 633, 667, 733, 767, 833, 867, 933, 967]); + let ntscMatches = 0; + block.segments.forEach(s => { + const msFraction = Math.round((s.duration % 1) * 1000); + if (ntscFractions.has(msFraction)) ntscMatches++; + }); + if (ntscMatches === block.segments.length) { + score += 5.0; // Definite framerate anomaly for inserted ad block + } + } + + // **KEY FEATURE**: Framerate fraction grid anomaly detection (25fps/integer ads inserted into 24fps/23.976fps streams) + // 24fps movie streams have fractional ms (.004, .002, .008). 25fps/PAL inserted ads use exact integer milliseconds (.000). + if (mainPattern.isGlobal24fps && block.segments.length > 0 && block.segments.length <= 10) { + let int0Count = 0; + block.segments.forEach(s => { + const msFraction = Math.round((s.duration % 1) * 1000); + if (msFraction === 0) int0Count++; + }); + if (int0Count >= block.segments.length - 1 && int0Count >= 2) { + score += 5.0; // Definite 25fps integer ad inserted into 24fps movie stream + } + } + return score; } diff --git a/lib/utils/m3u8-utils.ts b/lib/utils/m3u8-utils.ts index 924f95c..33fa511 100644 --- a/lib/utils/m3u8-utils.ts +++ b/lib/utils/m3u8-utils.ts @@ -81,10 +81,57 @@ function isAuxiliaryAdMetadataLine(trimmedLine: string, normalizedKeywords: stri * @param baseUrl The base URL of the M3U8 file (to resolve relative paths) * @returns The filtered M3U8 content */ +/** + * Strips TypeScript type annotations from dynamic ad filter JavaScript/TypeScript code + */ +export function removeTypeAnnotations(code: string): string { + return code + .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 ='); +} + +/** + * Safely executes dynamic custom ad filter code using Function constructor sandbox. + * Returns filtered content string or null if execution fails/throws. + */ +export function executeCustomAdFilter(customCode: string, content: string, baseUrl: string): string | null { + if (!customCode || !customCode.trim()) return null; + try { + const jsCode = removeTypeAnnotations(customCode); + const customFunction = new Function( + 'content', + 'baseUrl', + jsCode + '\nif (typeof filterAdsFromM3U8 === "function") { return filterAdsFromM3U8(content, baseUrl); } else if (typeof filterAds === "function") { return filterAds(content, baseUrl); } return content;' + ); + const result = customFunction(content, baseUrl); + return typeof result === 'string' ? result : null; + } catch (err) { + console.warn('[AdFilter] Custom script execution failed, falling back to built-in rules:', err); + return null; + } +} + export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive'; -export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMode = 'heuristic', customKeywords: string[] = []): string { +export function filterM3u8Ad( + content: string, + baseUrl: string, + mode: AdFilterMode = 'heuristic', + customKeywords: string[] = [], + customCode?: string +): string { if (!content) return ''; + if (mode === 'off') return content; + + // 1. Try executing custom dynamic filter script first (if provided) + if (customCode && customCode.trim()) { + const customResult = executeCustomAdFilter(customCode, content, baseUrl); + if (customResult !== null) { + return customResult; + } + // If custom script execution fails/throws, gracefully fall through to built-in heuristics + } // Use keywords passed from AdKeywordsWrapper (already loaded from env/file) const normalizedKeywords = normalizeKeywords(customKeywords); @@ -107,8 +154,8 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod } catch (e) { /* ignore */ } // 2. Global Scan: Check if any ad keywords exist in the content - const hasKeywordMatchInPlaylist = mode !== 'off' && hasKeywordMatch(content, normalizedKeywords); - const hasCueTag = mode !== 'off' && (content.includes('#EXT-X-CUE-OUT') || content.includes('#EXT-X-CUE-IN')); + const hasKeywordMatchInPlaylist = hasKeywordMatch(content, normalizedKeywords); + const hasCueTag = content.includes('#EXT-X-CUE-OUT') || content.includes('#EXT-X-CUE-IN'); // 3. Heuristic Analysis: If no explicit ad signals, use block-based detection const lines = content.split(/\r?\n/); @@ -174,16 +221,15 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod // 4. Strip modern HLS interstitial metadata before the player can schedule it. if ( - mode !== 'off' && - (isInterstitialDateRange(trimmedLine, normalizedKeywords) || - isAuxiliaryAdMetadataLine(trimmedLine, normalizedKeywords)) + isInterstitialDateRange(trimmedLine, normalizedKeywords) || + isAuxiliaryAdMetadataLine(trimmedLine, normalizedKeywords) ) { continue; } // 5. CUE Tag Detection (SCTE-35 Standard) // EXT-X-CUE-OUT marks start of ad, EXT-X-CUE-IN marks end - if (mode !== 'off' && trimmedLine.startsWith('#EXT-X-CUE-OUT')) { + if (trimmedLine.startsWith('#EXT-X-CUE-OUT')) { insideCueAdBlock = true; // Remove preceding DISCONTINUITY if present if (processedLines.length > 0 && processedLines[processedLines.length - 1].trim() === '#EXT-X-DISCONTINUITY') { diff --git a/tests/m3u8-ad-detector.test.ts b/tests/m3u8-ad-detector.test.ts index 4d587b6..c6d304a 100644 --- a/tests/m3u8-ad-detector.test.ts +++ b/tests/m3u8-ad-detector.test.ts @@ -80,3 +80,167 @@ test('filterM3u8Ad still strips interstitial DATERANGE metadata', () => { assert.equal(filtered.includes('com.apple.hls.interstitial'), false); assert.equal(filtered.includes('seg-0.ts'), true); }); + +test('filterM3u8Ad executes valid custom TS/JS script in sandbox', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:5.000,', + 'https://cdn.example.com/content/ad-segment.ts', + '#EXTINF:10.000,', + 'https://cdn.example.com/content/main-segment.ts', + ].join('\n'); + + const customScript = ` + function filterAdsFromM3U8(content: string, baseUrl: string): string { + return content.split('\\n').filter(line => !line.includes('ad-segment.ts')).join('\\n'); + } + `; + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic', [], customScript); + assert.equal(filtered.includes('ad-segment.ts'), false); + assert.equal(filtered.includes('main-segment.ts'), true); +}); + +test('filterM3u8Ad gracefully falls back to built-in rules when custom script throws runtime error', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:5.000,', + 'https://cdn.example.com/content/sponsor-ad.ts', + '#EXTINF:10.000,', + 'https://cdn.example.com/content/main-segment.ts', + ].join('\n'); + + const brokenScript = ` + function filterAdsFromM3U8(content: string): string { + throw new Error("Syntax error or intentional bug"); + } + `; + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic', ['sponsor'], brokenScript); + assert.equal(filtered.includes('sponsor-ad.ts'), false); + assert.equal(filtered.includes('main-segment.ts'), true); +}); + +test('filterM3u8Ad detects and strips 30fps/NTSC framerate fraction anomaly inserted ad blocks', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-0.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-1.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-2.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-3.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-4.ts', + '#EXT-X-DISCONTINUITY', + '#EXTINF:4.867,', + 'https://cdn.example.com/content/ad-0.ts', + '#EXTINF:3.333,', + 'https://cdn.example.com/content/ad-1.ts', + '#EXT-X-DISCONTINUITY', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-5.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-6.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/content/main-7.ts', + ].join('\n'); + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic'); + assert.equal(filtered.includes('ad-0.ts'), false); + assert.equal(filtered.includes('ad-1.ts'), false); + assert.equal(filtered.includes('main-0.ts'), true); + assert.equal(filtered.includes('main-7.ts'), true); +}); + +test('filterM3u8Ad detects and strips Multi-CDN domain/path prefix mismatch ad blocks', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:6.000,', + 'https://media-cdn.video.com/2026/ep01/hls/seg-0.ts', + '#EXTINF:6.000,', + 'https://media-cdn.video.com/2026/ep01/hls/seg-1.ts', + '#EXTINF:6.000,', + 'https://media-cdn.video.com/2026/ep01/hls/seg-2.ts', + '#EXT-X-DISCONTINUITY', + '#EXTINF:15.000,', + 'https://ad-server.net/campaign/preroll/ad-0.ts', + '#EXTINF:15.000,', + 'https://ad-server.net/campaign/preroll/ad-1.ts', + '#EXT-X-DISCONTINUITY', + '#EXTINF:6.000,', + 'https://media-cdn.video.com/2026/ep01/hls/seg-3.ts', + '#EXTINF:6.000,', + 'https://media-cdn.video.com/2026/ep01/hls/seg-4.ts', + ].join('\n'); + + const filtered = filterM3u8Ad(playlist, 'https://media-cdn.video.com/2026/ep01/hls/index.m3u8', 'heuristic'); + assert.equal(filtered.includes('ad-server.net'), false); + assert.equal(filtered.includes('seg-0.ts'), true); + assert.equal(filtered.includes('seg-4.ts'), true); +}); + +test('filterM3u8Ad protects global 30fps/NTSC feature film streams with 0 false positives', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:4.033,', + 'https://cdn.example.com/movie/seg-0.ts', + '#EXTINF:4.867,', + 'https://cdn.example.com/movie/seg-1.ts', + '#EXTINF:3.333,', + 'https://cdn.example.com/movie/seg-2.ts', + '#EXTINF:4.167,', + 'https://cdn.example.com/movie/seg-3.ts', + '#EXT-X-DISCONTINUITY', + '#EXTINF:4.033,', + 'https://cdn.example.com/movie/seg-4.ts', + '#EXTINF:4.867,', + 'https://cdn.example.com/movie/seg-5.ts', + '#EXTINF:3.333,', + 'https://cdn.example.com/movie/seg-6.ts', + ].join('\n'); + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/movie/index.m3u8', 'heuristic'); + assert.equal(filtered.includes('seg-0.ts'), true); + assert.equal(filtered.includes('seg-3.ts'), true); + assert.equal(filtered.includes('seg-6.ts'), true); +}); + +test('filterM3u8Ad detects and strips 25fps integer duration inserted ad blocks in 24fps movie streams', () => { + const playlist = [ + '#EXTM3U', + '#EXTINF:4.004,', + 'https://cdn.example.com/movie/seg-0.ts', + '#EXTINF:4.004,', + 'https://cdn.example.com/movie/seg-1.ts', + '#EXTINF:4.004,', + 'https://cdn.example.com/movie/seg-2.ts', + '#EXTINF:4.004,', + 'https://cdn.example.com/movie/seg-3.ts', + '#EXT-X-DISCONTINUITY', + '#EXTINF:4.000,', + 'https://cdn.example.com/movie/ad-0.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/movie/ad-1.ts', + '#EXTINF:4.000,', + 'https://cdn.example.com/movie/ad-2.ts', + '#EXTINF:0.560,', + 'https://cdn.example.com/movie/ad-3.ts', + '#EXT-X-DISCONTINUITY', + '#EXTINF:4.004,', + 'https://cdn.example.com/movie/seg-4.ts', + '#EXTINF:4.004,', + 'https://cdn.example.com/movie/seg-5.ts', + ].join('\n'); + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/movie/index.m3u8', 'heuristic'); + assert.equal(filtered.includes('ad-0.ts'), false); + assert.equal(filtered.includes('ad-3.ts'), false); + assert.equal(filtered.includes('seg-0.ts'), true); + assert.equal(filtered.includes('seg-5.ts'), true); +}); + + + From cdab3b1c8ac21f720f41d8495f7579fe195e2364 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Wed, 29 Jul 2026 22:13:54 +0800 Subject: [PATCH 2/2] fix: harden M3U8 ad filtering Reject unsafe dynamic filter execution, reduce heuristic false positives, add regression coverage, bump the app to 4.9.17, and update Next.js to the latest stable security patch. --- CHANGELOG.md | 9 + app-release.json | 15 +- app/api/ad-filter/route.ts | 40 -- components/player/hooks/useHlsPlayer.ts | 12 +- components/player/hooks/usePlayerSettings.ts | 14 - lib/store/settings-store.ts | 4 - lib/utils/m3u8-ad-detector.ts | 407 ++----------------- lib/utils/m3u8-ad-pattern.ts | 100 +++++ lib/utils/m3u8-ad-scoring.ts | 100 +++++ lib/utils/m3u8-ad-signatures.ts | 54 +++ lib/utils/m3u8-ad-types.ts | 27 ++ lib/utils/m3u8-duration-grid.ts | 53 +++ lib/utils/m3u8-utils.ts | 49 +-- package-lock.json | 192 ++++----- package.json | 6 +- tests/m3u8-ad-detector.test.ts | 164 -------- tests/m3u8-duration-grid.test.ts | 67 +++ tests/m3u8-filter-regression.test.ts | 61 +++ 18 files changed, 620 insertions(+), 754 deletions(-) delete mode 100644 app/api/ad-filter/route.ts create mode 100644 lib/utils/m3u8-ad-pattern.ts create mode 100644 lib/utils/m3u8-ad-scoring.ts create mode 100644 lib/utils/m3u8-ad-signatures.ts create mode 100644 lib/utils/m3u8-ad-types.ts create mode 100644 lib/utils/m3u8-duration-grid.ts create mode 100644 tests/m3u8-duration-grid.test.ts create mode 100644 tests/m3u8-filter-regression.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f0b12..8cdc954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 4.9.17 - 2026-07-29 + +- 新增基于主内容时长残差的组合式 M3U8 插播检测;残差只作为辅助证据,单个 `.033/.333/.867` 等合法分片不再被直接删除。 +- 重复时长指纹恢复大内容块保护并限制出现比例,避免固定 GOP、等长章节或重复正片结构被整块误删。 +- 跨源同路径检测同时比较来源域名与文件名模式,在增强 Multi-CDN 插播识别的同时保留合法 CDN 切换的保护条件。 +- 安全审查后未启用基于 `new Function` 的动态 JS/TS 执行和公开脚本 API;该实现不是沙盒,且无法防止任意代码执行或主线程死循环。 +- 广告检测器拆分为解析、主模式、重复指纹、时长网格与评分模块,并补充误删回归测试。 +- Next.js 与配套 ESLint 配置升级至最新稳定版 16.2.12,消除 16.2.10 的多项框架级高风险公告;上游固定的 PostCSS/sharp 审计项仍等待官方稳定修复。 + ## 4.9.16 - 2026-07-27 - 修复窗口宽度变化后右侧“历史播放记录”浮动按钮停留在原位置、无法继续贴靠右边缘的问题。 diff --git a/app-release.json b/app-release.json index a35f19b..19e40b1 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,21 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.16", + "currentVersion": "4.9.17", "releases": [ + { + "version": "4.9.17", + "publishedAt": "2026-07-29", + "title": "加固 M3U8 广告检测", + "notes": [ + "新增基于主内容时长残差的组合式检测,识别跨帧率小型插播,同时禁止把单个异常时长分片直接判为广告。", + "重复时长指纹恢复大内容块保护并限制出现比例,避免固定 GOP 或等长章节被整块误删。", + "跨源同路径检测同时比较来源域名与文件名模式,并保留合法 CDN 切换所需的组合证据。", + "安全审查后未启用动态 JS/TS 执行与公开脚本 API,避免任意代码执行、主线程死循环和无效配置链路。", + "广告检测器按解析、主模式、指纹与评分拆分,并新增误删回归测试。", + "Next.js 与配套 ESLint 配置升级至最新稳定版 16.2.12,消除 16.2.10 的多项框架级高风险公告;上游固定的 PostCSS/sharp 审计项仍等待官方稳定修复。" + ] + }, { "version": "4.9.16", "publishedAt": "2026-07-27", diff --git a/app/api/ad-filter/route.ts b/app/api/ad-filter/route.ts deleted file mode 100644 index 7b7a9e9..0000000 --- a/app/api/ad-filter/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextResponse } from 'next/server'; - -export const runtime = 'edge'; -export const dynamic = 'force-dynamic'; - -/** - * GET /api/ad-filter - * Public endpoint to fetch custom ad filter code and versioning. - * Query params: - * - ?full=true : returns { version, code } - * - default : returns { version } - */ -export async function GET(request: Request) { - try { - const { searchParams } = new URL(request.url); - const full = searchParams.get('full') === 'true'; - - const code = process.env.CUSTOM_AD_FILTER_CODE || ''; - const version = process.env.CUSTOM_AD_FILTER_VERSION - ? parseInt(process.env.CUSTOM_AD_FILTER_VERSION, 10) - : (code ? 1 : 0); - - if (full) { - return NextResponse.json({ - code, - version, - }); - } - - return NextResponse.json({ - version, - }); - } catch (error) { - console.error('[AdFilter API] Error fetching custom filter:', error); - return NextResponse.json( - { error: 'Failed to fetch ad filter config', details: (error as Error).message }, - { status: 500 } - ); - } -} diff --git a/components/player/hooks/useHlsPlayer.ts b/components/player/hooks/useHlsPlayer.ts index 426ce4d..4f4c194 100644 --- a/components/player/hooks/useHlsPlayer.ts +++ b/components/player/hooks/useHlsPlayer.ts @@ -22,7 +22,7 @@ export function useHlsPlayer({ onError }: UseHlsPlayerProps) { const hlsRef = useRef(null); - const { adFilterMode, adKeywords, customAdFilterCode } = usePlayerSettings(isPremium); + const { adFilterMode, adKeywords } = usePlayerSettings(isPremium); const { mediaProxyEnabled } = useRuntimeFeatures(); const isAdFilterEnabled = adFilterMode !== 'off'; @@ -59,7 +59,7 @@ export function useHlsPlayer({ if (typeof response.data === 'string') { try { // Filter the content - response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords, customAdFilterCode); + response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords); } catch (e) { console.warn('[HLS] Ad filter error:', e); } @@ -255,7 +255,7 @@ export function useHlsPlayer({ // If it's a simple playlist (no variants), just filter and play if (!masterContent.includes('#EXT-X-STREAM-INF')) { - const filtered = filterM3u8Ad(masterContent, absoluteMasterSrc, adFilterMode, adKeywords, customAdFilterCode); + const filtered = filterM3u8Ad(masterContent, absoluteMasterSrc, adFilterMode, adKeywords); const blob = new Blob([filtered], { type: 'application/vnd.apple.mpegurl' }); const blobUrl = URL.createObjectURL(blob); createdBlobs.push(blobUrl); @@ -283,7 +283,7 @@ export function useHlsPlayer({ try { const absoluteUrl = isRelative ? new URL(uri, absoluteMasterSrc).toString() : uri; const subContent = await fetchWithFallback(absoluteUrl); - const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords, customAdFilterCode); + const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords); const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' }); const subBlobUrl = URL.createObjectURL(subBlob); createdBlobs.push(subBlobUrl); @@ -306,7 +306,7 @@ export function useHlsPlayer({ try { const absoluteUrl = isRelative ? new URL(trimmedLine, absoluteMasterSrc).toString() : trimmedLine; const subContent = await fetchWithFallback(absoluteUrl); - const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords, customAdFilterCode); + const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords); const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' }); const subBlobUrl = URL.createObjectURL(subBlob); createdBlobs.push(subBlobUrl); @@ -424,5 +424,5 @@ export function useHlsPlayer({ } extraBlobs.forEach(url => URL.revokeObjectURL(url)); }; - }, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, customAdFilterCode, mediaProxyEnabled]); + }, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]); } diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts index 9f87bef..2834462 100644 --- a/components/player/hooks/usePlayerSettings.ts +++ b/components/player/hooks/usePlayerSettings.ts @@ -22,8 +22,6 @@ interface PlayerSettingsSnapshot { adFilter: boolean; adFilterMode: AdFilterMode; adKeywords: string[]; - customAdFilterCode: string; - customAdFilterVersion: number; fullscreenType: 'auto' | 'native' | 'window'; proxyMode: 'retry' | 'none' | 'always'; danmakuEnabled: boolean; @@ -47,8 +45,6 @@ function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolea adFilter: globalSettings.adFilter, adFilterMode: modeSettings.adFilterMode, adKeywords: globalSettings.adKeywords, - customAdFilterCode: globalSettings.customAdFilterCode || '', - customAdFilterVersion: globalSettings.customAdFilterVersion || 0, fullscreenType: modeSettings.fullscreenType, proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none', danmakuEnabled: modeSettings.danmakuEnabled, @@ -70,8 +66,6 @@ function playerSettingsEqual(a: PlayerSettingsSnapshot, b: PlayerSettingsSnapsho a.adFilter === b.adFilter && a.adFilterMode === b.adFilterMode && a.adKeywords === b.adKeywords && - a.customAdFilterCode === b.customAdFilterCode && - a.customAdFilterVersion === b.customAdFilterVersion && a.fullscreenType === b.fullscreenType && a.proxyMode === b.proxyMode && a.danmakuEnabled === b.danmakuEnabled && @@ -171,13 +165,6 @@ export function usePlayerSettings(isPremium: boolean = false) { updateGlobalSettings({ adKeywords: value }); }, [updateGlobalSettings]); - const setCustomAdFilterCode = useCallback((code: string, version?: number) => { - updateGlobalSettings({ - customAdFilterCode: code, - ...(version !== undefined ? { customAdFilterVersion: version } : {}) - }); - }, [updateGlobalSettings]); - const setFullscreenType = useCallback((value: 'auto' | 'native' | 'window') => { updateModeSettings({ fullscreenType: value }); }, [updateModeSettings]); @@ -217,7 +204,6 @@ export function usePlayerSettings(isPremium: boolean = false) { setAdFilter, setAdFilterMode, setAdKeywords, - setCustomAdFilterCode, setFullscreenType, setProxyMode, setDanmakuEnabled, diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index 5ecca5a..b89e2b5 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -53,8 +53,6 @@ export interface AppSettings { adFilter: boolean; // Filter ad tags from m3u8 (legacy, kept for compatibility) adFilterMode: AdFilterMode; // 'off' | 'keyword' | 'heuristic' | 'aggressive' adKeywords: string[]; // Dynamically loaded ad keywords - customAdFilterCode?: string; // Custom dynamic ad filter JS/TS script - customAdFilterVersion?: number; // Custom dynamic ad filter version // Search & Display settings realtimeLatency: boolean; // Enable real-time latency ping updates searchDisplayMode: SearchDisplayMode; // 'normal' = individual cards, 'grouped' = group same-name videos @@ -243,8 +241,6 @@ export const settingsStore = { adFilter: parsed.adFilter !== undefined ? parsed.adFilter : false, adFilterMode: parsed.adFilterMode || 'heuristic', adKeywords: Array.isArray(parsed.adKeywords) ? parsed.adKeywords : [], - customAdFilterCode: typeof parsed.customAdFilterCode === 'string' ? parsed.customAdFilterCode : '', - customAdFilterVersion: typeof parsed.customAdFilterVersion === 'number' ? parsed.customAdFilterVersion : 0, realtimeLatency: parsed.realtimeLatency !== undefined ? parsed.realtimeLatency : false, searchDisplayMode: parsed.searchDisplayMode === 'grouped' ? 'grouped' : 'normal', episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false, diff --git a/lib/utils/m3u8-ad-detector.ts b/lib/utils/m3u8-ad-detector.ts index 6c3bdea..f11e353 100644 --- a/lib/utils/m3u8-ad-detector.ts +++ b/lib/utils/m3u8-ad-detector.ts @@ -1,102 +1,59 @@ -/** - * Heuristic Ad Detection Module - * - * Provides block-based analysis for detecting ads in M3U8 playlists - * using filename pattern matching and other heuristics. - */ +import type { Block } from './m3u8-ad-types'; -// Ad-related path keywords for scoring -export const AD_PATH_KEYWORDS = [ - 'advert', 'preroll', 'midroll', 'postroll', - 'dai', 'vast', 'ima', 'adjump', 'commercial', 'sponsor' -]; +export type { Block, MainPattern, Segment } from './m3u8-ad-types'; +export { learnMainPattern } from './m3u8-ad-pattern'; +export { findDuplicateSignatureBlockIndices } from './m3u8-ad-signatures'; +export { + AD_PATH_KEYWORDS, + scoreBlock, + shouldFilterBlock, + THRESHOLDS, +} from './m3u8-ad-scoring'; -/** - * Represents a segment in the playlist - */ -interface Segment { - url: string; - duration: number; - lineIndex: number; -} - -/** - * Represents a block of segments between DISCONTINUITY markers - */ -interface Block { - segments: Segment[]; - startLineIndex: number; - endLineIndex: number; - hasCueTag: boolean; -} - -/** - * Pattern extracted from main content for comparison - */ -interface MainPattern { - filenameRegex: RegExp | null; - avgDuration: number; - commonPrefix: string; - pathPrefix: string; // Directory path prefix (e.g., "/20230907/73PWifvT/1392kb/hls/") - isGlobalNTSC: boolean; // Whether >30% of total playlist segments use 30fps NTSC frame fractions - isGlobal24fps: boolean; // Whether >35% of total playlist segments use 24fps/23.976fps frame fractions (.004, .002, .008) -} - -/** - * Parse M3U8 content into blocks separated by DISCONTINUITY markers - */ export function parseBlocks(lines: string[]): Block[] { const blocks: Block[] = []; let currentBlock: Block = { segments: [], startLineIndex: 0, endLineIndex: 0, - hasCueTag: false + hasCueTag: false, }; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); - // Check for CUE tags if (line.startsWith('#EXT-X-CUE-OUT') || line.startsWith('#EXT-X-CUE-IN')) { currentBlock.hasCueTag = true; } - // DISCONTINUITY marks block boundary if (line === '#EXT-X-DISCONTINUITY') { if (currentBlock.segments.length > 0) { - currentBlock.endLineIndex = i - 1; + currentBlock.endLineIndex = index - 1; blocks.push(currentBlock); } currentBlock = { segments: [], - startLineIndex: i + 1, + startLineIndex: index + 1, endLineIndex: 0, - hasCueTag: false + hasCueTag: false, }; continue; } - // Parse EXTINF and the following URL - if (line.startsWith('#EXTINF:')) { - const durationMatch = line.match(/#EXTINF:([\d.]+)/); - const duration = durationMatch ? parseFloat(durationMatch[1]) : 0; + if (!line.startsWith('#EXTINF:')) continue; - // Next line should be the URL - if (i + 1 < lines.length) { - const url = lines[i + 1].trim(); - if (url && !url.startsWith('#')) { - currentBlock.segments.push({ - url, - duration, - lineIndex: i + 1 - }); - } - } + const durationMatch = line.match(/#EXTINF:([\d.]+)/); + const duration = durationMatch ? Number.parseFloat(durationMatch[1]) : 0; + const url = lines[index + 1]?.trim(); + if (url && !url.startsWith('#')) { + currentBlock.segments.push({ + url, + duration, + lineIndex: index + 1, + }); } } - // Don't forget the last block if (currentBlock.segments.length > 0) { currentBlock.endLineIndex = lines.length - 1; blocks.push(currentBlock); @@ -104,315 +61,3 @@ export function parseBlocks(lines: string[]): Block[] { return blocks; } - -/** - * Unwrap proxied URL to get original URL - */ -function unwrapProxyUrl(url: string): string { - if (url.includes('/api/proxy?url=')) { - try { - const match = url.match(/[?&]url=([^&]+)/); - if (match && match[1]) { - return decodeURIComponent(match[1]); - } - } catch { - return url; - } - } - return url; -} - -/** - * Extract filename from URL (handles both relative and absolute URLs) - */ -function extractFilename(url: string): string { - try { - const unwrappedUrl = unwrapProxyUrl(url); - const path = unwrappedUrl.includes('://') ? new URL(unwrappedUrl).pathname : unwrappedUrl; - const parts = path.split('/'); - return parts[parts.length - 1] || ''; - } catch { - return url.split('/').pop() || ''; - } -} - -/** - * Find common prefix among an array of strings - */ -function findCommonPrefix(strings: string[]): string { - if (!strings || strings.length < 2) return ''; - - let prefix = ''; - const first = strings[0]; - - for (let i = 0; i < first.length; i++) { - const char = first[i]; - if (strings.every(s => s[i] === char)) { - prefix += char; - } else { - break; - } - } - - return prefix; -} - -/** - * Extract path prefix (directory) from URL - * e.g., "/20230907/73PWifvT/1392kb/hls/" from "/20230907/73PWifvT/1392kb/hls/gFE6lwIk.ts" - */ -function extractPathPrefix(url: string): string { - try { - const unwrappedUrl = unwrapProxyUrl(url); - const path = unwrappedUrl.includes('://') ? new URL(unwrappedUrl).pathname : unwrappedUrl; - const lastSlash = path.lastIndexOf('/'); - return lastSlash >= 0 ? path.substring(0, lastSlash + 1) : ''; - } catch { - const lastSlash = url.lastIndexOf('/'); - return lastSlash >= 0 ? url.substring(0, lastSlash + 1) : ''; - } -} - -/** - * Learn pattern from the largest block (assumed to be main content) - */ -export function learnMainPattern(blocks: Block[]): MainPattern { - // Find the largest block by segment count (likely main content) - const mainBlock = blocks.length > 0 ? blocks.reduce((largest, block) => - block.segments.length > largest.segments.length ? block : largest - ) : null; - - if (!mainBlock || mainBlock.segments.length === 0) { - return { filenameRegex: null, avgDuration: 0, commonPrefix: '', pathPrefix: '', isGlobalNTSC: false, isGlobal24fps: false }; - } - - // Extract filenames - const filenames = mainBlock.segments.map(s => extractFilename(s.url)); - - // Find common prefix - const commonPrefix = findCommonPrefix(filenames); - - // Calculate average duration - const totalDuration = mainBlock.segments.reduce((sum, s) => sum + s.duration, 0); - const avgDuration = totalDuration / mainBlock.segments.length; - - // Try to build a regex pattern from the filenames - // Common patterns: "0000001.ts", "seg-1.ts", "segment_001.ts" - let filenameRegex: RegExp | null = null; - if (commonPrefix.length >= 2) { - // Escape special regex characters in prefix - const escapedPrefix = commonPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - filenameRegex = new RegExp(`^${escapedPrefix}`); - } - - // Extract path prefix (directory path without filename) - // e.g., "/20230907/73PWifvT/1392kb/hls/" from "/20230907/73PWifvT/1392kb/hls/gFE6lwIk.ts" - const firstUrl = mainBlock.segments[0].url; - const pathPrefix = extractPathPrefix(firstUrl); - - // Calculate global NTSC 30fps and 24fps/23.976fps fraction ratios across all blocks in the playlist - const ntscFractions = new Set([33, 67, 133, 167, 233, 267, 333, 367, 433, 467, 533, 567, 633, 667, 733, 767, 833, 867, 933, 967]); - const fps24Fractions = new Set([2, 4, 6, 8, 12, 16, 20, 24]); - let totalSegCount = 0; - let ntscSegCount = 0; - let fps24SegCount = 0; - - blocks.forEach(b => { - b.segments.forEach(s => { - totalSegCount++; - const msFraction = Math.round((s.duration % 1) * 1000); - if (ntscFractions.has(msFraction)) ntscSegCount++; - if (fps24Fractions.has(msFraction)) fps24SegCount++; - }); - }); - - const isGlobalNTSC = totalSegCount > 0 && (ntscSegCount / totalSegCount) > 0.3; - const isGlobal24fps = totalSegCount > 0 && (fps24SegCount / totalSegCount) > 0.35; - - return { filenameRegex, avgDuration, commonPrefix, pathPrefix, isGlobalNTSC, isGlobal24fps }; -} - -/** - * Find blocks that share an identical sequence of segment durations (fingerprint) - * with another block in the playlist. - * - * If a block (with >= 3 segments) has an identical duration signature - * as another block in the playlist, and it is not the main content block, - * it is extremely likely to be a repeated inserted ad block. - */ -export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set { - const duplicateIndices = new Set(); - if (!blocks || blocks.length < 2) return duplicateIndices; - - // Find the largest block (assumed main content block) - let mainBlockIndex = -1; - let maxSegments = 0; - - blocks.forEach((block, idx) => { - if (block.segments.length > maxSegments) { - maxSegments = block.segments.length; - mainBlockIndex = idx; - } - }); - - let mainAvgDuration = 0; - if (mainBlockIndex >= 0 && blocks[mainBlockIndex].segments.length > 0) { - const mainSegs = blocks[mainBlockIndex].segments; - mainAvgDuration = mainSegs.reduce((sum, s) => sum + s.duration, 0) / mainSegs.length; - } - - // Map signature -> array of block indices - const signatureMap = new Map(); - - blocks.forEach((block, idx) => { - // Require at least 3 segments to form a signature to prevent accidental single-segment collisions - if (block.segments.length < 3) return; - - const firstDur = block.segments[0].duration; - const isUniformBlock = block.segments.every(s => Math.abs(s.duration - firstDur) < 0.005); - - // If the block is uniform (e.g. 2.0s, 2.0s, 2.0s) AND its duration matches the main content's duration (e.g. zuida.m3u8 where main content is also 2.0s), - // it represents normal main video chunking and must NOT be flagged as an ad signature. - if (isUniformBlock && mainAvgDuration > 0 && Math.abs(firstDur - mainAvgDuration) < 0.05) { - return; - } - - // Signature based on segment durations rounded to 3 decimal places (milliseconds precision) - const signature = block.segments.map(s => s.duration.toFixed(3)).join(','); - - const existing = signatureMap.get(signature) || []; - existing.push(idx); - signatureMap.set(signature, existing); - }); - - // Flag blocks whose signatures appear 2 or more times (up to 30% of total blocks, at least 3) - const maxAllowedAdOccurrences = Math.max(3, blocks.length * 0.3); - signatureMap.forEach((indices) => { - if (indices.length >= 2 && indices.length <= maxAllowedAdOccurrences) { - indices.forEach(idx => { - if (idx !== mainBlockIndex) { - duplicateIndices.add(idx); - } - }); - } - }); - - return duplicateIndices; -} - -/** - * Score a block for ad likelihood based on heuristics - * Returns a score where higher = more likely to be an ad - */ -export function scoreBlock( - block: Block, - mainPattern: MainPattern, - extraKeywords: string[] = [], - isDuplicateSignature: boolean = false -): number { - let score = 0; - - // If block has CUE tag or matches a duplicate signature, it's definitely an ad - if (block.hasCueTag || isDuplicateSignature) { - return 10; // Max score - } - - // Check path keywords (Built-in + Custom) - // We filter out very short custom keywords to avoid false positives in scoring - const safeExtraKeywords = extraKeywords.filter(k => k.length > 2); - const allKeywords = [...AD_PATH_KEYWORDS, ...safeExtraKeywords]; - - for (const segment of block.segments) { - const urlLower = segment.url.toLowerCase(); - for (const keyword of allKeywords) { - if (urlLower.includes(keyword.toLowerCase())) { - score += 2.5; - break; // Only count once per segment - } - } - } - - // Check filename pattern mismatch - if (mainPattern.filenameRegex) { - const mismatchCount = block.segments.filter(s => { - if (!mainPattern.filenameRegex) return false; // No pattern to compare against - const filename = extractFilename(s.url); - return !mainPattern.filenameRegex.test(filename); - }).length; - - if (mismatchCount === block.segments.length && block.segments.length > 0) { - score += 1.5; // All filenames differ from main pattern - } - } - - // **KEY FEATURE**: Check path prefix mismatch (e.g., different date/folder/bitrate) - // This is the most reliable indicator for ads that come from different CDN paths - if (mainPattern.pathPrefix !== undefined && block.segments.length > 0) { - const pathMismatchCount = block.segments.filter(s => { - const segmentPathPrefix = extractPathPrefix(s.url); - return segmentPathPrefix !== mainPattern.pathPrefix; - }).length; - - if (pathMismatchCount === block.segments.length) { - // ALL segments have different path prefix - strong ad indicator - score += 5.0; - } - } - - // **ENHANCEMENT**: Duration anomaly heuristic for mid-roll inserted ad blocks - // If main content has a consistent segment duration (e.g., 6.0s or 10.0s), - // but this small block has a vastly different duration signature (e.g., 2.0s), - // add additional score when combined with filename or path mismatch. - if (mainPattern.avgDuration > 0 && block.segments.length > 0 && block.segments.length <= 6) { - const blockAvgDuration = block.segments.reduce((sum, s) => sum + s.duration, 0) / block.segments.length; - const durationRatio = blockAvgDuration / mainPattern.avgDuration; - if (durationRatio < 0.6 || durationRatio > 1.8) { - score += 1.5; - } - } - - // **KEY FEATURE**: Framerate fraction grid anomaly detection (30fps vs 25fps inserted ad detection) - // Spliced NTSC 30fps/60fps ads inserted into 25fps/50fps streams create distinct fractional ms (.333, .667, .867, .133, .733). - // Note: Only triggered if the main content itself is NOT a global NTSC 30fps stream. - if (!mainPattern.isGlobalNTSC && block.segments.length > 0 && block.segments.length <= 10) { - const ntscFractions = new Set([33, 67, 133, 167, 233, 267, 333, 367, 433, 467, 533, 567, 633, 667, 733, 767, 833, 867, 933, 967]); - let ntscMatches = 0; - block.segments.forEach(s => { - const msFraction = Math.round((s.duration % 1) * 1000); - if (ntscFractions.has(msFraction)) ntscMatches++; - }); - if (ntscMatches === block.segments.length) { - score += 5.0; // Definite framerate anomaly for inserted ad block - } - } - - // **KEY FEATURE**: Framerate fraction grid anomaly detection (25fps/integer ads inserted into 24fps/23.976fps streams) - // 24fps movie streams have fractional ms (.004, .002, .008). 25fps/PAL inserted ads use exact integer milliseconds (.000). - if (mainPattern.isGlobal24fps && block.segments.length > 0 && block.segments.length <= 10) { - let int0Count = 0; - block.segments.forEach(s => { - const msFraction = Math.round((s.duration % 1) * 1000); - if (msFraction === 0) int0Count++; - }); - if (int0Count >= block.segments.length - 1 && int0Count >= 2) { - score += 5.0; // Definite 25fps integer ad inserted into 24fps movie stream - } - } - - return score; -} - -/** - * Threshold configuration - */ -export const THRESHOLDS = { - HIGH: 5.0, // Definitely an ad - LOW: 3.0 // Possibly an ad (for future "fuzzy" mode) -}; - -/** - * Determine if a block should be filtered based on its score - */ -export function shouldFilterBlock(score: number, threshold: number = THRESHOLDS.HIGH): boolean { - return score >= threshold; -} diff --git a/lib/utils/m3u8-ad-pattern.ts b/lib/utils/m3u8-ad-pattern.ts new file mode 100644 index 0000000..435cdd7 --- /dev/null +++ b/lib/utils/m3u8-ad-pattern.ts @@ -0,0 +1,100 @@ +import { + hasDominantFilm24LikeGrid, + hasDominantNtscLikeGrid, +} from './m3u8-duration-grid'; +import type { Block, MainPattern, SegmentLocation } from './m3u8-ad-types'; + +function unwrapProxyUrl(url: string): string { + if (!url.includes('/api/proxy?url=')) return url; + + try { + const match = url.match(/[?&]url=([^&]+)/); + return match?.[1] ? decodeURIComponent(match[1]) : url; + } catch { + return url; + } +} + +export function extractFilename(url: string): string { + try { + const unwrappedUrl = unwrapProxyUrl(url); + const path = unwrappedUrl.includes('://') + ? new URL(unwrappedUrl).pathname + : unwrappedUrl; + return path.split('/').pop() || ''; + } catch { + return url.split('/').pop() || ''; + } +} + +function findCommonPrefix(strings: string[]): string { + if (strings.length < 2) return ''; + + let prefix = ''; + for (let index = 0; index < strings[0].length; index += 1) { + const character = strings[0][index]; + if (!strings.every(value => value[index] === character)) break; + prefix += character; + } + return prefix; +} + +export function extractSegmentLocation(url: string): SegmentLocation { + try { + const unwrappedUrl = unwrapProxyUrl(url); + const parsedUrl = unwrappedUrl.includes('://') ? new URL(unwrappedUrl) : null; + const path = parsedUrl?.pathname || unwrappedUrl; + const lastSlash = path.lastIndexOf('/'); + return { + origin: parsedUrl?.origin || '', + pathPrefix: lastSlash >= 0 ? path.substring(0, lastSlash + 1) : '', + }; + } catch { + const lastSlash = url.lastIndexOf('/'); + return { + origin: '', + pathPrefix: lastSlash >= 0 ? url.substring(0, lastSlash + 1) : '', + }; + } +} + +export function learnMainPattern(blocks: Block[]): MainPattern { + const mainBlock = blocks.length > 0 + ? blocks.reduce((largest, block) => ( + block.segments.length > largest.segments.length ? block : largest + )) + : null; + + if (!mainBlock || mainBlock.segments.length === 0) { + return { + filenameRegex: null, + avgDuration: 0, + commonPrefix: '', + pathPrefix: '', + origin: '', + usesNtscLikeGrid: false, + usesFilm24LikeGrid: false, + }; + } + + const filenames = mainBlock.segments.map(segment => extractFilename(segment.url)); + const commonPrefix = findCommonPrefix(filenames); + const totalDuration = mainBlock.segments.reduce((sum, segment) => ( + sum + segment.duration + ), 0); + const avgDuration = totalDuration / mainBlock.segments.length; + const escapedPrefix = commonPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const filenameRegex = commonPrefix.length >= 2 + ? new RegExp(`^${escapedPrefix}`) + : null; + const location = extractSegmentLocation(mainBlock.segments[0].url); + + return { + filenameRegex, + avgDuration, + commonPrefix, + ...location, + usesNtscLikeGrid: hasDominantNtscLikeGrid(mainBlock.segments), + usesFilm24LikeGrid: hasDominantFilm24LikeGrid(mainBlock.segments), + }; +} diff --git a/lib/utils/m3u8-ad-scoring.ts b/lib/utils/m3u8-ad-scoring.ts new file mode 100644 index 0000000..eed5021 --- /dev/null +++ b/lib/utils/m3u8-ad-scoring.ts @@ -0,0 +1,100 @@ +import { + isSmallIntegerDurationBlock, + isSmallNtscLikeBlock, +} from './m3u8-duration-grid'; +import { extractFilename, extractSegmentLocation } from './m3u8-ad-pattern'; +import type { Block, MainPattern } from './m3u8-ad-types'; + +export const AD_PATH_KEYWORDS = [ + 'advert', 'preroll', 'midroll', 'postroll', + 'dai', 'vast', 'ima', 'adjump', 'commercial', 'sponsor', +]; + +function scoreKeywords(block: Block, extraKeywords: string[]): number { + const keywords = [ + ...AD_PATH_KEYWORDS, + ...extraKeywords.filter(keyword => keyword.length > 2), + ]; + + return block.segments.reduce((score, segment) => { + const url = segment.url.toLowerCase(); + return keywords.some(keyword => url.includes(keyword.toLowerCase())) + ? score + 2.5 + : score; + }, 0); +} + +function scoreFilenameMismatch(block: Block, mainPattern: MainPattern): number { + if (!mainPattern.filenameRegex || block.segments.length === 0) return 0; + + const allMismatch = block.segments.every(segment => ( + !mainPattern.filenameRegex?.test(extractFilename(segment.url)) + )); + return allMismatch ? 1.5 : 0; +} + +function scoreLocationMismatch(block: Block, mainPattern: MainPattern): number { + if ((!mainPattern.pathPrefix && !mainPattern.origin) || block.segments.length === 0) { + return 0; + } + + const locations = block.segments.map(segment => extractSegmentLocation(segment.url)); + const allPathsMismatch = locations.every(location => ( + location.pathPrefix !== mainPattern.pathPrefix + )); + if (allPathsMismatch) return 5; + + const allOriginsMismatch = locations.every(location => ( + Boolean(mainPattern.origin && location.origin) && location.origin !== mainPattern.origin + )); + return allOriginsMismatch ? 3.5 : 0; +} + +function scoreDurationMismatch(block: Block, mainPattern: MainPattern): number { + if (mainPattern.avgDuration <= 0 || block.segments.length === 0 || block.segments.length > 6) { + return 0; + } + + const blockAverage = block.segments.reduce((sum, segment) => ( + sum + segment.duration + ), 0) / block.segments.length; + const durationRatio = blockAverage / mainPattern.avgDuration; + return durationRatio < 0.6 || durationRatio > 1.8 ? 1.5 : 0; +} + +function scoreDurationGrid(block: Block, mainPattern: MainPattern): number { + if (!mainPattern.usesNtscLikeGrid && isSmallNtscLikeBlock(block.segments)) { + return 3.5; + } + if (mainPattern.usesFilm24LikeGrid && isSmallIntegerDurationBlock(block.segments)) { + return 3.5; + } + return 0; +} + +export function scoreBlock( + block: Block, + mainPattern: MainPattern, + extraKeywords: string[] = [], + isDuplicateSignature: boolean = false, +): number { + if (block.hasCueTag || isDuplicateSignature) return 10; + + return scoreKeywords(block, extraKeywords) + + scoreFilenameMismatch(block, mainPattern) + + scoreLocationMismatch(block, mainPattern) + + scoreDurationMismatch(block, mainPattern) + + scoreDurationGrid(block, mainPattern); +} + +export const THRESHOLDS = { + HIGH: 5, + LOW: 3, +}; + +export function shouldFilterBlock( + score: number, + threshold: number = THRESHOLDS.HIGH, +): boolean { + return score >= threshold; +} diff --git a/lib/utils/m3u8-ad-signatures.ts b/lib/utils/m3u8-ad-signatures.ts new file mode 100644 index 0000000..685d9ee --- /dev/null +++ b/lib/utils/m3u8-ad-signatures.ts @@ -0,0 +1,54 @@ +import type { Block } from './m3u8-ad-types'; + +export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set { + const duplicateIndices = new Set(); + if (blocks.length < 2) return duplicateIndices; + + let mainBlockIndex = -1; + let maxSegments = 0; + blocks.forEach((block, index) => { + if (block.segments.length > maxSegments) { + maxSegments = block.segments.length; + mainBlockIndex = index; + } + }); + + const mainSegments = mainBlockIndex >= 0 ? blocks[mainBlockIndex].segments : []; + const mainAvgDuration = mainSegments.length > 0 + ? mainSegments.reduce((sum, segment) => sum + segment.duration, 0) / mainSegments.length + : 0; + const signatureMap = new Map(); + + blocks.forEach((block, index) => { + if (block.segments.length < 3) return; + + const firstDuration = block.segments[0].duration; + const isUniform = block.segments.every(segment => ( + Math.abs(segment.duration - firstDuration) < 0.005 + )); + const matchesMainDuration = mainAvgDuration > 0 && + Math.abs(firstDuration - mainAvgDuration) < 0.05; + if (isUniform && matchesMainDuration) return; + + const signature = block.segments + .map(segment => segment.duration.toFixed(3)) + .join(','); + const matchingBlocks = signatureMap.get(signature) || []; + matchingBlocks.push(index); + signatureMap.set(signature, matchingBlocks); + }); + + const maxOccurrences = Math.max(2, Math.floor(blocks.length * 0.3)); + signatureMap.forEach((indices) => { + if (indices.length < 2 || indices.length > maxOccurrences) return; + + indices.forEach((index) => { + const isClearlySmallerThanMain = blocks[index].segments.length < maxSegments * 0.8; + if (index !== mainBlockIndex && isClearlySmallerThanMain) { + duplicateIndices.add(index); + } + }); + }); + + return duplicateIndices; +} diff --git a/lib/utils/m3u8-ad-types.ts b/lib/utils/m3u8-ad-types.ts new file mode 100644 index 0000000..3f1dfbd --- /dev/null +++ b/lib/utils/m3u8-ad-types.ts @@ -0,0 +1,27 @@ +export interface Segment { + url: string; + duration: number; + lineIndex: number; +} + +export interface Block { + segments: Segment[]; + startLineIndex: number; + endLineIndex: number; + hasCueTag: boolean; +} + +export interface MainPattern { + filenameRegex: RegExp | null; + avgDuration: number; + commonPrefix: string; + pathPrefix: string; + origin: string; + usesNtscLikeGrid: boolean; + usesFilm24LikeGrid: boolean; +} + +export interface SegmentLocation { + origin: string; + pathPrefix: string; +} diff --git a/lib/utils/m3u8-duration-grid.ts b/lib/utils/m3u8-duration-grid.ts new file mode 100644 index 0000000..11253e8 --- /dev/null +++ b/lib/utils/m3u8-duration-grid.ts @@ -0,0 +1,53 @@ +interface DurationSample { + duration: number; +} + +const NTSC_LIKE_MILLISECOND_FRACTIONS = new Set([ + 33, 67, 133, 167, 233, 267, 333, 367, 433, 467, + 533, 567, 633, 667, 733, 767, 833, 867, 933, 967, +]); + +const FILM_24_LIKE_MILLISECOND_FRACTIONS = new Set([ + 2, 4, 6, 8, 12, 16, 20, 24, +]); + +function millisecondFraction(duration: number): number { + const fractionalSeconds = Math.abs(duration - Math.trunc(duration)); + return Math.round(fractionalSeconds * 1000) % 1000; +} + +function matchingRatio(samples: DurationSample[], fractions: Set): number { + if (samples.length === 0) return 0; + + const matches = samples.reduce((count, sample) => ( + fractions.has(millisecondFraction(sample.duration)) ? count + 1 : count + ), 0); + + return matches / samples.length; +} + +export function hasDominantNtscLikeGrid(samples: DurationSample[]): boolean { + return samples.length >= 4 && + matchingRatio(samples, NTSC_LIKE_MILLISECOND_FRACTIONS) > 0.3; +} + +export function hasDominantFilm24LikeGrid(samples: DurationSample[]): boolean { + return samples.length >= 4 && + matchingRatio(samples, FILM_24_LIKE_MILLISECOND_FRACTIONS) > 0.35; +} + +export function isSmallNtscLikeBlock(samples: DurationSample[]): boolean { + return samples.length >= 2 && + samples.length <= 10 && + matchingRatio(samples, NTSC_LIKE_MILLISECOND_FRACTIONS) >= 0.8; +} + +export function isSmallIntegerDurationBlock(samples: DurationSample[]): boolean { + if (samples.length < 2 || samples.length > 10) return false; + + const integerDurations = samples.reduce((count, sample) => ( + millisecondFraction(sample.duration) === 0 ? count + 1 : count + ), 0); + + return integerDurations >= 2 && integerDurations / samples.length >= 0.75; +} diff --git a/lib/utils/m3u8-utils.ts b/lib/utils/m3u8-utils.ts index 33fa511..3b46c7c 100644 --- a/lib/utils/m3u8-utils.ts +++ b/lib/utils/m3u8-utils.ts @@ -81,58 +81,17 @@ function isAuxiliaryAdMetadataLine(trimmedLine: string, normalizedKeywords: stri * @param baseUrl The base URL of the M3U8 file (to resolve relative paths) * @returns The filtered M3U8 content */ -/** - * Strips TypeScript type annotations from dynamic ad filter JavaScript/TypeScript code - */ -export function removeTypeAnnotations(code: string): string { - return code - .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 ='); -} - -/** - * Safely executes dynamic custom ad filter code using Function constructor sandbox. - * Returns filtered content string or null if execution fails/throws. - */ -export function executeCustomAdFilter(customCode: string, content: string, baseUrl: string): string | null { - if (!customCode || !customCode.trim()) return null; - try { - const jsCode = removeTypeAnnotations(customCode); - const customFunction = new Function( - 'content', - 'baseUrl', - jsCode + '\nif (typeof filterAdsFromM3U8 === "function") { return filterAdsFromM3U8(content, baseUrl); } else if (typeof filterAds === "function") { return filterAds(content, baseUrl); } return content;' - ); - const result = customFunction(content, baseUrl); - return typeof result === 'string' ? result : null; - } catch (err) { - console.warn('[AdFilter] Custom script execution failed, falling back to built-in rules:', err); - return null; - } -} - export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive'; export function filterM3u8Ad( content: string, baseUrl: string, mode: AdFilterMode = 'heuristic', - customKeywords: string[] = [], - customCode?: string + customKeywords: string[] = [] ): string { if (!content) return ''; if (mode === 'off') return content; - // 1. Try executing custom dynamic filter script first (if provided) - if (customCode && customCode.trim()) { - const customResult = executeCustomAdFilter(customCode, content, baseUrl); - if (customResult !== null) { - return customResult; - } - // If custom script execution fails/throws, gracefully fall through to built-in heuristics - } - // Use keywords passed from AdKeywordsWrapper (already loaded from env/file) const normalizedKeywords = normalizeKeywords(customKeywords); @@ -144,14 +103,14 @@ export function filterM3u8Ad( if (urlMatch && urlMatch[1]) { effectiveBaseUrl = decodeURIComponent(urlMatch[1]); } - } catch (e) { /* ignore */ } + } catch { /* ignore */ } } const basePath = effectiveBaseUrl.substring(0, effectiveBaseUrl.lastIndexOf('/') + 1); let origin = ''; try { origin = new URL(effectiveBaseUrl).origin; - } catch (e) { /* ignore */ } + } catch { /* ignore */ } // 2. Global Scan: Check if any ad keywords exist in the content const hasKeywordMatchInPlaylist = hasKeywordMatch(content, normalizedKeywords); @@ -159,7 +118,7 @@ export function filterM3u8Ad( // 3. Heuristic Analysis: If no explicit ad signals, use block-based detection const lines = content.split(/\r?\n/); - let adLineIndices = new Set(); + const adLineIndices = new Set(); if (!hasCueTag && (mode === 'heuristic' || mode === 'aggressive')) { // No obvious ad signals - run heuristic analysis diff --git a/package-lock.json b/package-lock.json index 9b5ae3f..3021cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.16", + "version": "4.9.17", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.16", + "version": "4.9.17", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -15,7 +15,7 @@ "@vercel/analytics": "^2.0.1", "hls.js": "^1.6.15", "lucide-react": "^0.577.0", - "next": "16.2.10", + "next": "16.2.12", "opencc-js": "^1.0.5", "react": "19.2.4", "react-dom": "19.2.4", @@ -29,7 +29,7 @@ "@types/react-dom": "^19", "esbuild": "^0.27.7", "eslint": "^9.25.1", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.12", "postcss": "^8.5.8", "postcss-preset-env": "^11.2.0", "tailwindcss": "^4", @@ -3278,15 +3278,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", - "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", + "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", - "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", + "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", "dev": true, "license": "MIT", "dependencies": { @@ -3294,9 +3294,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", - "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", + "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", "cpu": [ "arm64" ], @@ -3310,9 +3310,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", - "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", + "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", "cpu": [ "x64" ], @@ -3326,9 +3326,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", - "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", + "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", "cpu": [ "arm64" ], @@ -3345,9 +3345,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", - "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", + "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", "cpu": [ "arm64" ], @@ -3364,9 +3364,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", - "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", + "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", "cpu": [ "x64" ], @@ -3383,9 +3383,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", - "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", + "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", "cpu": [ "x64" ], @@ -3402,9 +3402,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", - "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", + "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", "cpu": [ "arm64" ], @@ -3418,9 +3418,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", - "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", + "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", "cpu": [ "x64" ], @@ -7517,13 +7517,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz", - "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", + "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.10", + "@next/eslint-plugin-next": "16.2.12", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -10010,12 +10010,12 @@ "dev": true }, "node_modules/next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", - "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", + "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.10", + "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -10029,14 +10029,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.10", - "@next/swc-darwin-x64": "16.2.10", - "@next/swc-linux-arm64-gnu": "16.2.10", - "@next/swc-linux-arm64-musl": "16.2.10", - "@next/swc-linux-x64-gnu": "16.2.10", - "@next/swc-linux-x64-musl": "16.2.10", - "@next/swc-win32-arm64-msvc": "16.2.10", - "@next/swc-win32-x64-msvc": "16.2.10", + "@next/swc-darwin-arm64": "16.2.12", + "@next/swc-darwin-x64": "16.2.12", + "@next/swc-linux-arm64-gnu": "16.2.12", + "@next/swc-linux-arm64-musl": "16.2.12", + "@next/swc-linux-x64-gnu": "16.2.12", + "@next/swc-linux-x64-musl": "16.2.12", + "@next/swc-win32-arm64-msvc": "16.2.12", + "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { @@ -14904,65 +14904,65 @@ } }, "@next/env": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", - "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==" + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", + "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==" }, "@next/eslint-plugin-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", - "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", + "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", "dev": true, "requires": { "fast-glob": "3.3.1" } }, "@next/swc-darwin-arm64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", - "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", + "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", "optional": true }, "@next/swc-darwin-x64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", - "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", + "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", "optional": true }, "@next/swc-linux-arm64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", - "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", + "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", "optional": true }, "@next/swc-linux-arm64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", - "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", + "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", "optional": true }, "@next/swc-linux-x64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", - "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", + "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", "optional": true }, "@next/swc-linux-x64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", - "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", + "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", "optional": true }, "@next/swc-win32-arm64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", - "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", + "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", "optional": true }, "@next/swc-win32-x64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", - "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", + "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", "optional": true }, "@nodelib/fs.scandir": { @@ -17512,12 +17512,12 @@ } }, "eslint-config-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz", - "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", + "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", "dev": true, "requires": { - "@next/eslint-plugin-next": "16.2.10", + "@next/eslint-plugin-next": "16.2.12", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -19115,19 +19115,19 @@ "dev": true }, "next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", - "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", + "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", "requires": { - "@next/env": "16.2.10", - "@next/swc-darwin-arm64": "16.2.10", - "@next/swc-darwin-x64": "16.2.10", - "@next/swc-linux-arm64-gnu": "16.2.10", - "@next/swc-linux-arm64-musl": "16.2.10", - "@next/swc-linux-x64-gnu": "16.2.10", - "@next/swc-linux-x64-musl": "16.2.10", - "@next/swc-win32-arm64-msvc": "16.2.10", - "@next/swc-win32-x64-msvc": "16.2.10", + "@next/env": "16.2.12", + "@next/swc-darwin-arm64": "16.2.12", + "@next/swc-darwin-x64": "16.2.12", + "@next/swc-linux-arm64-gnu": "16.2.12", + "@next/swc-linux-arm64-musl": "16.2.12", + "@next/swc-linux-x64-gnu": "16.2.12", + "@next/swc-linux-x64-musl": "16.2.12", + "@next/swc-win32-arm64-msvc": "16.2.12", + "@next/swc-win32-x64-msvc": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", diff --git a/package.json b/package.json index ac420d7..9c732eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.16", + "version": "4.9.17", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", @@ -18,7 +18,7 @@ "@vercel/analytics": "^2.0.1", "hls.js": "^1.6.15", "lucide-react": "^0.577.0", - "next": "16.2.10", + "next": "16.2.12", "opencc-js": "^1.0.5", "react": "19.2.4", "react-dom": "19.2.4", @@ -32,7 +32,7 @@ "@types/react-dom": "^19", "esbuild": "^0.27.7", "eslint": "^9.25.1", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.12", "postcss": "^8.5.8", "postcss-preset-env": "^11.2.0", "tailwindcss": "^4", diff --git a/tests/m3u8-ad-detector.test.ts b/tests/m3u8-ad-detector.test.ts index c6d304a..4d587b6 100644 --- a/tests/m3u8-ad-detector.test.ts +++ b/tests/m3u8-ad-detector.test.ts @@ -80,167 +80,3 @@ test('filterM3u8Ad still strips interstitial DATERANGE metadata', () => { assert.equal(filtered.includes('com.apple.hls.interstitial'), false); assert.equal(filtered.includes('seg-0.ts'), true); }); - -test('filterM3u8Ad executes valid custom TS/JS script in sandbox', () => { - const playlist = [ - '#EXTM3U', - '#EXTINF:5.000,', - 'https://cdn.example.com/content/ad-segment.ts', - '#EXTINF:10.000,', - 'https://cdn.example.com/content/main-segment.ts', - ].join('\n'); - - const customScript = ` - function filterAdsFromM3U8(content: string, baseUrl: string): string { - return content.split('\\n').filter(line => !line.includes('ad-segment.ts')).join('\\n'); - } - `; - - const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic', [], customScript); - assert.equal(filtered.includes('ad-segment.ts'), false); - assert.equal(filtered.includes('main-segment.ts'), true); -}); - -test('filterM3u8Ad gracefully falls back to built-in rules when custom script throws runtime error', () => { - const playlist = [ - '#EXTM3U', - '#EXTINF:5.000,', - 'https://cdn.example.com/content/sponsor-ad.ts', - '#EXTINF:10.000,', - 'https://cdn.example.com/content/main-segment.ts', - ].join('\n'); - - const brokenScript = ` - function filterAdsFromM3U8(content: string): string { - throw new Error("Syntax error or intentional bug"); - } - `; - - const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic', ['sponsor'], brokenScript); - assert.equal(filtered.includes('sponsor-ad.ts'), false); - assert.equal(filtered.includes('main-segment.ts'), true); -}); - -test('filterM3u8Ad detects and strips 30fps/NTSC framerate fraction anomaly inserted ad blocks', () => { - const playlist = [ - '#EXTM3U', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-0.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-1.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-2.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-3.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-4.ts', - '#EXT-X-DISCONTINUITY', - '#EXTINF:4.867,', - 'https://cdn.example.com/content/ad-0.ts', - '#EXTINF:3.333,', - 'https://cdn.example.com/content/ad-1.ts', - '#EXT-X-DISCONTINUITY', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-5.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-6.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/content/main-7.ts', - ].join('\n'); - - const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic'); - assert.equal(filtered.includes('ad-0.ts'), false); - assert.equal(filtered.includes('ad-1.ts'), false); - assert.equal(filtered.includes('main-0.ts'), true); - assert.equal(filtered.includes('main-7.ts'), true); -}); - -test('filterM3u8Ad detects and strips Multi-CDN domain/path prefix mismatch ad blocks', () => { - const playlist = [ - '#EXTM3U', - '#EXTINF:6.000,', - 'https://media-cdn.video.com/2026/ep01/hls/seg-0.ts', - '#EXTINF:6.000,', - 'https://media-cdn.video.com/2026/ep01/hls/seg-1.ts', - '#EXTINF:6.000,', - 'https://media-cdn.video.com/2026/ep01/hls/seg-2.ts', - '#EXT-X-DISCONTINUITY', - '#EXTINF:15.000,', - 'https://ad-server.net/campaign/preroll/ad-0.ts', - '#EXTINF:15.000,', - 'https://ad-server.net/campaign/preroll/ad-1.ts', - '#EXT-X-DISCONTINUITY', - '#EXTINF:6.000,', - 'https://media-cdn.video.com/2026/ep01/hls/seg-3.ts', - '#EXTINF:6.000,', - 'https://media-cdn.video.com/2026/ep01/hls/seg-4.ts', - ].join('\n'); - - const filtered = filterM3u8Ad(playlist, 'https://media-cdn.video.com/2026/ep01/hls/index.m3u8', 'heuristic'); - assert.equal(filtered.includes('ad-server.net'), false); - assert.equal(filtered.includes('seg-0.ts'), true); - assert.equal(filtered.includes('seg-4.ts'), true); -}); - -test('filterM3u8Ad protects global 30fps/NTSC feature film streams with 0 false positives', () => { - const playlist = [ - '#EXTM3U', - '#EXTINF:4.033,', - 'https://cdn.example.com/movie/seg-0.ts', - '#EXTINF:4.867,', - 'https://cdn.example.com/movie/seg-1.ts', - '#EXTINF:3.333,', - 'https://cdn.example.com/movie/seg-2.ts', - '#EXTINF:4.167,', - 'https://cdn.example.com/movie/seg-3.ts', - '#EXT-X-DISCONTINUITY', - '#EXTINF:4.033,', - 'https://cdn.example.com/movie/seg-4.ts', - '#EXTINF:4.867,', - 'https://cdn.example.com/movie/seg-5.ts', - '#EXTINF:3.333,', - 'https://cdn.example.com/movie/seg-6.ts', - ].join('\n'); - - const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/movie/index.m3u8', 'heuristic'); - assert.equal(filtered.includes('seg-0.ts'), true); - assert.equal(filtered.includes('seg-3.ts'), true); - assert.equal(filtered.includes('seg-6.ts'), true); -}); - -test('filterM3u8Ad detects and strips 25fps integer duration inserted ad blocks in 24fps movie streams', () => { - const playlist = [ - '#EXTM3U', - '#EXTINF:4.004,', - 'https://cdn.example.com/movie/seg-0.ts', - '#EXTINF:4.004,', - 'https://cdn.example.com/movie/seg-1.ts', - '#EXTINF:4.004,', - 'https://cdn.example.com/movie/seg-2.ts', - '#EXTINF:4.004,', - 'https://cdn.example.com/movie/seg-3.ts', - '#EXT-X-DISCONTINUITY', - '#EXTINF:4.000,', - 'https://cdn.example.com/movie/ad-0.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/movie/ad-1.ts', - '#EXTINF:4.000,', - 'https://cdn.example.com/movie/ad-2.ts', - '#EXTINF:0.560,', - 'https://cdn.example.com/movie/ad-3.ts', - '#EXT-X-DISCONTINUITY', - '#EXTINF:4.004,', - 'https://cdn.example.com/movie/seg-4.ts', - '#EXTINF:4.004,', - 'https://cdn.example.com/movie/seg-5.ts', - ].join('\n'); - - const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/movie/index.m3u8', 'heuristic'); - assert.equal(filtered.includes('ad-0.ts'), false); - assert.equal(filtered.includes('ad-3.ts'), false); - assert.equal(filtered.includes('seg-0.ts'), true); - assert.equal(filtered.includes('seg-5.ts'), true); -}); - - - diff --git a/tests/m3u8-duration-grid.test.ts b/tests/m3u8-duration-grid.test.ts new file mode 100644 index 0000000..b68564c --- /dev/null +++ b/tests/m3u8-duration-grid.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { filterM3u8Ad } from '../lib/utils/m3u8-utils'; + +interface PlaylistBlock { + durations: number[]; + prefix: string; +} + +function buildPlaylist(blocks: PlaylistBlock[]): string { + const lines = ['#EXTM3U']; + blocks.forEach((block, blockIndex) => { + if (blockIndex > 0) lines.push('#EXT-X-DISCONTINUITY'); + block.durations.forEach((duration, segmentIndex) => { + lines.push(`#EXTINF:${duration.toFixed(3)},`); + lines.push(`https://cdn.example.com/content/${block.prefix}-${segmentIndex}.ts`); + }); + }); + return lines.join('\n'); +} + +test('NTSC-like duration residues remove a small block only with another signal', () => { + const playlist = buildPlaylist([ + { durations: [4, 4, 4, 4, 4], prefix: 'main-a' }, + { durations: [4.867, 3.333], prefix: 'ad' }, + { durations: [4, 4, 4], prefix: 'main-b' }, + ]); + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8'); + assert.equal(filtered.includes('/ad-0.ts'), false); + assert.equal(filtered.includes('/main-a-0.ts'), true); + assert.equal(filtered.includes('/main-b-2.ts'), true); +}); + +test('one legitimate NTSC-like segment is not deleted from an integer-duration stream', () => { + const playlist = buildPlaylist([ + { durations: [4, 4.033, 4, 4, 4], prefix: 'main' }, + ]); + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8'); + assert.equal(filtered.includes('/main-1.ts'), true); +}); + +test('a dominant NTSC-like main stream keeps later matching content blocks', () => { + const playlist = buildPlaylist([ + { durations: [4.033, 4.867, 3.333, 4.167], prefix: 'main-a' }, + { durations: [4.033, 4.867, 3.333], prefix: 'main-b' }, + ]); + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8'); + assert.equal(filtered.includes('/main-a-0.ts'), true); + assert.equal(filtered.includes('/main-b-2.ts'), true); +}); + +test('integer-duration inserts are detected inside a film-24-like stream', () => { + const playlist = buildPlaylist([ + { durations: [4.004, 4.004, 4.004, 4.004], prefix: 'main-a' }, + { durations: [4, 4, 4, 0.56], prefix: 'ad' }, + { durations: [4.004, 4.004], prefix: 'main-b' }, + ]); + + const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8'); + assert.equal(filtered.includes('/ad-0.ts'), false); + assert.equal(filtered.includes('/ad-3.ts'), false); + assert.equal(filtered.includes('/main-b-1.ts'), true); +}); diff --git a/tests/m3u8-filter-regression.test.ts b/tests/m3u8-filter-regression.test.ts new file mode 100644 index 0000000..65fa625 --- /dev/null +++ b/tests/m3u8-filter-regression.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { filterM3u8Ad } from '../lib/utils/m3u8-utils'; + +function addBlock( + lines: string[], + durations: number[], + origin: string, + prefix: string, +): void { + if (lines.length > 1) lines.push('#EXT-X-DISCONTINUITY'); + durations.forEach((duration, index) => { + lines.push(`#EXTINF:${duration.toFixed(3)},`); + lines.push(`${origin}/content/${prefix}-${index}.ts`); + }); +} + +test('equal-size repeated content blocks are not treated as duplicate ads', () => { + const lines = ['#EXTM3U']; + addBlock(lines, [6, 6.1, 5.9], 'https://cdn.example.com', 'chapter-a'); + addBlock(lines, [6, 6.1, 5.9], 'https://cdn.example.com', 'chapter-b'); + + const filtered = filterM3u8Ad(lines.join('\n'), 'https://cdn.example.com/content/index.m3u8'); + assert.equal(filtered.includes('/chapter-a-0.ts'), true); + assert.equal(filtered.includes('/chapter-b-2.ts'), true); +}); + +test('uniform fixed-GOP content matching the main duration is protected', () => { + const lines = ['#EXTM3U']; + addBlock(lines, [2, 2, 2], 'https://cdn.example.com', 'chapter-a'); + addBlock(lines, [2, 2, 2, 2, 2], 'https://cdn.example.com', 'chapter-b'); + addBlock(lines, [2, 2, 2], 'https://cdn.example.com', 'chapter-c'); + + const filtered = filterM3u8Ad(lines.join('\n'), 'https://cdn.example.com/content/index.m3u8'); + assert.equal(filtered.includes('/chapter-a-0.ts'), true); + assert.equal(filtered.includes('/chapter-c-2.ts'), true); +}); + +test('a same-path cross-origin insert needs a filename mismatch before removal', () => { + const lines = ['#EXTM3U']; + addBlock(lines, [6, 6, 6, 6], 'https://media.example.com', 'main-a'); + addBlock(lines, [6, 6], 'https://ads.example.net', 'ad'); + addBlock(lines, [6, 6], 'https://media.example.com', 'main-b'); + + const filtered = filterM3u8Ad(lines.join('\n'), 'https://media.example.com/content/index.m3u8'); + assert.equal(filtered.includes('ads.example.net'), false); + assert.equal(filtered.includes('/main-a-0.ts'), true); + assert.equal(filtered.includes('/main-b-1.ts'), true); +}); + +test('off mode returns the original playlist byte-for-byte', () => { + const playlist = [ + '#EXTM3U', + '#EXT-X-CUE-IN', + '#EXTINF:4.000,', + 'relative/segment.ts', + ].join('\n'); + + assert.equal(filterM3u8Ad(playlist, 'https://cdn.example.com/index.m3u8', 'off'), playlist); +});