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:
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user