mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Merge pull request #222 from Troray/feature/ad-filter-upgrade
Harden M3U8 ad filtering and prevent false positives
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",
|
||||
|
||||
+26
-304
@@ -1,100 +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/")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -102,240 +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: '' };
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
return { filenameRegex, avgDuration, commonPrefix, pathPrefix };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
signatureMap.forEach((indices) => {
|
||||
if (indices.length >= 2) {
|
||||
indices.forEach(idx => {
|
||||
// Ensure we don't accidentally flag the main content block
|
||||
if (idx !== mainBlockIndex && blocks[idx].segments.length < maxSegments * 0.8) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+15
-10
@@ -83,8 +83,14 @@ function isAuxiliaryAdMetadataLine(trimmedLine: string, normalizedKeywords: stri
|
||||
*/
|
||||
export type AdFilterMode = 'off' | 'keyword' | 'heuristic' | 'aggressive';
|
||||
|
||||
export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMode = 'heuristic', customKeywords: string[] = []): string {
|
||||
export function filterM3u8Ad(
|
||||
content: string,
|
||||
baseUrl: string,
|
||||
mode: AdFilterMode = 'heuristic',
|
||||
customKeywords: string[] = []
|
||||
): string {
|
||||
if (!content) return '';
|
||||
if (mode === 'off') return content;
|
||||
|
||||
// Use keywords passed from AdKeywordsWrapper (already loaded from env/file)
|
||||
const normalizedKeywords = normalizeKeywords(customKeywords);
|
||||
@@ -97,22 +103,22 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod
|
||||
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 = mode !== 'off' && hasKeywordMatch(content, normalizedKeywords);
|
||||
const hasCueTag = mode !== 'off' && (content.includes('#EXT-X-CUE-OUT') || content.includes('#EXT-X-CUE-IN'));
|
||||
const hasKeywordMatchInPlaylist = hasKeywordMatch(content, normalizedKeywords);
|
||||
const hasCueTag = content.includes('#EXT-X-CUE-OUT') || content.includes('#EXT-X-CUE-IN');
|
||||
|
||||
// 3. Heuristic Analysis: If no explicit ad signals, use block-based detection
|
||||
const lines = content.split(/\r?\n/);
|
||||
let adLineIndices = new Set<number>();
|
||||
const adLineIndices = new Set<number>();
|
||||
|
||||
if (!hasCueTag && (mode === 'heuristic' || mode === 'aggressive')) {
|
||||
// No obvious ad signals - run heuristic analysis
|
||||
@@ -174,16 +180,15 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod
|
||||
|
||||
// 4. Strip modern HLS interstitial metadata before the player can schedule it.
|
||||
if (
|
||||
mode !== 'off' &&
|
||||
(isInterstitialDateRange(trimmedLine, normalizedKeywords) ||
|
||||
isAuxiliaryAdMetadataLine(trimmedLine, normalizedKeywords))
|
||||
isInterstitialDateRange(trimmedLine, normalizedKeywords) ||
|
||||
isAuxiliaryAdMetadataLine(trimmedLine, normalizedKeywords)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. CUE Tag Detection (SCTE-35 Standard)
|
||||
// EXT-X-CUE-OUT marks start of ad, EXT-X-CUE-IN marks end
|
||||
if (mode !== 'off' && trimmedLine.startsWith('#EXT-X-CUE-OUT')) {
|
||||
if (trimmedLine.startsWith('#EXT-X-CUE-OUT')) {
|
||||
insideCueAdBlock = true;
|
||||
// Remove preceding DISCONTINUITY if present
|
||||
if (processedLines.length > 0 && processedLines[processedLines.length - 1].trim() === '#EXT-X-DISCONTINUITY') {
|
||||
|
||||
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",
|
||||
|
||||
@@ -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