Files
kuekhaoyang cdab3b1c8a fix: harden M3U8 ad filtering
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.
2026-07-29 22:13:54 +08:00

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');
}