mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user