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
+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') {