From 48ad9bc252d24e0fe4f67f1de2c37ee43e5190e4 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 2 Apr 2026 20:12:59 +0800 Subject: [PATCH] fix: restore HLS ad filtering Closes #133 Closes #134 --- components/AdKeywordsInjector.tsx | 28 ++- components/player/DesktopVideoPlayer.tsx | 5 +- components/player/desktop/DesktopMoreMenu.tsx | 4 +- components/player/desktop/DesktopOverlay.tsx | 3 + .../player/desktop/DesktopOverlayWrapper.tsx | 3 + components/player/hooks/useAutoSkip.ts | 4 +- components/player/hooks/useHlsPlayer.ts | 4 +- components/player/hooks/usePlayerSettings.ts | 201 +++++++++++------- lib/utils/m3u8-utils.ts | 113 ++++++++-- 9 files changed, 258 insertions(+), 107 deletions(-) diff --git a/components/AdKeywordsInjector.tsx b/components/AdKeywordsInjector.tsx index 6c2e161..966619b 100644 --- a/components/AdKeywordsInjector.tsx +++ b/components/AdKeywordsInjector.tsx @@ -1,22 +1,32 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { usePlayerSettings } from '@/components/player/hooks/usePlayerSettings'; +import { useEffect } from 'react'; +import { settingsStore } from '@/lib/store/settings-store'; interface AdKeywordsInjectorProps { keywords: string[]; } export function AdKeywordsInjector({ keywords }: AdKeywordsInjectorProps) { - const { setAdKeywords } = usePlayerSettings(); - const initialized = useRef(false); - useEffect(() => { - if (!initialized.current && keywords.length > 0) { - setAdKeywords(keywords); - initialized.current = true; + const normalizedKeywords = [...new Set( + keywords + .map((keyword) => keyword.trim()) + .filter((keyword) => keyword.length > 0) + )]; + const currentSettings = settingsStore.getSettings(); + + const isUnchanged = + currentSettings.adKeywords.length === normalizedKeywords.length && + currentSettings.adKeywords.every((keyword, index) => keyword === normalizedKeywords[index]); + + if (!isUnchanged) { + settingsStore.saveSettings({ + ...currentSettings, + adKeywords: normalizedKeywords, + }); } - }, [keywords, setAdKeywords]); + }, [keywords]); return null; } diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 031b48a..a45f632 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -60,7 +60,7 @@ export function DesktopVideoPlayer({ onResolutionDetected, }: DesktopVideoPlayerProps) { const { refs, data, actions } = useDesktopPlayerState(); - const { fullscreenType: settingsFullscreenType } = usePlayerSettings(); + const { fullscreenType: settingsFullscreenType } = usePlayerSettings(isPremium); const isIOS = useIsIOS(); const isMobile = useIsMobile(); const [seekStepSeconds, setSeekStepSeconds] = React.useState(DEFAULT_SEEK_STEP_SECONDS); @@ -160,6 +160,7 @@ export function DesktopVideoPlayer({ useHlsPlayer({ videoRef: refs.videoRef, src, + isPremium, autoPlay: shouldAutoPlay }); @@ -206,6 +207,7 @@ export function DesktopVideoPlayer({ currentTime, duration, isPlaying, + isPremium, totalEpisodes, currentEpisodeIndex, onNextEpisode, @@ -335,6 +337,7 @@ export function DesktopVideoPlayer({ isTransitioningToNextEpisode={isTransitioningToNextEpisode} // More Menu Props showMoreMenu={data.showMoreMenu} + isPremium={isPremium} isProxied={src.includes('/api/proxy')} onToggleMoreMenu={() => actions.setShowMoreMenu(!data.showMoreMenu)} onMoreMenuMouseEnter={() => { diff --git a/components/player/desktop/DesktopMoreMenu.tsx b/components/player/desktop/DesktopMoreMenu.tsx index 71d8699..b39ba84 100644 --- a/components/player/desktop/DesktopMoreMenu.tsx +++ b/components/player/desktop/DesktopMoreMenu.tsx @@ -9,6 +9,7 @@ import { createPortal } from 'react-dom'; interface DesktopMoreMenuProps { showMoreMenu: boolean; + isPremium?: boolean; isProxied?: boolean; onToggleMoreMenu: () => void; onMouseEnter: () => void; @@ -22,6 +23,7 @@ interface DesktopMoreMenuProps { export function DesktopMoreMenu({ showMoreMenu, + isPremium = false, isProxied = false, onToggleMoreMenu, onMouseEnter, @@ -60,7 +62,7 @@ export function DesktopMoreMenu({ setDanmakuFontSize, danmakuDisplayArea, setDanmakuDisplayArea, - } = usePlayerSettings(); + } = usePlayerSettings(isPremium); const buttonRef = React.useRef(null); const menuRef = React.useRef(null); diff --git a/components/player/desktop/DesktopOverlay.tsx b/components/player/desktop/DesktopOverlay.tsx index bea1c39..404bcd3 100644 --- a/components/player/desktop/DesktopOverlay.tsx +++ b/components/player/desktop/DesktopOverlay.tsx @@ -23,6 +23,7 @@ interface DesktopOverlayProps { onSkipForward: () => void; onSkipBackward: () => void; showMoreMenu: boolean; + isPremium?: boolean; isProxied: boolean; onToggleMoreMenu: () => void; onMoreMenuMouseEnter: () => void; @@ -62,6 +63,7 @@ export function DesktopOverlay({ onSkipBackward, showControls, showMoreMenu, + isPremium = false, isProxied, onToggleMoreMenu, onMoreMenuMouseEnter, @@ -89,6 +91,7 @@ export function DesktopOverlay({
void; isTransitioningToNextEpisode?: boolean; showMoreMenu: boolean; + isPremium?: boolean; isProxied: boolean; onToggleMoreMenu: () => void; onMoreMenuMouseEnter: () => void; @@ -43,6 +44,7 @@ export function DesktopOverlayWrapper({ onSkipBackward, isTransitioningToNextEpisode = false, showMoreMenu, + isPremium = false, isProxied, onToggleMoreMenu, onMoreMenuMouseEnter, @@ -93,6 +95,7 @@ export function DesktopOverlayWrapper({ onSkipForward={onSkipForward} onSkipBackward={onSkipBackward} showMoreMenu={showMoreMenu} + isPremium={isPremium} isProxied={isProxied} onToggleMoreMenu={onToggleMoreMenu} onMoreMenuMouseEnter={onMoreMenuMouseEnter} diff --git a/components/player/hooks/useAutoSkip.ts b/components/player/hooks/useAutoSkip.ts index 4579a3c..f6ba0b7 100644 --- a/components/player/hooks/useAutoSkip.ts +++ b/components/player/hooks/useAutoSkip.ts @@ -9,6 +9,7 @@ interface UseAutoSkipProps { currentTime: number; duration: number; isPlaying: boolean; + isPremium?: boolean; totalEpisodes?: number; currentEpisodeIndex?: number; onNextEpisode?: () => void; @@ -27,6 +28,7 @@ export function useAutoSkip({ currentTime, duration, isPlaying, + isPremium = false, totalEpisodes = 1, currentEpisodeIndex = 0, onNextEpisode, @@ -39,7 +41,7 @@ export function useAutoSkip({ skipIntroSeconds, autoSkipOutro, skipOutroSeconds, - } = usePlayerSettings(); + } = usePlayerSettings(isPremium); // Track if we've already skipped intro for this video session const hasSkippedIntroRef = useRef(false); diff --git a/components/player/hooks/useHlsPlayer.ts b/components/player/hooks/useHlsPlayer.ts index 9683b63..5bbd53e 100644 --- a/components/player/hooks/useHlsPlayer.ts +++ b/components/player/hooks/useHlsPlayer.ts @@ -6,6 +6,7 @@ import { filterM3u8Ad } from '@/lib/utils/m3u8-utils'; interface UseHlsPlayerProps { videoRef: React.RefObject; src: string; + isPremium?: boolean; autoPlay?: boolean; onAutoPlayPrevented?: (error: Error) => void; onError?: (message: string) => void; @@ -14,12 +15,13 @@ interface UseHlsPlayerProps { export function useHlsPlayer({ videoRef, src, + isPremium = false, autoPlay = false, onAutoPlayPrevented, onError }: UseHlsPlayerProps) { const hlsRef = useRef(null); - const { adFilterMode, adKeywords } = usePlayerSettings(); + const { adFilterMode, adKeywords } = usePlayerSettings(isPremium); const isAdFilterEnabled = adFilterMode !== 'off'; useEffect(() => { diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts index 890b133..8d3e96f 100644 --- a/components/player/hooks/usePlayerSettings.ts +++ b/components/player/hooks/usePlayerSettings.ts @@ -1,135 +1,172 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { settingsStore, AdFilterMode } from '@/lib/store/settings-store'; +import { + settingsStore, + type AppSettings, + type AdFilterMode, +} from '@/lib/store/settings-store'; +import { + premiumModeSettingsStore, + type ModeSettings, +} from '@/lib/store/premium-mode-settings'; + +interface PlayerSettingsSnapshot { + autoNextEpisode: boolean; + autoSkipIntro: boolean; + skipIntroSeconds: number; + autoSkipOutro: boolean; + skipOutroSeconds: number; + showModeIndicator: boolean; + adFilter: boolean; + adFilterMode: AdFilterMode; + adKeywords: string[]; + fullscreenType: 'auto' | 'native' | 'window'; + proxyMode: 'retry' | 'none' | 'always'; + danmakuEnabled: boolean; + danmakuApiUrl: string; + danmakuOpacity: number; + danmakuFontSize: number; + danmakuDisplayArea: number; +} + +function getPlayerSettingsSnapshot(isPremium: boolean): PlayerSettingsSnapshot { + const globalSettings = settingsStore.getSettings(); + const modeSettings = isPremium ? premiumModeSettingsStore.getSettings() : globalSettings; + + return { + autoNextEpisode: modeSettings.autoNextEpisode, + autoSkipIntro: modeSettings.autoSkipIntro, + skipIntroSeconds: modeSettings.skipIntroSeconds, + autoSkipOutro: modeSettings.autoSkipOutro, + skipOutroSeconds: modeSettings.skipOutroSeconds, + showModeIndicator: modeSettings.showModeIndicator, + adFilter: globalSettings.adFilter, + adFilterMode: modeSettings.adFilterMode, + adKeywords: globalSettings.adKeywords, + fullscreenType: modeSettings.fullscreenType, + proxyMode: modeSettings.proxyMode, + danmakuEnabled: modeSettings.danmakuEnabled, + danmakuApiUrl: modeSettings.danmakuApiUrl, + danmakuOpacity: modeSettings.danmakuOpacity, + danmakuFontSize: modeSettings.danmakuFontSize, + danmakuDisplayArea: modeSettings.danmakuDisplayArea, + }; +} /** * Hook to access and update player settings from the settings store * Provides reactive updates when settings change */ -export function usePlayerSettings() { - const [settings, setSettings] = useState(() => { - const stored = settingsStore.getSettings(); - return { - autoNextEpisode: stored.autoNextEpisode, - autoSkipIntro: stored.autoSkipIntro, - skipIntroSeconds: stored.skipIntroSeconds, - autoSkipOutro: stored.autoSkipOutro, - skipOutroSeconds: stored.skipOutroSeconds, - showModeIndicator: stored.showModeIndicator, - adFilter: stored.adFilter, - adFilterMode: stored.adFilterMode, - adKeywords: stored.adKeywords, - fullscreenType: stored.fullscreenType, - proxyMode: stored.proxyMode, - danmakuEnabled: stored.danmakuEnabled, - danmakuApiUrl: stored.danmakuApiUrl, - danmakuOpacity: stored.danmakuOpacity, - danmakuFontSize: stored.danmakuFontSize, - danmakuDisplayArea: stored.danmakuDisplayArea, - }; - }); +export function usePlayerSettings(isPremium: boolean = false) { + const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium)); // Subscribe to settings changes useEffect(() => { - const unsubscribe = settingsStore.subscribe(() => { - const stored = settingsStore.getSettings(); - setSettings({ - autoNextEpisode: stored.autoNextEpisode, - autoSkipIntro: stored.autoSkipIntro, - skipIntroSeconds: stored.skipIntroSeconds, - autoSkipOutro: stored.autoSkipOutro, - skipOutroSeconds: stored.skipOutroSeconds, - showModeIndicator: stored.showModeIndicator, - adFilter: stored.adFilter, - adFilterMode: stored.adFilterMode, - adKeywords: stored.adKeywords, - fullscreenType: stored.fullscreenType, - proxyMode: stored.proxyMode, - danmakuEnabled: stored.danmakuEnabled, - danmakuApiUrl: stored.danmakuApiUrl, - danmakuOpacity: stored.danmakuOpacity, - danmakuFontSize: stored.danmakuFontSize, - danmakuDisplayArea: stored.danmakuDisplayArea, + const syncSettings = () => { + setSettings(getPlayerSettingsSnapshot(isPremium)); + }; + + const modeStore = isPremium ? premiumModeSettingsStore : settingsStore; + const unsubscribeModeStore = modeStore.subscribe(syncSettings); + const unsubscribeGlobalStore = isPremium ? settingsStore.subscribe(syncSettings) : null; + + syncSettings(); + + return () => { + unsubscribeModeStore(); + unsubscribeGlobalStore?.(); + }; + }, [isPremium]); + + const updateModeSettings = useCallback((partial: Partial) => { + if (isPremium) { + const currentSettings = premiumModeSettingsStore.getSettings(); + premiumModeSettingsStore.saveSettings({ + ...currentSettings, + ...partial, }); - }); - return unsubscribe; - }, []); + return; + } - const updateSetting = useCallback(( - key: K, - value: typeof settings[K] - ) => { const currentSettings = settingsStore.getSettings(); settingsStore.saveSettings({ ...currentSettings, - [key]: value, + ...partial, + }); + }, [isPremium]); + + const updateGlobalSettings = useCallback((partial: Partial) => { + const currentSettings = settingsStore.getSettings(); + settingsStore.saveSettings({ + ...currentSettings, + ...partial, }); }, []); const setAutoNextEpisode = useCallback((value: boolean) => { - updateSetting('autoNextEpisode', value); - }, [updateSetting]); + updateModeSettings({ autoNextEpisode: value }); + }, [updateModeSettings]); const setAutoSkipIntro = useCallback((value: boolean) => { - updateSetting('autoSkipIntro', value); - }, [updateSetting]); + updateModeSettings({ autoSkipIntro: value }); + }, [updateModeSettings]); const setSkipIntroSeconds = useCallback((value: number) => { - updateSetting('skipIntroSeconds', Math.max(0, value)); - }, [updateSetting]); + updateModeSettings({ skipIntroSeconds: Math.max(0, value) }); + }, [updateModeSettings]); const setAutoSkipOutro = useCallback((value: boolean) => { - updateSetting('autoSkipOutro', value); - }, [updateSetting]); + updateModeSettings({ autoSkipOutro: value }); + }, [updateModeSettings]); const setSkipOutroSeconds = useCallback((value: number) => { - updateSetting('skipOutroSeconds', Math.max(0, value)); - }, [updateSetting]); + updateModeSettings({ skipOutroSeconds: Math.max(0, value) }); + }, [updateModeSettings]); const setShowModeIndicator = useCallback((value: boolean) => { - updateSetting('showModeIndicator', value); - }, [updateSetting]); + updateModeSettings({ showModeIndicator: value }); + }, [updateModeSettings]); const setAdFilter = useCallback((value: boolean) => { - updateSetting('adFilter', value); - }, [updateSetting]); + updateGlobalSettings({ adFilter: value }); + }, [updateGlobalSettings]); const setAdFilterMode = useCallback((value: AdFilterMode) => { - updateSetting('adFilterMode', value); - }, [updateSetting]); + updateModeSettings({ adFilterMode: value }); + }, [updateModeSettings]); const setAdKeywords = useCallback((value: string[]) => { - updateSetting('adKeywords', value); - }, [updateSetting]); + updateGlobalSettings({ adKeywords: value }); + }, [updateGlobalSettings]); const setFullscreenType = useCallback((value: 'auto' | 'native' | 'window') => { - updateSetting('fullscreenType', value); - }, [updateSetting]); + updateModeSettings({ fullscreenType: value }); + }, [updateModeSettings]); const setProxyMode = useCallback((value: 'retry' | 'none' | 'always') => { - updateSetting('proxyMode', value); - }, [updateSetting]); + updateModeSettings({ proxyMode: value }); + }, [updateModeSettings]); const setDanmakuEnabled = useCallback((value: boolean) => { - updateSetting('danmakuEnabled', value); - }, [updateSetting]); + updateModeSettings({ danmakuEnabled: value }); + }, [updateModeSettings]); const setDanmakuApiUrl = useCallback((value: string) => { - updateSetting('danmakuApiUrl', value); - }, [updateSetting]); + updateModeSettings({ danmakuApiUrl: value }); + }, [updateModeSettings]); const setDanmakuOpacity = useCallback((value: number) => { - updateSetting('danmakuOpacity', Math.max(0.1, Math.min(1, value))); - }, [updateSetting]); + updateModeSettings({ danmakuOpacity: Math.max(0.1, Math.min(1, value)) }); + }, [updateModeSettings]); const setDanmakuFontSize = useCallback((value: number) => { - updateSetting('danmakuFontSize', value); - }, [updateSetting]); + updateModeSettings({ danmakuFontSize: value }); + }, [updateModeSettings]); const setDanmakuDisplayArea = useCallback((value: number) => { - updateSetting('danmakuDisplayArea', value); - }, [updateSetting]); + updateModeSettings({ danmakuDisplayArea: value }); + }, [updateModeSettings]); return { ...settings, diff --git a/lib/utils/m3u8-utils.ts b/lib/utils/m3u8-utils.ts index 38ffa97..aa9765f 100644 --- a/lib/utils/m3u8-utils.ts +++ b/lib/utils/m3u8-utils.ts @@ -5,11 +5,69 @@ import { parseBlocks, learnMainPattern, scoreBlock, shouldFilterBlock } from './m3u8-ad-detector'; +const INTERSTITIAL_DATERANGE_MARKERS = [ + 'class="com.apple.hls.interstitial"', + 'x-asset-uri=', + 'x-asset-list=', + 'x-playout-limit=', + 'x-resume-offset=', + 'x-restrict=', + 'cue="once"', +]; + +const AUXILIARY_AD_TAG_PREFIXES = [ + '#EXT-X-ASSET:', + '#EXT-X-CUE-OUT-CONT', + '#EXT-X-PLACEMENT-OPPORTUNITY', + '#EXT-OATCLS-SCTE35', + '#EXT-X-SCTE35', +]; + +const SEGMENT_METADATA_PREFIXES = [ + '#EXTINF:', + '#EXT-X-BYTERANGE:', + '#EXT-X-DISCONTINUITY', + '#EXT-X-PROGRAM-DATE-TIME:', +]; + +function normalizeKeywords(keywords: string[]): string[] { + return [...new Set( + keywords + .map((keyword) => keyword.trim().toLowerCase()) + .filter((keyword) => keyword.length > 0) + )]; +} + +function hasKeywordMatch(line: string, normalizedKeywords: string[]): boolean { + if (normalizedKeywords.length === 0) { + return false; + } + + const lowerLine = line.toLowerCase(); + return normalizedKeywords.some((keyword) => lowerLine.includes(keyword)); +} + +function isInterstitialDateRange(trimmedLine: string, normalizedKeywords: string[]): boolean { + if (!trimmedLine.startsWith('#EXT-X-DATERANGE:')) { + return false; + } + + const lowerLine = trimmedLine.toLowerCase(); + return INTERSTITIAL_DATERANGE_MARKERS.some((marker) => lowerLine.includes(marker)) || + hasKeywordMatch(trimmedLine, normalizedKeywords); +} + +function isAuxiliaryAdMetadataLine(trimmedLine: string, normalizedKeywords: string[]): boolean { + return AUXILIARY_AD_TAG_PREFIXES.some((prefix) => trimmedLine.startsWith(prefix)) || + (trimmedLine.startsWith('#EXT-X-DATERANGE:') && hasKeywordMatch(trimmedLine, normalizedKeywords)); +} + /** * Filters ads from specific M3U8 content using multiple detection strategies: * 1. Keyword matching (configurable via env) * 2. CUE-OUT/CUE-IN standard tags - * 3. Heuristic block analysis (filename patterns, ad path keywords) + * 3. HLS interstitial metadata removal + * 4. Heuristic block analysis (filename patterns, ad path keywords) * * Also converts relative URLs to absolute URLs for Blob playback. * @@ -23,7 +81,7 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod if (!content) return ''; // Use keywords passed from AdKeywordsWrapper (already loaded from env/file) - const keywords = customKeywords; + const normalizedKeywords = normalizeKeywords(customKeywords); // Unwrap baseUrl if it's a proxy URL to get correct basePath and origin let effectiveBaseUrl = baseUrl; @@ -43,7 +101,7 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod } catch (e) { /* ignore */ } // 2. Global Scan: Check if any ad keywords exist in the content - const hasKeywordMatch = mode !== 'off' && keywords.some(k => content.includes(k)); + const hasKeywordMatchInPlaylist = mode !== 'off' && hasKeywordMatch(content, normalizedKeywords); const hasCueTag = mode !== 'off' && (content.includes('#EXT-X-CUE-OUT') || content.includes('#EXT-X-CUE-IN')); // 3. Heuristic Analysis: If no explicit ad signals, use block-based detection @@ -57,7 +115,7 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod const mainPattern = learnMainPattern(blocks); for (const block of blocks) { // Pass all keywords (including custom ones) to heuristic scorer - const score = scoreBlock(block, mainPattern, keywords); + const score = scoreBlock(block, mainPattern, normalizedKeywords); const threshold = mode === 'aggressive' ? 3.0 : 5.0; if (shouldFilterBlock(score, threshold)) { @@ -77,7 +135,7 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod startLineIndex: segment.lineIndex - 1, endLineIndex: segment.lineIndex }; - const segmentScore = scoreBlock(singleSegmentBlock, mainPattern, keywords); + const segmentScore = scoreBlock(singleSegmentBlock, mainPattern, normalizedKeywords); // Higher threshold for individual segments to avoid false positives if (segmentScore >= 4.0) { adLineIndices.add(segment.lineIndex); @@ -90,6 +148,7 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod } const processedLines: string[] = []; + let hasKeptMediaLine = false; // State machine for CUE-OUT/CUE-IN tracking let insideCueAdBlock = false; @@ -103,7 +162,16 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod continue; } - // 3. CUE Tag Detection (SCTE-35 Standard) + // 4. Strip modern HLS interstitial metadata before the player can schedule it. + if ( + mode !== 'off' && + (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')) { insideCueAdBlock = true; @@ -128,14 +196,18 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod continue; } - // 4. Keyword-based Ad Detection & Backtrack (skip if no keywords configured) - if (keywords.length > 0 && hasKeywordMatch && keywords.some(keyword => trimmedLine.includes(keyword))) { + // 6. Keyword-based Ad Detection & Backtrack (skip if no keywords configured) + if ( + normalizedKeywords.length > 0 && + hasKeywordMatchInPlaylist && + hasKeywordMatch(trimmedLine, normalizedKeywords) + ) { // Found Ad: Remove it and backtrack to remove associated metadata while (processedLines.length > 0) { const lastIndex = processedLines.length - 1; const lastLine = processedLines[lastIndex].trim(); - if (lastLine.startsWith('#EXTINF:') || lastLine === '#EXT-X-DISCONTINUITY') { + if (SEGMENT_METADATA_PREFIXES.some((prefix) => lastLine.startsWith(prefix))) { processedLines.pop(); } else { break; @@ -144,18 +216,27 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod continue; // Skip the ad line itself } - // 5. Discontinuity Handling (Conservative Mode) + // 7. Discontinuity Handling (Conservative Mode) // Keep all Discontinuity tags by default. // They will ONLY be removed via backtracking when a confirmed ad segment is found. // This prevents false positives on legitimate concatenated streams. if (trimmedLine === '#EXT-X-DISCONTINUITY') { + if ( + !hasKeptMediaLine || + processedLines[processedLines.length - 1].trim() === '#EXT-X-DISCONTINUITY' + ) { + continue; + } processedLines.push(line); continue; } - // 6. General Cleanup & URL Normalization + // 8. General Cleanup & URL Normalization if (!trimmedLine || trimmedLine.startsWith('http') || trimmedLine.startsWith('blob:')) { processedLines.push(line); + if (trimmedLine && !trimmedLine.startsWith('#')) { + hasKeptMediaLine = true; + } continue; } @@ -175,12 +256,20 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod continue; } - // 7. Resolve Relative URLs (for Blob support) + // 9. Resolve Relative URLs (for Blob support) if (trimmedLine.startsWith('/')) { processedLines.push(origin ? `${origin}${trimmedLine}` : trimmedLine); } else { processedLines.push(`${basePath}${trimmedLine}`); } + hasKeptMediaLine = true; + } + + while ( + processedLines.length > 0 && + processedLines[processedLines.length - 1].trim() === '#EXT-X-DISCONTINUITY' + ) { + processedLines.pop(); } return processedLines.join('\n');