mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-13 07:43:43 +08:00
Reject unsafe dynamic filter execution, reduce heuristic false positives, add regression coverage, bump the app to 4.9.17, and update Next.js to the latest stable security patch.
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
|
|
/**
|
|
* Utility functions for M3U8 playlist manipulation
|
|
*/
|
|
|
|
import {
|
|
parseBlocks,
|
|
learnMainPattern,
|
|
scoreBlock,
|
|
shouldFilterBlock,
|
|
findDuplicateSignatureBlockIndices,
|
|
} from './m3u8-ad-detector';
|
|
|
|
const INTERSTITIAL_DATERANGE_MARKERS = [
|
|
'class="com.apple.hls.interstitial"',
|
|
'x-asset-uri=',
|
|
'x-asset-list=',
|
|
'x-playout-limit=',
|
|
'x-resume-offset=',
|
|
'x-restrict=',
|
|
'cue="once"',
|
|
];
|
|
|
|
const AUXILIARY_AD_TAG_PREFIXES = [
|
|
'#EXT-X-ASSET:',
|
|
'#EXT-X-CUE-OUT-CONT',
|
|
'#EXT-X-PLACEMENT-OPPORTUNITY',
|
|
'#EXT-OATCLS-SCTE35',
|
|
'#EXT-X-SCTE35',
|
|
];
|
|
|
|
const SEGMENT_METADATA_PREFIXES = [
|
|
'#EXTINF:',
|
|
'#EXT-X-BYTERANGE:',
|
|
'#EXT-X-DISCONTINUITY',
|
|
'#EXT-X-PROGRAM-DATE-TIME:',
|
|
];
|
|
|
|
function normalizeKeywords(keywords: string[]): string[] {
|
|
return [...new Set(
|
|
keywords
|
|
.map((keyword) => keyword.trim().toLowerCase())
|
|
.filter((keyword) => keyword.length > 0)
|
|
)];
|
|
}
|
|
|
|
function hasKeywordMatch(line: string, normalizedKeywords: string[]): boolean {
|
|
if (normalizedKeywords.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const lowerLine = line.toLowerCase();
|
|
return normalizedKeywords.some((keyword) => lowerLine.includes(keyword));
|
|
}
|
|
|
|
function isInterstitialDateRange(trimmedLine: string, normalizedKeywords: string[]): boolean {
|
|
if (!trimmedLine.startsWith('#EXT-X-DATERANGE:')) {
|
|
return false;
|
|
}
|
|
|
|
const lowerLine = trimmedLine.toLowerCase();
|
|
return INTERSTITIAL_DATERANGE_MARKERS.some((marker) => lowerLine.includes(marker)) ||
|
|
hasKeywordMatch(trimmedLine, normalizedKeywords);
|
|
}
|
|
|
|
function isAuxiliaryAdMetadataLine(trimmedLine: string, normalizedKeywords: string[]): boolean {
|
|
return AUXILIARY_AD_TAG_PREFIXES.some((prefix) => trimmedLine.startsWith(prefix)) ||
|
|
(trimmedLine.startsWith('#EXT-X-DATERANGE:') && hasKeywordMatch(trimmedLine, normalizedKeywords));
|
|
}
|
|
|
|
/**
|
|
* Filters ads from specific M3U8 content using multiple detection strategies:
|
|
* 1. Keyword matching (configurable via env)
|
|
* 2. CUE-OUT/CUE-IN standard tags
|
|
* 3. HLS interstitial metadata removal
|
|
* 4. Heuristic block analysis (filename patterns, ad path keywords, duration signature fingerprints)
|
|
*
|
|
* Also converts relative URLs to absolute URLs for Blob playback.
|
|
*
|
|
* @param content The raw M3U8 content string
|
|
* @param baseUrl The base URL of the M3U8 file (to resolve relative paths)
|
|
* @returns The filtered M3U8 content
|
|
*/
|
|
export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive';
|
|
|
|
export function filterM3u8Ad(
|
|
content: string,
|
|
baseUrl: string,
|
|
mode: AdFilterMode = 'heuristic',
|
|
customKeywords: string[] = []
|
|
): string {
|
|
if (!content) return '';
|
|
if (mode === 'off') return content;
|
|
|
|
// Use keywords passed from AdKeywordsWrapper (already loaded from env/file)
|
|
const normalizedKeywords = normalizeKeywords(customKeywords);
|
|
|
|
// Unwrap baseUrl if it's a proxy URL to get correct basePath and origin
|
|
let effectiveBaseUrl = baseUrl;
|
|
if (baseUrl.includes('/api/proxy?url=')) {
|
|
try {
|
|
const urlMatch = baseUrl.match(/[?&]url=([^&]+)/);
|
|
if (urlMatch && urlMatch[1]) {
|
|
effectiveBaseUrl = decodeURIComponent(urlMatch[1]);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const basePath = effectiveBaseUrl.substring(0, effectiveBaseUrl.lastIndexOf('/') + 1);
|
|
let origin = '';
|
|
try {
|
|
origin = new URL(effectiveBaseUrl).origin;
|
|
} catch { /* ignore */ }
|
|
|
|
// 2. Global Scan: Check if any ad keywords exist in the content
|
|
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/);
|
|
const adLineIndices = new Set<number>();
|
|
|
|
if (!hasCueTag && (mode === 'heuristic' || mode === 'aggressive')) {
|
|
// No obvious ad signals - run heuristic analysis
|
|
const blocks = parseBlocks(lines);
|
|
if (blocks.length > 0) {
|
|
const mainPattern = learnMainPattern(blocks);
|
|
const duplicateIndices = findDuplicateSignatureBlockIndices(blocks);
|
|
|
|
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
|
|
const block = blocks[blockIdx];
|
|
const isDuplicate = duplicateIndices.has(blockIdx);
|
|
// Pass all keywords (including custom ones) to heuristic scorer
|
|
const score = scoreBlock(block, mainPattern, normalizedKeywords, isDuplicate);
|
|
const threshold = mode === 'aggressive' ? 3.0 : 5.0;
|
|
|
|
if (shouldFilterBlock(score, threshold)) {
|
|
// Mark all lines in this block for removal
|
|
for (const segment of block.segments) {
|
|
adLineIndices.add(segment.lineIndex);
|
|
adLineIndices.add(segment.lineIndex - 1); // EXTINF line
|
|
}
|
|
} else if (block.segments.length > 0) {
|
|
// Segment-level detection:
|
|
// Even if the whole block didn't trigger, check segments individually
|
|
// if it's a suspicious single-segment "block" (common for ads without discontinuity)
|
|
for (const segment of block.segments) {
|
|
const singleSegmentBlock = {
|
|
segments: [segment],
|
|
hasCueTag: false,
|
|
startLineIndex: segment.lineIndex - 1,
|
|
endLineIndex: segment.lineIndex
|
|
};
|
|
const segmentScore = scoreBlock(singleSegmentBlock, mainPattern, normalizedKeywords);
|
|
// Higher threshold for individual segments to avoid false positives
|
|
if (segmentScore >= 4.0) {
|
|
adLineIndices.add(segment.lineIndex);
|
|
adLineIndices.add(segment.lineIndex - 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const processedLines: string[] = [];
|
|
let hasKeptMediaLine = false;
|
|
|
|
// State machine for CUE-OUT/CUE-IN tracking
|
|
let insideCueAdBlock = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmedLine = line.trim();
|
|
|
|
// Skip lines marked by heuristic analysis
|
|
if (adLineIndices.has(i)) {
|
|
continue;
|
|
}
|
|
|
|
// 4. Strip modern HLS interstitial metadata before the player can schedule it.
|
|
if (
|
|
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 (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') {
|
|
processedLines.pop();
|
|
}
|
|
continue; // Skip the CUE-OUT tag itself
|
|
}
|
|
|
|
if (trimmedLine.startsWith('#EXT-X-CUE-IN')) {
|
|
insideCueAdBlock = false;
|
|
// Also skip the next line if it's a DISCONTINUITY (ad block ending marker)
|
|
if (i + 1 < lines.length && lines[i + 1].trim() === '#EXT-X-DISCONTINUITY') {
|
|
i++; // Skip the following DISCONTINUITY
|
|
}
|
|
continue; // Skip the CUE-IN tag itself
|
|
}
|
|
|
|
// Skip all content inside CUE ad block
|
|
if (insideCueAdBlock) {
|
|
continue;
|
|
}
|
|
|
|
// 6. Keyword-based Ad Detection & Backtrack (skip if no keywords configured)
|
|
if (
|
|
normalizedKeywords.length > 0 &&
|
|
hasKeywordMatchInPlaylist &&
|
|
hasKeywordMatch(trimmedLine, normalizedKeywords)
|
|
) {
|
|
// Found Ad: Remove it and backtrack to remove associated metadata
|
|
while (processedLines.length > 0) {
|
|
const lastIndex = processedLines.length - 1;
|
|
const lastLine = processedLines[lastIndex].trim();
|
|
|
|
if (SEGMENT_METADATA_PREFIXES.some((prefix) => lastLine.startsWith(prefix))) {
|
|
processedLines.pop();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
continue; // Skip the ad line itself
|
|
}
|
|
|
|
// 7. Discontinuity Handling (Conservative Mode)
|
|
// Keep all Discontinuity tags by default.
|
|
// They will ONLY be removed via backtracking when a confirmed ad segment is found.
|
|
// This prevents false positives on legitimate concatenated streams.
|
|
if (trimmedLine === '#EXT-X-DISCONTINUITY') {
|
|
if (
|
|
!hasKeptMediaLine ||
|
|
processedLines[processedLines.length - 1].trim() === '#EXT-X-DISCONTINUITY'
|
|
) {
|
|
continue;
|
|
}
|
|
processedLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
// 8. General Cleanup & URL Normalization
|
|
if (!trimmedLine || trimmedLine.startsWith('http') || trimmedLine.startsWith('blob:')) {
|
|
processedLines.push(line);
|
|
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
|
hasKeptMediaLine = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (trimmedLine.startsWith('#')) {
|
|
// Handle URI="..." in attributes (e.g. #EXT-X-KEY)
|
|
if (trimmedLine.includes('URI="')) {
|
|
processedLines.push(line.replace(/URI="([^"]+)"/g, (match, uri) => {
|
|
if (uri.startsWith('http')) return match; // Already absolute
|
|
if (uri.startsWith('/')) {
|
|
return `URI="${origin}${uri}"`; // Root-relative
|
|
}
|
|
return `URI="${basePath}${uri}"`; // Path-relative
|
|
}));
|
|
} else {
|
|
processedLines.push(line);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 9. Resolve Relative URLs (for Blob support)
|
|
if (trimmedLine.startsWith('/')) {
|
|
processedLines.push(origin ? `${origin}${trimmedLine}` : trimmedLine);
|
|
} else {
|
|
processedLines.push(`${basePath}${trimmedLine}`);
|
|
}
|
|
hasKeptMediaLine = true;
|
|
}
|
|
|
|
while (
|
|
processedLines.length > 0 &&
|
|
processedLines[processedLines.length - 1].trim() === '#EXT-X-DISCONTINUITY'
|
|
) {
|
|
processedLines.pop();
|
|
}
|
|
|
|
return processedLines.join('\n');
|
|
}
|