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); +}); + + +