feat(ad-filter): upgrade M3U8 ad filtering algorithm with dynamic script sandbox, zero false-positive framerate protections, and Edge API

This commit is contained in:
Troray
2026-07-29 13:38:31 +08:00
parent 6151edab4d
commit c53801d3bf
7 changed files with 364 additions and 19 deletions
+40
View File
@@ -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 }
);
}
}
+6 -6
View File
@@ -22,7 +22,7 @@ export function useHlsPlayer({
onError
}: UseHlsPlayerProps) {
const hlsRef = useRef<Hls | null>(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]);
}
@@ -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,
+4
View File
@@ -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,
+83 -6
View File
@@ -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<number>
// 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<number>
}
});
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<string, number[]>();
@@ -240,6 +268,15 @@ export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set<number>
// 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<number>
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;
}
+53 -7
View File
@@ -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') {
+164
View File
@@ -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);
});