mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 08:43:44 +08:00
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -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({
|
||||
<div className={`absolute top-8 left-6 z-40 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0'}`} style={{ pointerEvents: showControls ? 'auto' : 'none' }}>
|
||||
<DesktopMoreMenu
|
||||
showMoreMenu={showMoreMenu}
|
||||
isPremium={isPremium}
|
||||
isProxied={isProxied}
|
||||
onToggleMoreMenu={onToggleMoreMenu}
|
||||
onMouseEnter={onMoreMenuMouseEnter}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface DesktopOverlayWrapperProps {
|
||||
onSkipBackward: () => 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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
|
||||
interface UseHlsPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
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<Hls | null>(null);
|
||||
const { adFilterMode, adKeywords } = usePlayerSettings();
|
||||
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
|
||||
const isAdFilterEnabled = adFilterMode !== 'off';
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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<ModeSettings>) => {
|
||||
if (isPremium) {
|
||||
const currentSettings = premiumModeSettingsStore.getSettings();
|
||||
premiumModeSettingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
...partial,
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSetting = useCallback(<K extends keyof typeof settings>(
|
||||
key: K,
|
||||
value: typeof settings[K]
|
||||
) => {
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
settingsStore.saveSettings({
|
||||
...currentSettings,
|
||||
[key]: value,
|
||||
...partial,
|
||||
});
|
||||
}, [isPremium]);
|
||||
|
||||
const updateGlobalSettings = useCallback((partial: Partial<AppSettings>) => {
|
||||
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,
|
||||
|
||||
+101
-12
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user