diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f0b12..8cdc954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 修复窗口宽度变化后右侧“历史播放记录”浮动按钮停留在原位置、无法继续贴靠右边缘的问题。 diff --git a/app-release.json b/app-release.json index a35f19b..19e40b1 100644 --- a/app-release.json +++ b/app-release.json @@ -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", diff --git a/lib/utils/m3u8-ad-detector.ts b/lib/utils/m3u8-ad-detector.ts index 0cdf293..f11e353 100644 --- a/lib/utils/m3u8-ad-detector.ts +++ b/lib/utils/m3u8-ad-detector.ts @@ -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 { - const duplicateIndices = new Set(); - 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(); - - 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; -} diff --git a/lib/utils/m3u8-ad-pattern.ts b/lib/utils/m3u8-ad-pattern.ts new file mode 100644 index 0000000..435cdd7 --- /dev/null +++ b/lib/utils/m3u8-ad-pattern.ts @@ -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), + }; +} diff --git a/lib/utils/m3u8-ad-scoring.ts b/lib/utils/m3u8-ad-scoring.ts new file mode 100644 index 0000000..eed5021 --- /dev/null +++ b/lib/utils/m3u8-ad-scoring.ts @@ -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; +} diff --git a/lib/utils/m3u8-ad-signatures.ts b/lib/utils/m3u8-ad-signatures.ts new file mode 100644 index 0000000..685d9ee --- /dev/null +++ b/lib/utils/m3u8-ad-signatures.ts @@ -0,0 +1,54 @@ +import type { Block } from './m3u8-ad-types'; + +export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set { + const duplicateIndices = new Set(); + 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(); + + 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; +} diff --git a/lib/utils/m3u8-ad-types.ts b/lib/utils/m3u8-ad-types.ts new file mode 100644 index 0000000..3f1dfbd --- /dev/null +++ b/lib/utils/m3u8-ad-types.ts @@ -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; +} diff --git a/lib/utils/m3u8-duration-grid.ts b/lib/utils/m3u8-duration-grid.ts new file mode 100644 index 0000000..11253e8 --- /dev/null +++ b/lib/utils/m3u8-duration-grid.ts @@ -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 { + 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; +} diff --git a/lib/utils/m3u8-utils.ts b/lib/utils/m3u8-utils.ts index 924f95c..3b46c7c 100644 --- a/lib/utils/m3u8-utils.ts +++ b/lib/utils/m3u8-utils.ts @@ -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(); + const adLineIndices = new Set(); 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') { diff --git a/package-lock.json b/package-lock.json index 9b5ae3f..3021cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ac420d7..9c732eb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/m3u8-duration-grid.test.ts b/tests/m3u8-duration-grid.test.ts new file mode 100644 index 0000000..b68564c --- /dev/null +++ b/tests/m3u8-duration-grid.test.ts @@ -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); +}); diff --git a/tests/m3u8-filter-regression.test.ts b/tests/m3u8-filter-regression.test.ts new file mode 100644 index 0000000..65fa625 --- /dev/null +++ b/tests/m3u8-filter-regression.test.ts @@ -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); +});