Merge pull request #222 from Troray/feature/ad-filter-upgrade

Harden M3U8 ad filtering and prevent false positives
This commit is contained in:
Kuek Hao Yang
2026-07-29 22:15:20 +08:00
committed by GitHub
13 changed files with 625 additions and 414 deletions
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+100
View File
@@ -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),
};
}
+100
View File
@@ -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;
}
+54
View File
@@ -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;
}
+27
View File
@@ -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;
}
+53
View File
@@ -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
View File
@@ -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') {
+96 -96
View File
@@ -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
View File
@@ -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",
+67
View File
@@ -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);
});
+61
View File
@@ -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);
});