fix: restore HLS ad filtering

Closes #133

Closes #134
This commit is contained in:
kuekhaoyang
2026-04-02 20:12:59 +08:00
parent ba3a1a7009
commit 48ad9bc252
9 changed files with 258 additions and 107 deletions
+19 -9
View File
@@ -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;
}
+4 -1
View File
@@ -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}
+3 -1
View File
@@ -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);
+3 -1
View File
@@ -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(() => {
+119 -82
View File
@@ -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
View File
@@ -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');