mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
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.
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 4.9.17 - 2026-07-29
|
||||
|
||||
- 新增基于主内容时长残差的组合式 M3U8 插播检测;残差只作为辅助证据,单个 `.033/.333/.867` 等合法分片不再被直接删除。
|
||||
- 重复时长指纹恢复大内容块保护并限制出现比例,避免固定 GOP、等长章节或重复正片结构被整块误删。
|
||||
- 跨源同路径检测同时比较来源域名与文件名模式,在增强 Multi-CDN 插播识别的同时保留合法 CDN 切换的保护条件。
|
||||
- 安全审查后未启用基于 `new Function` 的动态 JS/TS 执行和公开脚本 API;该实现不是沙盒,且无法防止任意代码执行或主线程死循环。
|
||||
- 广告检测器拆分为解析、主模式、重复指纹、时长网格与评分模块,并补充误删回归测试。
|
||||
- Next.js 与配套 ESLint 配置升级至最新稳定版 16.2.12,消除 16.2.10 的多项框架级高风险公告;上游固定的 PostCSS/sharp 审计项仍等待官方稳定修复。
|
||||
|
||||
## 4.9.16 - 2026-07-27
|
||||
|
||||
- 修复窗口宽度变化后右侧“历史播放记录”浮动按钮停留在原位置、无法继续贴靠右边缘的问题。
|
||||
|
||||
+14
-1
@@ -4,8 +4,21 @@
|
||||
"name": "KVideo",
|
||||
"branch": "main"
|
||||
},
|
||||
"currentVersion": "4.9.16",
|
||||
"currentVersion": "4.9.17",
|
||||
"releases": [
|
||||
{
|
||||
"version": "4.9.17",
|
||||
"publishedAt": "2026-07-29",
|
||||
"title": "加固 M3U8 广告检测",
|
||||
"notes": [
|
||||
"新增基于主内容时长残差的组合式检测,识别跨帧率小型插播,同时禁止把单个异常时长分片直接判为广告。",
|
||||
"重复时长指纹恢复大内容块保护并限制出现比例,避免固定 GOP 或等长章节被整块误删。",
|
||||
"跨源同路径检测同时比较来源域名与文件名模式,并保留合法 CDN 切换所需的组合证据。",
|
||||
"安全审查后未启用动态 JS/TS 执行与公开脚本 API,避免任意代码执行、主线程死循环和无效配置链路。",
|
||||
"广告检测器按解析、主模式、指纹与评分拆分,并新增误删回归测试。",
|
||||
"Next.js 与配套 ESLint 配置升级至最新稳定版 16.2.12,消除 16.2.10 的多项框架级高风险公告;上游固定的 PostCSS/sharp 审计项仍等待官方稳定修复。"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "4.9.16",
|
||||
"publishedAt": "2026-07-27",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/ad-filter
|
||||
* Public endpoint to fetch custom ad filter code and versioning.
|
||||
* Query params:
|
||||
* - ?full=true : returns { version, code }
|
||||
* - default : returns { version }
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const full = searchParams.get('full') === 'true';
|
||||
|
||||
const code = process.env.CUSTOM_AD_FILTER_CODE || '';
|
||||
const version = process.env.CUSTOM_AD_FILTER_VERSION
|
||||
? parseInt(process.env.CUSTOM_AD_FILTER_VERSION, 10)
|
||||
: (code ? 1 : 0);
|
||||
|
||||
if (full) {
|
||||
return NextResponse.json({
|
||||
code,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
version,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AdFilter API] Error fetching custom filter:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch ad filter config', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function useHlsPlayer({
|
||||
onError
|
||||
}: UseHlsPlayerProps) {
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const { adFilterMode, adKeywords, customAdFilterCode } = usePlayerSettings(isPremium);
|
||||
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
|
||||
const { mediaProxyEnabled } = useRuntimeFeatures();
|
||||
const isAdFilterEnabled = adFilterMode !== 'off';
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useHlsPlayer({
|
||||
if (typeof response.data === 'string') {
|
||||
try {
|
||||
// Filter the content
|
||||
response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords, customAdFilterCode);
|
||||
response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords);
|
||||
} catch (e) {
|
||||
console.warn('[HLS] Ad filter error:', e);
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export function useHlsPlayer({
|
||||
|
||||
// If it's a simple playlist (no variants), just filter and play
|
||||
if (!masterContent.includes('#EXT-X-STREAM-INF')) {
|
||||
const filtered = filterM3u8Ad(masterContent, absoluteMasterSrc, adFilterMode, adKeywords, customAdFilterCode);
|
||||
const filtered = filterM3u8Ad(masterContent, absoluteMasterSrc, adFilterMode, adKeywords);
|
||||
const blob = new Blob([filtered], { type: 'application/vnd.apple.mpegurl' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
createdBlobs.push(blobUrl);
|
||||
@@ -283,7 +283,7 @@ export function useHlsPlayer({
|
||||
try {
|
||||
const absoluteUrl = isRelative ? new URL(uri, absoluteMasterSrc).toString() : uri;
|
||||
const subContent = await fetchWithFallback(absoluteUrl);
|
||||
const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords, customAdFilterCode);
|
||||
const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords);
|
||||
const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' });
|
||||
const subBlobUrl = URL.createObjectURL(subBlob);
|
||||
createdBlobs.push(subBlobUrl);
|
||||
@@ -306,7 +306,7 @@ export function useHlsPlayer({
|
||||
try {
|
||||
const absoluteUrl = isRelative ? new URL(trimmedLine, absoluteMasterSrc).toString() : trimmedLine;
|
||||
const subContent = await fetchWithFallback(absoluteUrl);
|
||||
const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords, customAdFilterCode);
|
||||
const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords);
|
||||
const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' });
|
||||
const subBlobUrl = URL.createObjectURL(subBlob);
|
||||
createdBlobs.push(subBlobUrl);
|
||||
@@ -424,5 +424,5 @@ export function useHlsPlayer({
|
||||
}
|
||||
extraBlobs.forEach(url => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, customAdFilterCode, mediaProxyEnabled]);
|
||||
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ interface PlayerSettingsSnapshot {
|
||||
adFilter: boolean;
|
||||
adFilterMode: AdFilterMode;
|
||||
adKeywords: string[];
|
||||
customAdFilterCode: string;
|
||||
customAdFilterVersion: number;
|
||||
fullscreenType: 'auto' | 'native' | 'window';
|
||||
proxyMode: 'retry' | 'none' | 'always';
|
||||
danmakuEnabled: boolean;
|
||||
@@ -47,8 +45,6 @@ function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolea
|
||||
adFilter: globalSettings.adFilter,
|
||||
adFilterMode: modeSettings.adFilterMode,
|
||||
adKeywords: globalSettings.adKeywords,
|
||||
customAdFilterCode: globalSettings.customAdFilterCode || '',
|
||||
customAdFilterVersion: globalSettings.customAdFilterVersion || 0,
|
||||
fullscreenType: modeSettings.fullscreenType,
|
||||
proxyMode: mediaProxyEnabled ? modeSettings.proxyMode : 'none',
|
||||
danmakuEnabled: modeSettings.danmakuEnabled,
|
||||
@@ -70,8 +66,6 @@ function playerSettingsEqual(a: PlayerSettingsSnapshot, b: PlayerSettingsSnapsho
|
||||
a.adFilter === b.adFilter &&
|
||||
a.adFilterMode === b.adFilterMode &&
|
||||
a.adKeywords === b.adKeywords &&
|
||||
a.customAdFilterCode === b.customAdFilterCode &&
|
||||
a.customAdFilterVersion === b.customAdFilterVersion &&
|
||||
a.fullscreenType === b.fullscreenType &&
|
||||
a.proxyMode === b.proxyMode &&
|
||||
a.danmakuEnabled === b.danmakuEnabled &&
|
||||
@@ -171,13 +165,6 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
updateGlobalSettings({ adKeywords: value });
|
||||
}, [updateGlobalSettings]);
|
||||
|
||||
const setCustomAdFilterCode = useCallback((code: string, version?: number) => {
|
||||
updateGlobalSettings({
|
||||
customAdFilterCode: code,
|
||||
...(version !== undefined ? { customAdFilterVersion: version } : {})
|
||||
});
|
||||
}, [updateGlobalSettings]);
|
||||
|
||||
const setFullscreenType = useCallback((value: 'auto' | 'native' | 'window') => {
|
||||
updateModeSettings({ fullscreenType: value });
|
||||
}, [updateModeSettings]);
|
||||
@@ -217,7 +204,6 @@ export function usePlayerSettings(isPremium: boolean = false) {
|
||||
setAdFilter,
|
||||
setAdFilterMode,
|
||||
setAdKeywords,
|
||||
setCustomAdFilterCode,
|
||||
setFullscreenType,
|
||||
setProxyMode,
|
||||
setDanmakuEnabled,
|
||||
|
||||
@@ -53,8 +53,6 @@ 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
|
||||
@@ -243,8 +241,6 @@ 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,
|
||||
|
||||
+26
-381
@@ -1,102 +1,59 @@
|
||||
/**
|
||||
* Heuristic Ad Detection Module
|
||||
*
|
||||
* Provides block-based analysis for detecting ads in M3U8 playlists
|
||||
* using filename pattern matching and other heuristics.
|
||||
*/
|
||||
import type { Block } from './m3u8-ad-types';
|
||||
|
||||
// Ad-related path keywords for scoring
|
||||
export const AD_PATH_KEYWORDS = [
|
||||
'advert', 'preroll', 'midroll', 'postroll',
|
||||
'dai', 'vast', 'ima', 'adjump', 'commercial', 'sponsor'
|
||||
];
|
||||
export type { Block, MainPattern, Segment } from './m3u8-ad-types';
|
||||
export { learnMainPattern } from './m3u8-ad-pattern';
|
||||
export { findDuplicateSignatureBlockIndices } from './m3u8-ad-signatures';
|
||||
export {
|
||||
AD_PATH_KEYWORDS,
|
||||
scoreBlock,
|
||||
shouldFilterBlock,
|
||||
THRESHOLDS,
|
||||
} from './m3u8-ad-scoring';
|
||||
|
||||
/**
|
||||
* Represents a segment in the playlist
|
||||
*/
|
||||
interface Segment {
|
||||
url: string;
|
||||
duration: number;
|
||||
lineIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a block of segments between DISCONTINUITY markers
|
||||
*/
|
||||
interface Block {
|
||||
segments: Segment[];
|
||||
startLineIndex: number;
|
||||
endLineIndex: number;
|
||||
hasCueTag: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern extracted from main content for comparison
|
||||
*/
|
||||
interface MainPattern {
|
||||
filenameRegex: RegExp | null;
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse M3U8 content into blocks separated by DISCONTINUITY markers
|
||||
*/
|
||||
export function parseBlocks(lines: string[]): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
let currentBlock: Block = {
|
||||
segments: [],
|
||||
startLineIndex: 0,
|
||||
endLineIndex: 0,
|
||||
hasCueTag: false
|
||||
hasCueTag: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index].trim();
|
||||
|
||||
// Check for CUE tags
|
||||
if (line.startsWith('#EXT-X-CUE-OUT') || line.startsWith('#EXT-X-CUE-IN')) {
|
||||
currentBlock.hasCueTag = true;
|
||||
}
|
||||
|
||||
// DISCONTINUITY marks block boundary
|
||||
if (line === '#EXT-X-DISCONTINUITY') {
|
||||
if (currentBlock.segments.length > 0) {
|
||||
currentBlock.endLineIndex = i - 1;
|
||||
currentBlock.endLineIndex = index - 1;
|
||||
blocks.push(currentBlock);
|
||||
}
|
||||
currentBlock = {
|
||||
segments: [],
|
||||
startLineIndex: i + 1,
|
||||
startLineIndex: index + 1,
|
||||
endLineIndex: 0,
|
||||
hasCueTag: false
|
||||
hasCueTag: false,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse EXTINF and the following URL
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
const durationMatch = line.match(/#EXTINF:([\d.]+)/);
|
||||
const duration = durationMatch ? parseFloat(durationMatch[1]) : 0;
|
||||
if (!line.startsWith('#EXTINF:')) continue;
|
||||
|
||||
// Next line should be the URL
|
||||
if (i + 1 < lines.length) {
|
||||
const url = lines[i + 1].trim();
|
||||
if (url && !url.startsWith('#')) {
|
||||
currentBlock.segments.push({
|
||||
url,
|
||||
duration,
|
||||
lineIndex: i + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
const durationMatch = line.match(/#EXTINF:([\d.]+)/);
|
||||
const duration = durationMatch ? Number.parseFloat(durationMatch[1]) : 0;
|
||||
const url = lines[index + 1]?.trim();
|
||||
if (url && !url.startsWith('#')) {
|
||||
currentBlock.segments.push({
|
||||
url,
|
||||
duration,
|
||||
lineIndex: index + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last block
|
||||
if (currentBlock.segments.length > 0) {
|
||||
currentBlock.endLineIndex = lines.length - 1;
|
||||
blocks.push(currentBlock);
|
||||
@@ -104,315 +61,3 @@ export function parseBlocks(lines: string[]): Block[] {
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap proxied URL to get original URL
|
||||
*/
|
||||
function unwrapProxyUrl(url: string): string {
|
||||
if (url.includes('/api/proxy?url=')) {
|
||||
try {
|
||||
const match = url.match(/[?&]url=([^&]+)/);
|
||||
if (match && match[1]) {
|
||||
return decodeURIComponent(match[1]);
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from URL (handles both relative and absolute URLs)
|
||||
*/
|
||||
function extractFilename(url: string): string {
|
||||
try {
|
||||
const unwrappedUrl = unwrapProxyUrl(url);
|
||||
const path = unwrappedUrl.includes('://') ? new URL(unwrappedUrl).pathname : unwrappedUrl;
|
||||
const parts = path.split('/');
|
||||
return parts[parts.length - 1] || '';
|
||||
} catch {
|
||||
return url.split('/').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find common prefix among an array of strings
|
||||
*/
|
||||
function findCommonPrefix(strings: string[]): string {
|
||||
if (!strings || strings.length < 2) return '';
|
||||
|
||||
let prefix = '';
|
||||
const first = strings[0];
|
||||
|
||||
for (let i = 0; i < first.length; i++) {
|
||||
const char = first[i];
|
||||
if (strings.every(s => s[i] === char)) {
|
||||
prefix += char;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path prefix (directory) from URL
|
||||
* e.g., "/20230907/73PWifvT/1392kb/hls/" from "/20230907/73PWifvT/1392kb/hls/gFE6lwIk.ts"
|
||||
*/
|
||||
function extractPathPrefix(url: string): string {
|
||||
try {
|
||||
const unwrappedUrl = unwrapProxyUrl(url);
|
||||
const path = unwrappedUrl.includes('://') ? new URL(unwrappedUrl).pathname : unwrappedUrl;
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? path.substring(0, lastSlash + 1) : '';
|
||||
} catch {
|
||||
const lastSlash = url.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? url.substring(0, lastSlash + 1) : '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Learn pattern from the largest block (assumed to be main content)
|
||||
*/
|
||||
export function learnMainPattern(blocks: Block[]): MainPattern {
|
||||
// Find the largest block by segment count (likely main content)
|
||||
const mainBlock = blocks.length > 0 ? blocks.reduce((largest, block) =>
|
||||
block.segments.length > largest.segments.length ? block : largest
|
||||
) : null;
|
||||
|
||||
if (!mainBlock || mainBlock.segments.length === 0) {
|
||||
return { filenameRegex: null, avgDuration: 0, commonPrefix: '', pathPrefix: '', isGlobalNTSC: false, isGlobal24fps: false };
|
||||
}
|
||||
|
||||
// Extract filenames
|
||||
const filenames = mainBlock.segments.map(s => extractFilename(s.url));
|
||||
|
||||
// Find common prefix
|
||||
const commonPrefix = findCommonPrefix(filenames);
|
||||
|
||||
// Calculate average duration
|
||||
const totalDuration = mainBlock.segments.reduce((sum, s) => sum + s.duration, 0);
|
||||
const avgDuration = totalDuration / mainBlock.segments.length;
|
||||
|
||||
// Try to build a regex pattern from the filenames
|
||||
// Common patterns: "0000001.ts", "seg-1.ts", "segment_001.ts"
|
||||
let filenameRegex: RegExp | null = null;
|
||||
if (commonPrefix.length >= 2) {
|
||||
// Escape special regex characters in prefix
|
||||
const escapedPrefix = commonPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
filenameRegex = new RegExp(`^${escapedPrefix}`);
|
||||
}
|
||||
|
||||
// Extract path prefix (directory path without filename)
|
||||
// e.g., "/20230907/73PWifvT/1392kb/hls/" from "/20230907/73PWifvT/1392kb/hls/gFE6lwIk.ts"
|
||||
const firstUrl = mainBlock.segments[0].url;
|
||||
const pathPrefix = extractPathPrefix(firstUrl);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find blocks that share an identical sequence of segment durations (fingerprint)
|
||||
* with another block in the playlist.
|
||||
*
|
||||
* If a block (with >= 3 segments) has an identical duration signature
|
||||
* as another block in the playlist, and it is not the main content block,
|
||||
* it is extremely likely to be a repeated inserted ad block.
|
||||
*/
|
||||
export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set<number> {
|
||||
const duplicateIndices = new Set<number>();
|
||||
if (!blocks || blocks.length < 2) return duplicateIndices;
|
||||
|
||||
// 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;
|
||||
mainBlockIndex = idx;
|
||||
}
|
||||
});
|
||||
|
||||
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[]>();
|
||||
|
||||
blocks.forEach((block, idx) => {
|
||||
// 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(',');
|
||||
|
||||
const existing = signatureMap.get(signature) || [];
|
||||
existing.push(idx);
|
||||
signatureMap.set(signature, existing);
|
||||
});
|
||||
|
||||
// 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 && indices.length <= maxAllowedAdOccurrences) {
|
||||
indices.forEach(idx => {
|
||||
if (idx !== mainBlockIndex) {
|
||||
duplicateIndices.add(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return duplicateIndices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a block for ad likelihood based on heuristics
|
||||
* Returns a score where higher = more likely to be an ad
|
||||
*/
|
||||
export function scoreBlock(
|
||||
block: Block,
|
||||
mainPattern: MainPattern,
|
||||
extraKeywords: string[] = [],
|
||||
isDuplicateSignature: boolean = false
|
||||
): number {
|
||||
let score = 0;
|
||||
|
||||
// If block has CUE tag or matches a duplicate signature, it's definitely an ad
|
||||
if (block.hasCueTag || isDuplicateSignature) {
|
||||
return 10; // Max score
|
||||
}
|
||||
|
||||
// Check path keywords (Built-in + Custom)
|
||||
// We filter out very short custom keywords to avoid false positives in scoring
|
||||
const safeExtraKeywords = extraKeywords.filter(k => k.length > 2);
|
||||
const allKeywords = [...AD_PATH_KEYWORDS, ...safeExtraKeywords];
|
||||
|
||||
for (const segment of block.segments) {
|
||||
const urlLower = segment.url.toLowerCase();
|
||||
for (const keyword of allKeywords) {
|
||||
if (urlLower.includes(keyword.toLowerCase())) {
|
||||
score += 2.5;
|
||||
break; // Only count once per segment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check filename pattern mismatch
|
||||
if (mainPattern.filenameRegex) {
|
||||
const mismatchCount = block.segments.filter(s => {
|
||||
if (!mainPattern.filenameRegex) return false; // No pattern to compare against
|
||||
const filename = extractFilename(s.url);
|
||||
return !mainPattern.filenameRegex.test(filename);
|
||||
}).length;
|
||||
|
||||
if (mismatchCount === block.segments.length && block.segments.length > 0) {
|
||||
score += 1.5; // All filenames differ from main pattern
|
||||
}
|
||||
}
|
||||
|
||||
// **KEY FEATURE**: Check path prefix mismatch (e.g., different date/folder/bitrate)
|
||||
// This is the most reliable indicator for ads that come from different CDN paths
|
||||
if (mainPattern.pathPrefix !== undefined && block.segments.length > 0) {
|
||||
const pathMismatchCount = block.segments.filter(s => {
|
||||
const segmentPathPrefix = extractPathPrefix(s.url);
|
||||
return segmentPathPrefix !== mainPattern.pathPrefix;
|
||||
}).length;
|
||||
|
||||
if (pathMismatchCount === block.segments.length) {
|
||||
// ALL segments have different path prefix - strong ad indicator
|
||||
score += 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
// **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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold configuration
|
||||
*/
|
||||
export const THRESHOLDS = {
|
||||
HIGH: 5.0, // Definitely an ad
|
||||
LOW: 3.0 // Possibly an ad (for future "fuzzy" mode)
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if a block should be filtered based on its score
|
||||
*/
|
||||
export function shouldFilterBlock(score: number, threshold: number = THRESHOLDS.HIGH): boolean {
|
||||
return score >= threshold;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
hasDominantFilm24LikeGrid,
|
||||
hasDominantNtscLikeGrid,
|
||||
} from './m3u8-duration-grid';
|
||||
import type { Block, MainPattern, SegmentLocation } from './m3u8-ad-types';
|
||||
|
||||
function unwrapProxyUrl(url: string): string {
|
||||
if (!url.includes('/api/proxy?url=')) return url;
|
||||
|
||||
try {
|
||||
const match = url.match(/[?&]url=([^&]+)/);
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractFilename(url: string): string {
|
||||
try {
|
||||
const unwrappedUrl = unwrapProxyUrl(url);
|
||||
const path = unwrappedUrl.includes('://')
|
||||
? new URL(unwrappedUrl).pathname
|
||||
: unwrappedUrl;
|
||||
return path.split('/').pop() || '';
|
||||
} catch {
|
||||
return url.split('/').pop() || '';
|
||||
}
|
||||
}
|
||||
|
||||
function findCommonPrefix(strings: string[]): string {
|
||||
if (strings.length < 2) return '';
|
||||
|
||||
let prefix = '';
|
||||
for (let index = 0; index < strings[0].length; index += 1) {
|
||||
const character = strings[0][index];
|
||||
if (!strings.every(value => value[index] === character)) break;
|
||||
prefix += character;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
export function extractSegmentLocation(url: string): SegmentLocation {
|
||||
try {
|
||||
const unwrappedUrl = unwrapProxyUrl(url);
|
||||
const parsedUrl = unwrappedUrl.includes('://') ? new URL(unwrappedUrl) : null;
|
||||
const path = parsedUrl?.pathname || unwrappedUrl;
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
return {
|
||||
origin: parsedUrl?.origin || '',
|
||||
pathPrefix: lastSlash >= 0 ? path.substring(0, lastSlash + 1) : '',
|
||||
};
|
||||
} catch {
|
||||
const lastSlash = url.lastIndexOf('/');
|
||||
return {
|
||||
origin: '',
|
||||
pathPrefix: lastSlash >= 0 ? url.substring(0, lastSlash + 1) : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function learnMainPattern(blocks: Block[]): MainPattern {
|
||||
const mainBlock = blocks.length > 0
|
||||
? blocks.reduce((largest, block) => (
|
||||
block.segments.length > largest.segments.length ? block : largest
|
||||
))
|
||||
: null;
|
||||
|
||||
if (!mainBlock || mainBlock.segments.length === 0) {
|
||||
return {
|
||||
filenameRegex: null,
|
||||
avgDuration: 0,
|
||||
commonPrefix: '',
|
||||
pathPrefix: '',
|
||||
origin: '',
|
||||
usesNtscLikeGrid: false,
|
||||
usesFilm24LikeGrid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const filenames = mainBlock.segments.map(segment => extractFilename(segment.url));
|
||||
const commonPrefix = findCommonPrefix(filenames);
|
||||
const totalDuration = mainBlock.segments.reduce((sum, segment) => (
|
||||
sum + segment.duration
|
||||
), 0);
|
||||
const avgDuration = totalDuration / mainBlock.segments.length;
|
||||
const escapedPrefix = commonPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const filenameRegex = commonPrefix.length >= 2
|
||||
? new RegExp(`^${escapedPrefix}`)
|
||||
: null;
|
||||
const location = extractSegmentLocation(mainBlock.segments[0].url);
|
||||
|
||||
return {
|
||||
filenameRegex,
|
||||
avgDuration,
|
||||
commonPrefix,
|
||||
...location,
|
||||
usesNtscLikeGrid: hasDominantNtscLikeGrid(mainBlock.segments),
|
||||
usesFilm24LikeGrid: hasDominantFilm24LikeGrid(mainBlock.segments),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
isSmallIntegerDurationBlock,
|
||||
isSmallNtscLikeBlock,
|
||||
} from './m3u8-duration-grid';
|
||||
import { extractFilename, extractSegmentLocation } from './m3u8-ad-pattern';
|
||||
import type { Block, MainPattern } from './m3u8-ad-types';
|
||||
|
||||
export const AD_PATH_KEYWORDS = [
|
||||
'advert', 'preroll', 'midroll', 'postroll',
|
||||
'dai', 'vast', 'ima', 'adjump', 'commercial', 'sponsor',
|
||||
];
|
||||
|
||||
function scoreKeywords(block: Block, extraKeywords: string[]): number {
|
||||
const keywords = [
|
||||
...AD_PATH_KEYWORDS,
|
||||
...extraKeywords.filter(keyword => keyword.length > 2),
|
||||
];
|
||||
|
||||
return block.segments.reduce((score, segment) => {
|
||||
const url = segment.url.toLowerCase();
|
||||
return keywords.some(keyword => url.includes(keyword.toLowerCase()))
|
||||
? score + 2.5
|
||||
: score;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function scoreFilenameMismatch(block: Block, mainPattern: MainPattern): number {
|
||||
if (!mainPattern.filenameRegex || block.segments.length === 0) return 0;
|
||||
|
||||
const allMismatch = block.segments.every(segment => (
|
||||
!mainPattern.filenameRegex?.test(extractFilename(segment.url))
|
||||
));
|
||||
return allMismatch ? 1.5 : 0;
|
||||
}
|
||||
|
||||
function scoreLocationMismatch(block: Block, mainPattern: MainPattern): number {
|
||||
if ((!mainPattern.pathPrefix && !mainPattern.origin) || block.segments.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const locations = block.segments.map(segment => extractSegmentLocation(segment.url));
|
||||
const allPathsMismatch = locations.every(location => (
|
||||
location.pathPrefix !== mainPattern.pathPrefix
|
||||
));
|
||||
if (allPathsMismatch) return 5;
|
||||
|
||||
const allOriginsMismatch = locations.every(location => (
|
||||
Boolean(mainPattern.origin && location.origin) && location.origin !== mainPattern.origin
|
||||
));
|
||||
return allOriginsMismatch ? 3.5 : 0;
|
||||
}
|
||||
|
||||
function scoreDurationMismatch(block: Block, mainPattern: MainPattern): number {
|
||||
if (mainPattern.avgDuration <= 0 || block.segments.length === 0 || block.segments.length > 6) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const blockAverage = block.segments.reduce((sum, segment) => (
|
||||
sum + segment.duration
|
||||
), 0) / block.segments.length;
|
||||
const durationRatio = blockAverage / mainPattern.avgDuration;
|
||||
return durationRatio < 0.6 || durationRatio > 1.8 ? 1.5 : 0;
|
||||
}
|
||||
|
||||
function scoreDurationGrid(block: Block, mainPattern: MainPattern): number {
|
||||
if (!mainPattern.usesNtscLikeGrid && isSmallNtscLikeBlock(block.segments)) {
|
||||
return 3.5;
|
||||
}
|
||||
if (mainPattern.usesFilm24LikeGrid && isSmallIntegerDurationBlock(block.segments)) {
|
||||
return 3.5;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function scoreBlock(
|
||||
block: Block,
|
||||
mainPattern: MainPattern,
|
||||
extraKeywords: string[] = [],
|
||||
isDuplicateSignature: boolean = false,
|
||||
): number {
|
||||
if (block.hasCueTag || isDuplicateSignature) return 10;
|
||||
|
||||
return scoreKeywords(block, extraKeywords) +
|
||||
scoreFilenameMismatch(block, mainPattern) +
|
||||
scoreLocationMismatch(block, mainPattern) +
|
||||
scoreDurationMismatch(block, mainPattern) +
|
||||
scoreDurationGrid(block, mainPattern);
|
||||
}
|
||||
|
||||
export const THRESHOLDS = {
|
||||
HIGH: 5,
|
||||
LOW: 3,
|
||||
};
|
||||
|
||||
export function shouldFilterBlock(
|
||||
score: number,
|
||||
threshold: number = THRESHOLDS.HIGH,
|
||||
): boolean {
|
||||
return score >= threshold;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Block } from './m3u8-ad-types';
|
||||
|
||||
export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set<number> {
|
||||
const duplicateIndices = new Set<number>();
|
||||
if (blocks.length < 2) return duplicateIndices;
|
||||
|
||||
let mainBlockIndex = -1;
|
||||
let maxSegments = 0;
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.segments.length > maxSegments) {
|
||||
maxSegments = block.segments.length;
|
||||
mainBlockIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
const mainSegments = mainBlockIndex >= 0 ? blocks[mainBlockIndex].segments : [];
|
||||
const mainAvgDuration = mainSegments.length > 0
|
||||
? mainSegments.reduce((sum, segment) => sum + segment.duration, 0) / mainSegments.length
|
||||
: 0;
|
||||
const signatureMap = new Map<string, number[]>();
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.segments.length < 3) return;
|
||||
|
||||
const firstDuration = block.segments[0].duration;
|
||||
const isUniform = block.segments.every(segment => (
|
||||
Math.abs(segment.duration - firstDuration) < 0.005
|
||||
));
|
||||
const matchesMainDuration = mainAvgDuration > 0 &&
|
||||
Math.abs(firstDuration - mainAvgDuration) < 0.05;
|
||||
if (isUniform && matchesMainDuration) return;
|
||||
|
||||
const signature = block.segments
|
||||
.map(segment => segment.duration.toFixed(3))
|
||||
.join(',');
|
||||
const matchingBlocks = signatureMap.get(signature) || [];
|
||||
matchingBlocks.push(index);
|
||||
signatureMap.set(signature, matchingBlocks);
|
||||
});
|
||||
|
||||
const maxOccurrences = Math.max(2, Math.floor(blocks.length * 0.3));
|
||||
signatureMap.forEach((indices) => {
|
||||
if (indices.length < 2 || indices.length > maxOccurrences) return;
|
||||
|
||||
indices.forEach((index) => {
|
||||
const isClearlySmallerThanMain = blocks[index].segments.length < maxSegments * 0.8;
|
||||
if (index !== mainBlockIndex && isClearlySmallerThanMain) {
|
||||
duplicateIndices.add(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return duplicateIndices;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface Segment {
|
||||
url: string;
|
||||
duration: number;
|
||||
lineIndex: number;
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
segments: Segment[];
|
||||
startLineIndex: number;
|
||||
endLineIndex: number;
|
||||
hasCueTag: boolean;
|
||||
}
|
||||
|
||||
export interface MainPattern {
|
||||
filenameRegex: RegExp | null;
|
||||
avgDuration: number;
|
||||
commonPrefix: string;
|
||||
pathPrefix: string;
|
||||
origin: string;
|
||||
usesNtscLikeGrid: boolean;
|
||||
usesFilm24LikeGrid: boolean;
|
||||
}
|
||||
|
||||
export interface SegmentLocation {
|
||||
origin: string;
|
||||
pathPrefix: string;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
interface DurationSample {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const NTSC_LIKE_MILLISECOND_FRACTIONS = new Set([
|
||||
33, 67, 133, 167, 233, 267, 333, 367, 433, 467,
|
||||
533, 567, 633, 667, 733, 767, 833, 867, 933, 967,
|
||||
]);
|
||||
|
||||
const FILM_24_LIKE_MILLISECOND_FRACTIONS = new Set([
|
||||
2, 4, 6, 8, 12, 16, 20, 24,
|
||||
]);
|
||||
|
||||
function millisecondFraction(duration: number): number {
|
||||
const fractionalSeconds = Math.abs(duration - Math.trunc(duration));
|
||||
return Math.round(fractionalSeconds * 1000) % 1000;
|
||||
}
|
||||
|
||||
function matchingRatio(samples: DurationSample[], fractions: Set<number>): number {
|
||||
if (samples.length === 0) return 0;
|
||||
|
||||
const matches = samples.reduce((count, sample) => (
|
||||
fractions.has(millisecondFraction(sample.duration)) ? count + 1 : count
|
||||
), 0);
|
||||
|
||||
return matches / samples.length;
|
||||
}
|
||||
|
||||
export function hasDominantNtscLikeGrid(samples: DurationSample[]): boolean {
|
||||
return samples.length >= 4 &&
|
||||
matchingRatio(samples, NTSC_LIKE_MILLISECOND_FRACTIONS) > 0.3;
|
||||
}
|
||||
|
||||
export function hasDominantFilm24LikeGrid(samples: DurationSample[]): boolean {
|
||||
return samples.length >= 4 &&
|
||||
matchingRatio(samples, FILM_24_LIKE_MILLISECOND_FRACTIONS) > 0.35;
|
||||
}
|
||||
|
||||
export function isSmallNtscLikeBlock(samples: DurationSample[]): boolean {
|
||||
return samples.length >= 2 &&
|
||||
samples.length <= 10 &&
|
||||
matchingRatio(samples, NTSC_LIKE_MILLISECOND_FRACTIONS) >= 0.8;
|
||||
}
|
||||
|
||||
export function isSmallIntegerDurationBlock(samples: DurationSample[]): boolean {
|
||||
if (samples.length < 2 || samples.length > 10) return false;
|
||||
|
||||
const integerDurations = samples.reduce((count, sample) => (
|
||||
millisecondFraction(sample.duration) === 0 ? count + 1 : count
|
||||
), 0);
|
||||
|
||||
return integerDurations >= 2 && integerDurations / samples.length >= 0.75;
|
||||
}
|
||||
+4
-45
@@ -81,58 +81,17 @@ 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[] = [],
|
||||
customCode?: string
|
||||
customKeywords: 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);
|
||||
|
||||
@@ -144,14 +103,14 @@ export function filterM3u8Ad(
|
||||
if (urlMatch && urlMatch[1]) {
|
||||
effectiveBaseUrl = decodeURIComponent(urlMatch[1]);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const basePath = effectiveBaseUrl.substring(0, effectiveBaseUrl.lastIndexOf('/') + 1);
|
||||
let origin = '';
|
||||
try {
|
||||
origin = new URL(effectiveBaseUrl).origin;
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// 2. Global Scan: Check if any ad keywords exist in the content
|
||||
const hasKeywordMatchInPlaylist = hasKeywordMatch(content, normalizedKeywords);
|
||||
@@ -159,7 +118,7 @@ export function filterM3u8Ad(
|
||||
|
||||
// 3. Heuristic Analysis: If no explicit ad signals, use block-based detection
|
||||
const lines = content.split(/\r?\n/);
|
||||
let adLineIndices = new Set<number>();
|
||||
const adLineIndices = new Set<number>();
|
||||
|
||||
if (!hasCueTag && (mode === 'heuristic' || mode === 'aggressive')) {
|
||||
// No obvious ad signals - run heuristic analysis
|
||||
|
||||
Generated
+96
-96
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.9.16",
|
||||
"version": "4.9.17",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.9.16",
|
||||
"version": "4.9.17",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -15,7 +15,7 @@
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.2.10",
|
||||
"next": "16.2.12",
|
||||
"opencc-js": "^1.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -29,7 +29,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"esbuild": "^0.27.7",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"eslint-config-next": "16.2.12",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-env": "^11.2.0",
|
||||
"tailwindcss": "^4",
|
||||
@@ -3278,15 +3278,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
|
||||
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz",
|
||||
"integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz",
|
||||
"integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz",
|
||||
"integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3294,9 +3294,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
|
||||
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz",
|
||||
"integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3310,9 +3310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
|
||||
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz",
|
||||
"integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3326,9 +3326,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
|
||||
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz",
|
||||
"integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3345,9 +3345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
|
||||
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz",
|
||||
"integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3364,9 +3364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
|
||||
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz",
|
||||
"integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3383,9 +3383,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
|
||||
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz",
|
||||
"integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3402,9 +3402,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
|
||||
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz",
|
||||
"integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3418,9 +3418,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
|
||||
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz",
|
||||
"integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -7517,13 +7517,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz",
|
||||
"integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz",
|
||||
"integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.2.10",
|
||||
"@next/eslint-plugin-next": "16.2.12",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -10010,12 +10010,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
|
||||
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz",
|
||||
"integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.2.10",
|
||||
"@next/env": "16.2.12",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -10029,14 +10029,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.2.10",
|
||||
"@next/swc-darwin-x64": "16.2.10",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.10",
|
||||
"@next/swc-linux-arm64-musl": "16.2.10",
|
||||
"@next/swc-linux-x64-gnu": "16.2.10",
|
||||
"@next/swc-linux-x64-musl": "16.2.10",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.10",
|
||||
"@next/swc-win32-x64-msvc": "16.2.10",
|
||||
"@next/swc-darwin-arm64": "16.2.12",
|
||||
"@next/swc-darwin-x64": "16.2.12",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.12",
|
||||
"@next/swc-linux-arm64-musl": "16.2.12",
|
||||
"@next/swc-linux-x64-gnu": "16.2.12",
|
||||
"@next/swc-linux-x64-musl": "16.2.12",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.12",
|
||||
"@next/swc-win32-x64-msvc": "16.2.12",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -14904,65 +14904,65 @@
|
||||
}
|
||||
},
|
||||
"@next/env": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
|
||||
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA=="
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz",
|
||||
"integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="
|
||||
},
|
||||
"@next/eslint-plugin-next": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz",
|
||||
"integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz",
|
||||
"integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fast-glob": "3.3.1"
|
||||
}
|
||||
},
|
||||
"@next/swc-darwin-arm64": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
|
||||
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz",
|
||||
"integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-darwin-x64": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
|
||||
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz",
|
||||
"integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
|
||||
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz",
|
||||
"integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-arm64-musl": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
|
||||
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz",
|
||||
"integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-x64-gnu": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
|
||||
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz",
|
||||
"integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-linux-x64-musl": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
|
||||
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz",
|
||||
"integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
|
||||
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz",
|
||||
"integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==",
|
||||
"optional": true
|
||||
},
|
||||
"@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
|
||||
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz",
|
||||
"integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==",
|
||||
"optional": true
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
@@ -17512,12 +17512,12 @@
|
||||
}
|
||||
},
|
||||
"eslint-config-next": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz",
|
||||
"integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz",
|
||||
"integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@next/eslint-plugin-next": "16.2.10",
|
||||
"@next/eslint-plugin-next": "16.2.12",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -19115,19 +19115,19 @@
|
||||
"dev": true
|
||||
},
|
||||
"next": {
|
||||
"version": "16.2.10",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
|
||||
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
|
||||
"version": "16.2.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz",
|
||||
"integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==",
|
||||
"requires": {
|
||||
"@next/env": "16.2.10",
|
||||
"@next/swc-darwin-arm64": "16.2.10",
|
||||
"@next/swc-darwin-x64": "16.2.10",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.10",
|
||||
"@next/swc-linux-arm64-musl": "16.2.10",
|
||||
"@next/swc-linux-x64-gnu": "16.2.10",
|
||||
"@next/swc-linux-x64-musl": "16.2.10",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.10",
|
||||
"@next/swc-win32-x64-msvc": "16.2.10",
|
||||
"@next/env": "16.2.12",
|
||||
"@next/swc-darwin-arm64": "16.2.12",
|
||||
"@next/swc-darwin-x64": "16.2.12",
|
||||
"@next/swc-linux-arm64-gnu": "16.2.12",
|
||||
"@next/swc-linux-arm64-musl": "16.2.12",
|
||||
"@next/swc-linux-x64-gnu": "16.2.12",
|
||||
"@next/swc-linux-x64-musl": "16.2.12",
|
||||
"@next/swc-win32-arm64-msvc": "16.2.12",
|
||||
"@next/swc-win32-x64-msvc": "16.2.12",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.9.16",
|
||||
"version": "4.9.17",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/next-with-lan-access.mjs dev",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.2.10",
|
||||
"next": "16.2.12",
|
||||
"opencc-js": "^1.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"esbuild": "^0.27.7",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"eslint-config-next": "16.2.12",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-env": "^11.2.0",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
@@ -80,167 +80,3 @@ test('filterM3u8Ad still strips interstitial DATERANGE metadata', () => {
|
||||
assert.equal(filtered.includes('com.apple.hls.interstitial'), false);
|
||||
assert.equal(filtered.includes('seg-0.ts'), true);
|
||||
});
|
||||
|
||||
test('filterM3u8Ad executes valid custom TS/JS script in sandbox', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:5.000,',
|
||||
'https://cdn.example.com/content/ad-segment.ts',
|
||||
'#EXTINF:10.000,',
|
||||
'https://cdn.example.com/content/main-segment.ts',
|
||||
].join('\n');
|
||||
|
||||
const customScript = `
|
||||
function filterAdsFromM3U8(content: string, baseUrl: string): string {
|
||||
return content.split('\\n').filter(line => !line.includes('ad-segment.ts')).join('\\n');
|
||||
}
|
||||
`;
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic', [], customScript);
|
||||
assert.equal(filtered.includes('ad-segment.ts'), false);
|
||||
assert.equal(filtered.includes('main-segment.ts'), true);
|
||||
});
|
||||
|
||||
test('filterM3u8Ad gracefully falls back to built-in rules when custom script throws runtime error', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:5.000,',
|
||||
'https://cdn.example.com/content/sponsor-ad.ts',
|
||||
'#EXTINF:10.000,',
|
||||
'https://cdn.example.com/content/main-segment.ts',
|
||||
].join('\n');
|
||||
|
||||
const brokenScript = `
|
||||
function filterAdsFromM3U8(content: string): string {
|
||||
throw new Error("Syntax error or intentional bug");
|
||||
}
|
||||
`;
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic', ['sponsor'], brokenScript);
|
||||
assert.equal(filtered.includes('sponsor-ad.ts'), false);
|
||||
assert.equal(filtered.includes('main-segment.ts'), true);
|
||||
});
|
||||
|
||||
test('filterM3u8Ad detects and strips 30fps/NTSC framerate fraction anomaly inserted ad blocks', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-0.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-1.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-2.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-3.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-4.ts',
|
||||
'#EXT-X-DISCONTINUITY',
|
||||
'#EXTINF:4.867,',
|
||||
'https://cdn.example.com/content/ad-0.ts',
|
||||
'#EXTINF:3.333,',
|
||||
'https://cdn.example.com/content/ad-1.ts',
|
||||
'#EXT-X-DISCONTINUITY',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-5.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-6.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/content/main-7.ts',
|
||||
].join('\n');
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic');
|
||||
assert.equal(filtered.includes('ad-0.ts'), false);
|
||||
assert.equal(filtered.includes('ad-1.ts'), false);
|
||||
assert.equal(filtered.includes('main-0.ts'), true);
|
||||
assert.equal(filtered.includes('main-7.ts'), true);
|
||||
});
|
||||
|
||||
test('filterM3u8Ad detects and strips Multi-CDN domain/path prefix mismatch ad blocks', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:6.000,',
|
||||
'https://media-cdn.video.com/2026/ep01/hls/seg-0.ts',
|
||||
'#EXTINF:6.000,',
|
||||
'https://media-cdn.video.com/2026/ep01/hls/seg-1.ts',
|
||||
'#EXTINF:6.000,',
|
||||
'https://media-cdn.video.com/2026/ep01/hls/seg-2.ts',
|
||||
'#EXT-X-DISCONTINUITY',
|
||||
'#EXTINF:15.000,',
|
||||
'https://ad-server.net/campaign/preroll/ad-0.ts',
|
||||
'#EXTINF:15.000,',
|
||||
'https://ad-server.net/campaign/preroll/ad-1.ts',
|
||||
'#EXT-X-DISCONTINUITY',
|
||||
'#EXTINF:6.000,',
|
||||
'https://media-cdn.video.com/2026/ep01/hls/seg-3.ts',
|
||||
'#EXTINF:6.000,',
|
||||
'https://media-cdn.video.com/2026/ep01/hls/seg-4.ts',
|
||||
].join('\n');
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://media-cdn.video.com/2026/ep01/hls/index.m3u8', 'heuristic');
|
||||
assert.equal(filtered.includes('ad-server.net'), false);
|
||||
assert.equal(filtered.includes('seg-0.ts'), true);
|
||||
assert.equal(filtered.includes('seg-4.ts'), true);
|
||||
});
|
||||
|
||||
test('filterM3u8Ad protects global 30fps/NTSC feature film streams with 0 false positives', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:4.033,',
|
||||
'https://cdn.example.com/movie/seg-0.ts',
|
||||
'#EXTINF:4.867,',
|
||||
'https://cdn.example.com/movie/seg-1.ts',
|
||||
'#EXTINF:3.333,',
|
||||
'https://cdn.example.com/movie/seg-2.ts',
|
||||
'#EXTINF:4.167,',
|
||||
'https://cdn.example.com/movie/seg-3.ts',
|
||||
'#EXT-X-DISCONTINUITY',
|
||||
'#EXTINF:4.033,',
|
||||
'https://cdn.example.com/movie/seg-4.ts',
|
||||
'#EXTINF:4.867,',
|
||||
'https://cdn.example.com/movie/seg-5.ts',
|
||||
'#EXTINF:3.333,',
|
||||
'https://cdn.example.com/movie/seg-6.ts',
|
||||
].join('\n');
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/movie/index.m3u8', 'heuristic');
|
||||
assert.equal(filtered.includes('seg-0.ts'), true);
|
||||
assert.equal(filtered.includes('seg-3.ts'), true);
|
||||
assert.equal(filtered.includes('seg-6.ts'), true);
|
||||
});
|
||||
|
||||
test('filterM3u8Ad detects and strips 25fps integer duration inserted ad blocks in 24fps movie streams', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:4.004,',
|
||||
'https://cdn.example.com/movie/seg-0.ts',
|
||||
'#EXTINF:4.004,',
|
||||
'https://cdn.example.com/movie/seg-1.ts',
|
||||
'#EXTINF:4.004,',
|
||||
'https://cdn.example.com/movie/seg-2.ts',
|
||||
'#EXTINF:4.004,',
|
||||
'https://cdn.example.com/movie/seg-3.ts',
|
||||
'#EXT-X-DISCONTINUITY',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/movie/ad-0.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/movie/ad-1.ts',
|
||||
'#EXTINF:4.000,',
|
||||
'https://cdn.example.com/movie/ad-2.ts',
|
||||
'#EXTINF:0.560,',
|
||||
'https://cdn.example.com/movie/ad-3.ts',
|
||||
'#EXT-X-DISCONTINUITY',
|
||||
'#EXTINF:4.004,',
|
||||
'https://cdn.example.com/movie/seg-4.ts',
|
||||
'#EXTINF:4.004,',
|
||||
'https://cdn.example.com/movie/seg-5.ts',
|
||||
].join('\n');
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/movie/index.m3u8', 'heuristic');
|
||||
assert.equal(filtered.includes('ad-0.ts'), false);
|
||||
assert.equal(filtered.includes('ad-3.ts'), false);
|
||||
assert.equal(filtered.includes('seg-0.ts'), true);
|
||||
assert.equal(filtered.includes('seg-5.ts'), true);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { filterM3u8Ad } from '../lib/utils/m3u8-utils';
|
||||
|
||||
interface PlaylistBlock {
|
||||
durations: number[];
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
function buildPlaylist(blocks: PlaylistBlock[]): string {
|
||||
const lines = ['#EXTM3U'];
|
||||
blocks.forEach((block, blockIndex) => {
|
||||
if (blockIndex > 0) lines.push('#EXT-X-DISCONTINUITY');
|
||||
block.durations.forEach((duration, segmentIndex) => {
|
||||
lines.push(`#EXTINF:${duration.toFixed(3)},`);
|
||||
lines.push(`https://cdn.example.com/content/${block.prefix}-${segmentIndex}.ts`);
|
||||
});
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
test('NTSC-like duration residues remove a small block only with another signal', () => {
|
||||
const playlist = buildPlaylist([
|
||||
{ durations: [4, 4, 4, 4, 4], prefix: 'main-a' },
|
||||
{ durations: [4.867, 3.333], prefix: 'ad' },
|
||||
{ durations: [4, 4, 4], prefix: 'main-b' },
|
||||
]);
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8');
|
||||
assert.equal(filtered.includes('/ad-0.ts'), false);
|
||||
assert.equal(filtered.includes('/main-a-0.ts'), true);
|
||||
assert.equal(filtered.includes('/main-b-2.ts'), true);
|
||||
});
|
||||
|
||||
test('one legitimate NTSC-like segment is not deleted from an integer-duration stream', () => {
|
||||
const playlist = buildPlaylist([
|
||||
{ durations: [4, 4.033, 4, 4, 4], prefix: 'main' },
|
||||
]);
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8');
|
||||
assert.equal(filtered.includes('/main-1.ts'), true);
|
||||
});
|
||||
|
||||
test('a dominant NTSC-like main stream keeps later matching content blocks', () => {
|
||||
const playlist = buildPlaylist([
|
||||
{ durations: [4.033, 4.867, 3.333, 4.167], prefix: 'main-a' },
|
||||
{ durations: [4.033, 4.867, 3.333], prefix: 'main-b' },
|
||||
]);
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8');
|
||||
assert.equal(filtered.includes('/main-a-0.ts'), true);
|
||||
assert.equal(filtered.includes('/main-b-2.ts'), true);
|
||||
});
|
||||
|
||||
test('integer-duration inserts are detected inside a film-24-like stream', () => {
|
||||
const playlist = buildPlaylist([
|
||||
{ durations: [4.004, 4.004, 4.004, 4.004], prefix: 'main-a' },
|
||||
{ durations: [4, 4, 4, 0.56], prefix: 'ad' },
|
||||
{ durations: [4.004, 4.004], prefix: 'main-b' },
|
||||
]);
|
||||
|
||||
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8');
|
||||
assert.equal(filtered.includes('/ad-0.ts'), false);
|
||||
assert.equal(filtered.includes('/ad-3.ts'), false);
|
||||
assert.equal(filtered.includes('/main-b-1.ts'), true);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { filterM3u8Ad } from '../lib/utils/m3u8-utils';
|
||||
|
||||
function addBlock(
|
||||
lines: string[],
|
||||
durations: number[],
|
||||
origin: string,
|
||||
prefix: string,
|
||||
): void {
|
||||
if (lines.length > 1) lines.push('#EXT-X-DISCONTINUITY');
|
||||
durations.forEach((duration, index) => {
|
||||
lines.push(`#EXTINF:${duration.toFixed(3)},`);
|
||||
lines.push(`${origin}/content/${prefix}-${index}.ts`);
|
||||
});
|
||||
}
|
||||
|
||||
test('equal-size repeated content blocks are not treated as duplicate ads', () => {
|
||||
const lines = ['#EXTM3U'];
|
||||
addBlock(lines, [6, 6.1, 5.9], 'https://cdn.example.com', 'chapter-a');
|
||||
addBlock(lines, [6, 6.1, 5.9], 'https://cdn.example.com', 'chapter-b');
|
||||
|
||||
const filtered = filterM3u8Ad(lines.join('\n'), 'https://cdn.example.com/content/index.m3u8');
|
||||
assert.equal(filtered.includes('/chapter-a-0.ts'), true);
|
||||
assert.equal(filtered.includes('/chapter-b-2.ts'), true);
|
||||
});
|
||||
|
||||
test('uniform fixed-GOP content matching the main duration is protected', () => {
|
||||
const lines = ['#EXTM3U'];
|
||||
addBlock(lines, [2, 2, 2], 'https://cdn.example.com', 'chapter-a');
|
||||
addBlock(lines, [2, 2, 2, 2, 2], 'https://cdn.example.com', 'chapter-b');
|
||||
addBlock(lines, [2, 2, 2], 'https://cdn.example.com', 'chapter-c');
|
||||
|
||||
const filtered = filterM3u8Ad(lines.join('\n'), 'https://cdn.example.com/content/index.m3u8');
|
||||
assert.equal(filtered.includes('/chapter-a-0.ts'), true);
|
||||
assert.equal(filtered.includes('/chapter-c-2.ts'), true);
|
||||
});
|
||||
|
||||
test('a same-path cross-origin insert needs a filename mismatch before removal', () => {
|
||||
const lines = ['#EXTM3U'];
|
||||
addBlock(lines, [6, 6, 6, 6], 'https://media.example.com', 'main-a');
|
||||
addBlock(lines, [6, 6], 'https://ads.example.net', 'ad');
|
||||
addBlock(lines, [6, 6], 'https://media.example.com', 'main-b');
|
||||
|
||||
const filtered = filterM3u8Ad(lines.join('\n'), 'https://media.example.com/content/index.m3u8');
|
||||
assert.equal(filtered.includes('ads.example.net'), false);
|
||||
assert.equal(filtered.includes('/main-a-0.ts'), true);
|
||||
assert.equal(filtered.includes('/main-b-1.ts'), true);
|
||||
});
|
||||
|
||||
test('off mode returns the original playlist byte-for-byte', () => {
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-CUE-IN',
|
||||
'#EXTINF:4.000,',
|
||||
'relative/segment.ts',
|
||||
].join('\n');
|
||||
|
||||
assert.equal(filterM3u8Ad(playlist, 'https://cdn.example.com/index.m3u8', 'off'), playlist);
|
||||
});
|
||||
Reference in New Issue
Block a user