Files
KVideo/lib/utils/m3u8-filter.ts
T
2025-11-16 10:49:55 +08:00

367 lines
8.1 KiB
TypeScript

/**
* M3U8 Ad Filtering Utility
* Custom HLS loader with ad segment filtering
*/
// Ad detection patterns
const AD_PATTERNS = [
'/ad/',
'/ads/',
'/advertisement/',
'/advert/',
'_ad_',
'_ads_',
'-ad-',
'-ads-',
'ad.ts',
'ad.m3u8',
'ads.ts',
'ads.m3u8',
'advert',
'commercial',
'/promo/',
];
// Additional keywords to filter
const AD_KEYWORDS = [
'advertisement',
'commercial',
'sponsored',
'promo',
'banner',
];
/**
* Check if URL contains ad patterns
*/
function isAdSegment(url: string): boolean {
const lowerUrl = url.toLowerCase();
// Check URL patterns
if (AD_PATTERNS.some(pattern => lowerUrl.includes(pattern))) {
return true;
}
// Check keywords
if (AD_KEYWORDS.some(keyword => lowerUrl.includes(keyword))) {
return true;
}
return false;
}
/**
* Parse M3U8 playlist content
*/
interface M3U8Segment {
duration?: number;
url: string;
metadata: string[];
isAd: boolean;
}
function parseM3U8(content: string, baseUrl: string): {
header: string[];
segments: M3U8Segment[];
} {
const lines = content.split('\n');
const header: string[] = [];
const segments: M3U8Segment[] = [];
let currentMetadata: string[] = [];
let inHeader = true;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Header lines
if (line.startsWith('#EXTM3U')) {
header.push(line);
continue;
}
// Check if we're still in header
if (inHeader && line.startsWith('#EXT-X-')) {
header.push(line);
continue;
}
if (line.startsWith('#EXTINF')) {
inHeader = false;
currentMetadata.push(line);
// Extract duration
const durationMatch = line.match(/#EXTINF:([\d.]+)/);
const duration = durationMatch ? parseFloat(durationMatch[1]) : undefined;
// Next line should be the URL
if (i + 1 < lines.length) {
i++;
const urlLine = lines[i].trim();
if (urlLine && !urlLine.startsWith('#')) {
// Resolve URL
const resolvedUrl = resolveUrl(urlLine, baseUrl);
const isAd = isAdSegment(resolvedUrl);
segments.push({
duration,
url: urlLine, // Keep original URL
metadata: [...currentMetadata],
isAd,
});
currentMetadata = [];
}
}
} else if (line.startsWith('#')) {
if (inHeader) {
header.push(line);
} else {
currentMetadata.push(line);
}
}
}
return { header, segments };
}
/**
* Resolve relative URL
*/
function resolveUrl(url: string, baseUrl: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
try {
const base = new URL(baseUrl);
return new URL(url, base).href;
} catch {
return url;
}
}
/**
* Filter M3U8 playlist to remove ads
*/
export function filterM3U8Playlist(content: string, baseUrl: string): string {
const { header, segments } = parseM3U8(content, baseUrl);
// Filter out ad segments
const filteredSegments = segments.filter(segment => !segment.isAd);
// Rebuild playlist
const output: string[] = [...header];
let needsDiscontinuity = false;
for (let i = 0; i < filteredSegments.length; i++) {
const segment = filteredSegments[i];
const prevSegment = i > 0 ? filteredSegments[i - 1] : null;
// Check if we need discontinuity tag
if (prevSegment && needsDiscontinuity) {
// Find discontinuity in metadata
const hasDiscontinuity = segment.metadata.some(line =>
line.includes('DISCONTINUITY')
);
if (!hasDiscontinuity) {
// Add discontinuity if needed
output.push('#EXT-X-DISCONTINUITY');
}
needsDiscontinuity = false;
}
// Add segment metadata (excluding discontinuity tags)
segment.metadata.forEach(line => {
if (!line.includes('DISCONTINUITY')) {
output.push(line);
}
});
// Add segment URL
output.push(segment.url);
}
return output.join('\n');
}
/**
* Custom HLS loader with ad filtering
*/
export class AdFilteringHLSLoader {
private baseUrl: string = '';
load(
context: any,
config: any,
callbacks: any
): void {
const url = context.url;
// Store base URL for resolving relative URLs
if (url.includes('.m3u8')) {
this.baseUrl = url.substring(0, url.lastIndexOf('/'));
}
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
})
.then(content => {
// Check if it's a playlist
if (content.includes('#EXTM3U') && content.includes('#EXTINF')) {
// Filter ads from playlist
const filtered = filterM3U8Playlist(content, url);
// Convert to response format
const blob = new Blob([filtered], { type: 'application/vnd.apple.mpegurl' });
const reader = new FileReader();
reader.onload = () => {
callbacks.onSuccess(
{
url,
data: reader.result,
},
{
url,
},
context
);
};
reader.onerror = () => {
callbacks.onError(
{
code: 500,
text: 'Failed to process playlist',
},
context
);
};
reader.readAsText(blob);
} else {
// Not a playlist, pass through
callbacks.onSuccess(
{
url,
data: content,
},
{
url,
},
context
);
}
})
.catch(error => {
callbacks.onError(
{
code: 500,
text: error.message,
},
context
);
});
}
abort(): void {
// Implement abort logic if needed
}
}
/**
* Create HLS config with ad filtering
*/
export function createAdFilteringConfig(hlsConfig: any = {}): any {
return {
...hlsConfig,
loader: AdFilteringHLSLoader,
debug: false,
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90,
};
}
/**
* Detect if M3U8 contains ads
*/
export async function detectAdsInM3U8(url: string): Promise<{
hasAds: boolean;
adCount: number;
totalSegments: number;
}> {
try {
const response = await fetch(url);
const content = await response.text();
const { segments } = parseM3U8(content, url);
const adSegments = segments.filter(s => s.isAd);
return {
hasAds: adSegments.length > 0,
adCount: adSegments.length,
totalSegments: segments.length,
};
} catch (error) {
console.error('Failed to detect ads:', error);
return {
hasAds: false,
adCount: 0,
totalSegments: 0,
};
}
}
/**
* Add custom ad pattern
*/
const CUSTOM_AD_PATTERNS_KEY = 'kvideo_custom_ad_patterns';
export function addCustomAdPattern(pattern: string): void {
if (typeof window === 'undefined') return;
try {
const patterns = getCustomAdPatterns();
if (!patterns.includes(pattern)) {
patterns.push(pattern);
localStorage.setItem(CUSTOM_AD_PATTERNS_KEY, JSON.stringify(patterns));
}
} catch (error) {
console.error('Failed to add custom ad pattern:', error);
}
}
export function getCustomAdPatterns(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(CUSTOM_AD_PATTERNS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function removeCustomAdPattern(pattern: string): void {
if (typeof window === 'undefined') return;
try {
const patterns = getCustomAdPatterns();
const filtered = patterns.filter(p => p !== pattern);
localStorage.setItem(CUSTOM_AD_PATTERNS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to remove custom ad pattern:', error);
}
}