mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-04-11 11:50:51 +00:00
855 lines
38 KiB
Python
855 lines
38 KiB
Python
import asyncio
|
|
import codecs
|
|
import logging
|
|
import re
|
|
from typing import AsyncGenerator, List, Optional
|
|
|
|
from urllib import parse
|
|
|
|
from mediaflow_proxy.configs import settings
|
|
from mediaflow_proxy.utils.crypto_utils import encryption_handler
|
|
from mediaflow_proxy.utils.http_utils import encode_mediaflow_proxy_url, encode_stremio_proxy_url, get_original_scheme
|
|
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def generate_graceful_end_playlist(message: str = "Stream ended") -> str:
|
|
"""
|
|
Generate a minimal valid m3u8 playlist that signals stream end.
|
|
|
|
This is used when upstream fails but we want to provide a graceful
|
|
end to the player instead of an abrupt error. Most players will
|
|
interpret this as the stream ending normally.
|
|
|
|
Args:
|
|
message: Optional message to include as a comment.
|
|
|
|
Returns:
|
|
str: A valid m3u8 playlist string with EXT-X-ENDLIST.
|
|
"""
|
|
return f"""#EXTM3U
|
|
#EXT-X-VERSION:3
|
|
#EXT-X-TARGETDURATION:1
|
|
#EXT-X-PLAYLIST-TYPE:VOD
|
|
# {message}
|
|
#EXT-X-ENDLIST
|
|
"""
|
|
|
|
|
|
def generate_error_playlist(error_message: str = "Stream unavailable") -> str:
|
|
"""
|
|
Generate a minimal valid m3u8 playlist for error scenarios.
|
|
|
|
Unlike generate_graceful_end_playlist, this includes a very short
|
|
segment duration to signal something went wrong while still being
|
|
a valid playlist that players can parse.
|
|
|
|
Args:
|
|
error_message: Error message to include as a comment.
|
|
|
|
Returns:
|
|
str: A valid m3u8 playlist string.
|
|
"""
|
|
return f"""#EXTM3U
|
|
#EXT-X-VERSION:3
|
|
#EXT-X-TARGETDURATION:1
|
|
#EXT-X-PLAYLIST-TYPE:VOD
|
|
# Error: {error_message}
|
|
#EXT-X-ENDLIST
|
|
"""
|
|
|
|
|
|
class SkipSegmentFilter:
|
|
"""
|
|
Helper class to filter HLS segments based on time ranges.
|
|
|
|
Tracks cumulative playback time and determines which segments
|
|
should be skipped based on the provided skip segment list.
|
|
"""
|
|
|
|
def __init__(self, skip_segments: Optional[List[dict]] = None):
|
|
"""
|
|
Initialize the skip segment filter.
|
|
|
|
Args:
|
|
skip_segments: List of skip segment dicts with 'start' and 'end' keys.
|
|
"""
|
|
self.skip_segments = skip_segments or []
|
|
self.current_time = 0.0 # Cumulative playback time in seconds
|
|
|
|
def should_skip_segment(self, duration: float) -> bool:
|
|
"""
|
|
Determine if the current segment should be skipped.
|
|
|
|
Args:
|
|
duration: Duration of the current segment in seconds.
|
|
|
|
Returns:
|
|
True if the segment overlaps with any skip range, False otherwise.
|
|
"""
|
|
segment_start = self.current_time
|
|
segment_end = self.current_time + duration
|
|
|
|
# Check if this segment overlaps with any skip range
|
|
for skip in self.skip_segments:
|
|
skip_start = skip.get("start", 0)
|
|
skip_end = skip.get("end", 0)
|
|
|
|
# Check for overlap: segment overlaps if it starts before skip ends AND ends after skip starts
|
|
if segment_start < skip_end and segment_end > skip_start:
|
|
logger.debug(
|
|
f"Skipping segment at {segment_start:.2f}s-{segment_end:.2f}s "
|
|
f"(overlaps with skip range {skip_start:.2f}s-{skip_end:.2f}s)"
|
|
)
|
|
return True
|
|
|
|
return False
|
|
|
|
def advance_time(self, duration: float):
|
|
"""Advance the cumulative playback time."""
|
|
self.current_time += duration
|
|
|
|
def has_skip_segments(self) -> bool:
|
|
"""Check if there are any skip segments configured."""
|
|
return bool(self.skip_segments)
|
|
|
|
|
|
class M3U8Processor:
|
|
def __init__(
|
|
self,
|
|
request,
|
|
key_url: str = None,
|
|
force_playlist_proxy: bool = None,
|
|
key_only_proxy: bool = False,
|
|
no_proxy: bool = False,
|
|
skip_segments: Optional[List[dict]] = None,
|
|
start_offset: Optional[float] = None,
|
|
):
|
|
"""
|
|
Initializes the M3U8Processor with the request and URL prefix.
|
|
|
|
Args:
|
|
request (Request): The incoming HTTP request.
|
|
key_url (HttpUrl, optional): The URL of the key server. Defaults to None.
|
|
force_playlist_proxy (bool, optional): Force all playlist URLs to be proxied through MediaFlow. Defaults to None.
|
|
key_only_proxy (bool, optional): Only proxy the key URL, leaving segment URLs direct. Defaults to False.
|
|
no_proxy (bool, optional): If True, returns the manifest without proxying any URLs. Defaults to False.
|
|
skip_segments (List[dict], optional): List of time segments to skip. Each dict should have
|
|
'start', 'end' (in seconds), and optionally 'type'.
|
|
start_offset (float, optional): Time offset in seconds for EXT-X-START tag. Use negative values
|
|
for live streams to start behind the live edge.
|
|
"""
|
|
self.request = request
|
|
self.key_url = parse.urlparse(key_url) if key_url else None
|
|
self.key_only_proxy = key_only_proxy
|
|
self.no_proxy = no_proxy
|
|
self.force_playlist_proxy = force_playlist_proxy
|
|
self.skip_filter = SkipSegmentFilter(skip_segments)
|
|
# Track if user explicitly provided start_offset (vs using default)
|
|
self._user_provided_start_offset = start_offset is not None
|
|
# Store the explicit value or default (will be applied conditionally for live streams)
|
|
self._start_offset_value = start_offset if start_offset is not None else settings.livestream_start_offset
|
|
self.mediaflow_proxy_url = str(
|
|
request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request))
|
|
)
|
|
# Base URL for segment proxy - extension will be appended based on actual segment
|
|
# url_for with path param returns URL with placeholder, so we build it manually
|
|
self.segment_proxy_base_url = str(
|
|
request.url_for("hls_manifest_proxy").replace(scheme=get_original_scheme(request))
|
|
).replace("/hls/manifest.m3u8", "/hls/segment")
|
|
self.playlist_url = None # Will be set when processing starts
|
|
|
|
def _should_apply_start_offset(self, content: str) -> bool:
|
|
"""
|
|
Determine if start_offset should be applied to this playlist.
|
|
|
|
Args:
|
|
content: The playlist content to check.
|
|
|
|
Returns:
|
|
True if start_offset should be applied, False otherwise.
|
|
"""
|
|
if self._start_offset_value is None:
|
|
return False
|
|
|
|
# If user explicitly provided start_offset, always use it
|
|
if self._user_provided_start_offset:
|
|
return True
|
|
|
|
# Using default from settings - only apply for live streams
|
|
# VOD playlists have #EXT-X-ENDLIST tag or #EXT-X-PLAYLIST-TYPE:VOD
|
|
# Also skip master playlists (they have #EXT-X-STREAM-INF)
|
|
is_vod = "#EXT-X-ENDLIST" in content or "#EXT-X-PLAYLIST-TYPE:VOD" in content
|
|
is_master = "#EXT-X-STREAM-INF" in content
|
|
|
|
return not is_vod and not is_master
|
|
|
|
async def process_m3u8(self, content: str, base_url: str) -> str:
|
|
"""
|
|
Processes the m3u8 content, proxying URLs and handling key lines.
|
|
|
|
For content filtering with skip_segments, this follows the IntroHater approach:
|
|
- Segments within skip ranges are completely removed (EXTINF + URL)
|
|
- A #EXT-X-DISCONTINUITY marker is added BEFORE the URL of the first segment
|
|
after a skipped section (not before the EXTINF)
|
|
|
|
Args:
|
|
content (str): The m3u8 content to process.
|
|
base_url (str): The base URL to resolve relative URLs.
|
|
|
|
Returns:
|
|
str: The processed m3u8 content.
|
|
"""
|
|
# Store the playlist URL for prebuffering
|
|
self.playlist_url = base_url
|
|
|
|
lines = content.splitlines()
|
|
processed_lines = []
|
|
|
|
# Track if we need to add discontinuity before next URL (after skipping segments)
|
|
discontinuity_pending = False
|
|
# Buffer the current EXTINF line - only output when we output the URL
|
|
pending_extinf: Optional[str] = None
|
|
# Track if we've injected EXT-X-START tag
|
|
start_offset_injected = False
|
|
# Determine if we should apply start_offset (checks if live stream)
|
|
apply_start_offset = self._should_apply_start_offset(content)
|
|
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
|
|
# Inject EXT-X-START tag right after #EXTM3U (only for live streams or if user explicitly requested)
|
|
if line.strip() == "#EXTM3U" and apply_start_offset and not start_offset_injected:
|
|
processed_lines.append(line)
|
|
processed_lines.append(f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES")
|
|
start_offset_injected = True
|
|
i += 1
|
|
continue
|
|
|
|
# Handle EXTINF lines (segment duration markers)
|
|
if line.startswith("#EXTINF:"):
|
|
duration = self._parse_extinf_duration(line)
|
|
|
|
if self.skip_filter.has_skip_segments() and self.skip_filter.should_skip_segment(duration):
|
|
# Skip this segment entirely - don't buffer the EXTINF
|
|
discontinuity_pending = True # Mark that we need discontinuity before next kept segment
|
|
self.skip_filter.advance_time(duration)
|
|
pending_extinf = None
|
|
i += 1
|
|
continue
|
|
else:
|
|
# Keep this segment
|
|
self.skip_filter.advance_time(duration)
|
|
pending_extinf = line
|
|
i += 1
|
|
continue
|
|
|
|
# Handle segment URLs (non-comment, non-empty lines)
|
|
if not line.startswith("#") and line.strip():
|
|
if pending_extinf is None:
|
|
# No pending EXTINF means this segment was skipped
|
|
i += 1
|
|
continue
|
|
|
|
# Add discontinuity BEFORE the EXTINF if we just skipped segments
|
|
# Per HLS spec, EXT-X-DISCONTINUITY must appear before the first segment of the new content
|
|
if discontinuity_pending:
|
|
processed_lines.append("#EXT-X-DISCONTINUITY")
|
|
discontinuity_pending = False
|
|
|
|
# Output the buffered EXTINF and proxied URL
|
|
processed_lines.append(pending_extinf)
|
|
processed_lines.append(await self.proxy_content_url(line, base_url))
|
|
pending_extinf = None
|
|
i += 1
|
|
continue
|
|
|
|
# Handle existing discontinuity markers - pass through but reset pending flag
|
|
if line.startswith("#EXT-X-DISCONTINUITY"):
|
|
processed_lines.append(line)
|
|
discontinuity_pending = False # Don't add duplicate
|
|
i += 1
|
|
continue
|
|
|
|
# Handle key lines
|
|
if "URI=" in line:
|
|
processed_lines.append(await self.process_key_line(line, base_url))
|
|
i += 1
|
|
continue
|
|
|
|
# All other lines (headers, comments, etc.)
|
|
processed_lines.append(line)
|
|
i += 1
|
|
|
|
# Log skip statistics
|
|
if self.skip_filter.has_skip_segments():
|
|
logger.info(f"Content filtering: processed playlist with {len(self.skip_filter.skip_segments)} skip ranges")
|
|
|
|
# Register playlist with the priority-based prefetcher
|
|
if settings.enable_hls_prebuffer and "#EXTM3U" in content and self.playlist_url:
|
|
# Skip master playlists
|
|
if "#EXT-X-STREAM-INF" not in content:
|
|
segment_urls = self._extract_segment_urls_from_content(content, self.playlist_url)
|
|
|
|
if segment_urls:
|
|
headers = {}
|
|
for key, value in self.request.query_params.items():
|
|
if key.startswith("h_"):
|
|
headers[key[2:]] = value
|
|
|
|
logger.info(
|
|
f"[M3U8Processor] Registering playlist ({len(segment_urls)} segments): {self.playlist_url}"
|
|
)
|
|
asyncio.create_task(
|
|
hls_prebuffer.register_playlist(
|
|
self.playlist_url,
|
|
segment_urls,
|
|
headers,
|
|
)
|
|
)
|
|
|
|
return "\n".join(processed_lines)
|
|
|
|
def _parse_extinf_duration(self, line: str) -> float:
|
|
"""
|
|
Parse the duration from an #EXTINF line.
|
|
|
|
Args:
|
|
line: The #EXTINF line (e.g., "#EXTINF:10.0," or "#EXTINF:10,title")
|
|
|
|
Returns:
|
|
The duration in seconds as a float.
|
|
"""
|
|
# Format: #EXTINF:<duration>[,<title>]
|
|
match = re.match(r"#EXTINF:(\d+(?:\.\d+)?)", line)
|
|
if match:
|
|
return float(match.group(1))
|
|
return 0.0
|
|
|
|
async def process_m3u8_streaming(
|
|
self, content_iterator: AsyncGenerator[bytes, None], base_url: str
|
|
) -> AsyncGenerator[str, None]:
|
|
"""
|
|
Processes the m3u8 content on-the-fly, yielding processed lines as they are read.
|
|
Optimized to avoid accumulating the entire playlist content in memory.
|
|
|
|
Note: When skip_segments are configured, this method buffers lines to properly
|
|
handle EXTINF + segment URL pairs that need to be skipped together.
|
|
|
|
Args:
|
|
content_iterator: An async iterator that yields chunks of the m3u8 content.
|
|
base_url (str): The base URL to resolve relative URLs.
|
|
|
|
Yields:
|
|
str: Processed lines of the m3u8 content.
|
|
|
|
Raises:
|
|
ValueError: If the content is not a valid m3u8 playlist (e.g., HTML error page).
|
|
"""
|
|
# Store the playlist URL for prebuffering
|
|
self.playlist_url = base_url
|
|
|
|
buffer = "" # String buffer for decoded content
|
|
raw_content = "" # Accumulate raw content for prebuffer
|
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
is_playlist_detected = False
|
|
is_html_detected = False
|
|
initial_check_done = False
|
|
|
|
# State for skip segment filtering
|
|
discontinuity_pending = False # Track if we need discontinuity before next URL
|
|
pending_extinf = None # Buffer EXTINF line until we decide to emit it
|
|
# Track if we've injected EXT-X-START tag
|
|
start_offset_injected = False
|
|
# Buffer header lines until we know if it's a master playlist (for default start_offset)
|
|
header_buffer = []
|
|
header_flushed = False
|
|
|
|
# Process the content chunk by chunk
|
|
async for chunk in content_iterator:
|
|
if isinstance(chunk, str):
|
|
chunk = chunk.encode("utf-8")
|
|
|
|
# Incrementally decode the chunk
|
|
decoded_chunk = decoder.decode(chunk)
|
|
buffer += decoded_chunk
|
|
raw_content += decoded_chunk # Accumulate for prebuffer
|
|
|
|
# Early detection: check if this is HTML instead of m3u8
|
|
# This helps catch upstream error pages quickly
|
|
if not initial_check_done and len(buffer) > 50:
|
|
initial_check_done = True
|
|
buffer_lower = buffer.lower().strip()
|
|
# Check for HTML markers
|
|
if buffer_lower.startswith("<!doctype") or buffer_lower.startswith("<html"):
|
|
is_html_detected = True
|
|
logger.error(f"Upstream returned HTML instead of m3u8 playlist: {base_url}")
|
|
# Raise an error so the HTTP handler returns a proper error response
|
|
# This allows the player to retry or show an error instead of thinking
|
|
# the stream has ended normally
|
|
raise ValueError(
|
|
f"Upstream returned HTML instead of m3u8 playlist. "
|
|
f"The stream may be offline or unavailable: {base_url}"
|
|
)
|
|
|
|
# Check for playlist marker early to avoid accumulating content
|
|
if not is_playlist_detected and "#EXTM3U" in buffer:
|
|
is_playlist_detected = True
|
|
|
|
# Process complete lines
|
|
lines = buffer.split("\n")
|
|
if len(lines) > 1:
|
|
# Process all complete lines except the last one
|
|
for line in lines[:-1]:
|
|
if not line: # Skip empty lines
|
|
continue
|
|
|
|
# Buffer header lines until we can determine playlist type
|
|
# This allows us to decide whether to inject EXT-X-START
|
|
if not header_flushed:
|
|
# Always buffer the current line first
|
|
header_buffer.append(line)
|
|
|
|
# Check if we can now determine playlist type
|
|
# Only check the current line, not raw_content (which may contain future content)
|
|
is_master = "#EXT-X-STREAM-INF" in line
|
|
is_media = "#EXTINF" in line
|
|
|
|
if is_master or is_media:
|
|
# For non-user-provided (default) start_offset, determine if this
|
|
# is a live stream before injecting. We need to avoid injecting
|
|
# EXT-X-START with negative offsets into VOD playlists, as players
|
|
# like VLC interpret negative offsets as "from the end" and start
|
|
# playing near the end of the video.
|
|
#
|
|
# Live stream indicators (checked in header):
|
|
# - No #EXT-X-PLAYLIST-TYPE:VOD tag
|
|
# - No #EXT-X-ENDLIST tag (may not be visible yet in streaming)
|
|
# - #EXT-X-MEDIA-SEQUENCE > 0 (live windows have rolling sequence)
|
|
#
|
|
# VOD indicators:
|
|
# - #EXT-X-PLAYLIST-TYPE:VOD in header
|
|
# - #EXT-X-ENDLIST in raw_content (if small enough to be buffered)
|
|
# - #EXT-X-MEDIA-SEQUENCE:0 or absent (VOD starts from beginning)
|
|
header_content = "\n".join(header_buffer)
|
|
all_content = header_content + "\n" + raw_content
|
|
|
|
is_explicitly_vod = (
|
|
"#EXT-X-PLAYLIST-TYPE:VOD" in all_content or "#EXT-X-ENDLIST" in all_content
|
|
)
|
|
|
|
# Check for live stream indicator: #EXT-X-MEDIA-SEQUENCE with value > 0
|
|
# Live streams have a rolling window so their media sequence increments
|
|
is_likely_live = False
|
|
seq_match = re.search(r"#EXT-X-MEDIA-SEQUENCE:\s*(\d+)", all_content)
|
|
if seq_match and int(seq_match.group(1)) > 0:
|
|
is_likely_live = True
|
|
|
|
# Flush header buffer with or without EXT-X-START
|
|
should_inject = (
|
|
self._start_offset_value is not None
|
|
and not is_master
|
|
and (
|
|
self._user_provided_start_offset
|
|
or (is_media and not is_explicitly_vod and is_likely_live)
|
|
) # User provided OR it's a live media playlist
|
|
)
|
|
|
|
for header_line in header_buffer:
|
|
# Process header lines to rewrite URLs (e.g., #EXT-X-KEY)
|
|
processed_header_line = await self.process_line(header_line, base_url)
|
|
yield processed_header_line + "\n"
|
|
if header_line.strip() == "#EXTM3U" and should_inject and not start_offset_injected:
|
|
yield f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES\n"
|
|
start_offset_injected = True
|
|
|
|
header_buffer = []
|
|
header_flushed = True
|
|
# If not master/media yet, continue buffering (line already added above)
|
|
continue
|
|
|
|
# If user explicitly provided start_offset and we haven't injected yet
|
|
# (handles edge case where we flush header before seeing EXTINF/STREAM-INF)
|
|
if (
|
|
line.strip() == "#EXTM3U"
|
|
and self._user_provided_start_offset
|
|
and self._start_offset_value is not None
|
|
and not start_offset_injected
|
|
):
|
|
yield line + "\n"
|
|
yield f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES\n"
|
|
start_offset_injected = True
|
|
continue
|
|
|
|
# Handle segment filtering if skip_segments are configured
|
|
if self.skip_filter.has_skip_segments():
|
|
result = await self._process_line_with_filtering(
|
|
line, base_url, discontinuity_pending, pending_extinf
|
|
)
|
|
processed_line, discontinuity_pending, pending_extinf = result
|
|
if processed_line is not None:
|
|
yield processed_line + "\n"
|
|
else:
|
|
# No filtering, process normally
|
|
processed_line = await self.process_line(line, base_url)
|
|
yield processed_line + "\n"
|
|
|
|
# Keep the last line in the buffer (it might be incomplete)
|
|
buffer = lines[-1]
|
|
|
|
# If HTML was detected, we already returned an error playlist
|
|
if is_html_detected:
|
|
return
|
|
|
|
# Flush any remaining header buffer (for short playlists or edge cases)
|
|
# At this point we have the full raw_content so we can make a definitive determination
|
|
if header_buffer and not header_flushed:
|
|
is_master = "#EXT-X-STREAM-INF" in raw_content
|
|
is_vod = "#EXT-X-ENDLIST" in raw_content or "#EXT-X-PLAYLIST-TYPE:VOD" in raw_content
|
|
# For default offset, also require positive live indicator
|
|
is_likely_live = False
|
|
seq_match = re.search(r"#EXT-X-MEDIA-SEQUENCE:\s*(\d+)", raw_content)
|
|
if seq_match and int(seq_match.group(1)) > 0:
|
|
is_likely_live = True
|
|
should_inject = (
|
|
self._start_offset_value is not None
|
|
and not is_master
|
|
and (
|
|
self._user_provided_start_offset
|
|
or (not is_vod and is_likely_live) # Default offset: only inject for live streams
|
|
)
|
|
)
|
|
for header_line in header_buffer:
|
|
yield header_line + "\n"
|
|
if header_line.strip() == "#EXTM3U" and should_inject and not start_offset_injected:
|
|
yield f"#EXT-X-START:TIME-OFFSET={self._start_offset_value:.1f},PRECISE=YES\n"
|
|
start_offset_injected = True
|
|
header_buffer = []
|
|
|
|
# Process any remaining data in the buffer plus final bytes
|
|
final_chunk = decoder.decode(b"", final=True)
|
|
if final_chunk:
|
|
buffer += final_chunk
|
|
|
|
# Final validation: if we never detected a valid m3u8 playlist marker
|
|
if not is_playlist_detected:
|
|
logger.error(f"Invalid m3u8 content from upstream (no #EXTM3U marker found): {base_url}")
|
|
yield "#EXTM3U\n"
|
|
yield "#EXT-X-PLAYLIST-TYPE:VOD\n"
|
|
yield "# ERROR: Invalid m3u8 content from upstream (no #EXTM3U marker found)\n"
|
|
yield "# The upstream server may have returned an error page\n"
|
|
yield "#EXT-X-ENDLIST\n"
|
|
return
|
|
|
|
if buffer: # Process the last line if it's not empty
|
|
if self.skip_filter.has_skip_segments():
|
|
result = await self._process_line_with_filtering(
|
|
buffer, base_url, discontinuity_pending, pending_extinf
|
|
)
|
|
processed_line, _, _ = result
|
|
if processed_line is not None:
|
|
yield processed_line
|
|
else:
|
|
processed_line = await self.process_line(buffer, base_url)
|
|
yield processed_line
|
|
|
|
# Log skip statistics
|
|
if self.skip_filter.has_skip_segments():
|
|
logger.info(f"Content filtering: processed playlist with {len(self.skip_filter.skip_segments)} skip ranges")
|
|
|
|
# Register playlist with the priority-based prefetcher
|
|
# The prefetcher uses a smart approach:
|
|
# 1. When player requests a segment, it gets priority (downloaded first)
|
|
# 2. After serving priority segment, prefetcher continues sequentially
|
|
# 3. Multiple users watching same channel share the prefetcher
|
|
# 4. Inactive prefetchers are cleaned up automatically
|
|
if settings.enable_hls_prebuffer and is_playlist_detected and self.playlist_url and raw_content:
|
|
# Skip master playlists (they contain variant streams, not segments)
|
|
if "#EXT-X-STREAM-INF" not in raw_content:
|
|
# Extract segment URLs from the playlist
|
|
segment_urls = self._extract_segment_urls_from_content(raw_content, self.playlist_url)
|
|
|
|
if segment_urls:
|
|
# Extract headers for prefetcher
|
|
headers = {}
|
|
for key, value in self.request.query_params.items():
|
|
if key.startswith("h_"):
|
|
headers[key[2:]] = value
|
|
|
|
logger.info(
|
|
f"[M3U8Processor] Registering playlist ({len(segment_urls)} segments): {self.playlist_url}"
|
|
)
|
|
asyncio.create_task(
|
|
hls_prebuffer.register_playlist(
|
|
self.playlist_url,
|
|
segment_urls,
|
|
headers,
|
|
)
|
|
)
|
|
|
|
async def _process_line_with_filtering(
|
|
self, line: str, base_url: str, discontinuity_pending: bool, pending_extinf: Optional[str]
|
|
) -> tuple:
|
|
"""
|
|
Process a single line with segment filtering (skip/mute/black).
|
|
|
|
Uses the IntroHater approach: discontinuity is added BEFORE the URL of the
|
|
first segment after a skipped section, not before the EXTINF.
|
|
|
|
Returns a tuple of (processed_lines, discontinuity_pending, pending_extinf).
|
|
processed_lines is None if the line should be skipped, otherwise a string to output.
|
|
"""
|
|
# Handle EXTINF lines (segment duration markers)
|
|
if line.startswith("#EXTINF:"):
|
|
duration = self._parse_extinf_duration(line)
|
|
|
|
if self.skip_filter.should_skip_segment(duration):
|
|
# Skip this segment - don't buffer the EXTINF
|
|
self.skip_filter.advance_time(duration)
|
|
return (None, True, None) # discontinuity_pending = True, clear pending
|
|
else:
|
|
# Keep this segment
|
|
self.skip_filter.advance_time(duration)
|
|
return (None, discontinuity_pending, line) # Buffer EXTINF
|
|
|
|
# Handle segment URLs (non-comment, non-empty lines)
|
|
if not line.startswith("#") and line.strip():
|
|
if pending_extinf is None:
|
|
# No pending EXTINF means this segment was skipped
|
|
return (None, discontinuity_pending, None)
|
|
|
|
# Build output: optional discontinuity + EXTINF + URL
|
|
# Per HLS spec, EXT-X-DISCONTINUITY must appear before the first segment of the new content
|
|
processed_url = await self.proxy_content_url(line, base_url)
|
|
|
|
output_lines = []
|
|
if discontinuity_pending:
|
|
output_lines.append("#EXT-X-DISCONTINUITY")
|
|
output_lines.append(pending_extinf)
|
|
output_lines.append(processed_url)
|
|
|
|
return ("\n".join(output_lines), False, None)
|
|
|
|
# Handle existing discontinuity markers - pass through and reset pending
|
|
if line.startswith("#EXT-X-DISCONTINUITY"):
|
|
return (line, False, pending_extinf)
|
|
|
|
# Handle key lines
|
|
if "URI=" in line:
|
|
processed = await self.process_key_line(line, base_url)
|
|
return (processed, discontinuity_pending, pending_extinf)
|
|
|
|
# All other lines (headers, comments, etc.)
|
|
return (line, discontinuity_pending, pending_extinf)
|
|
|
|
async def process_line(self, line: str, base_url: str) -> str:
|
|
"""
|
|
Process a single line from the m3u8 content.
|
|
|
|
Args:
|
|
line (str): The line to process.
|
|
base_url (str): The base URL to resolve relative URLs.
|
|
|
|
Returns:
|
|
str: The processed line.
|
|
"""
|
|
if "URI=" in line:
|
|
return await self.process_key_line(line, base_url)
|
|
elif not line.startswith("#") and line.strip():
|
|
return await self.proxy_content_url(line, base_url)
|
|
else:
|
|
return line
|
|
|
|
async def process_key_line(self, line: str, base_url: str) -> str:
|
|
"""
|
|
Processes a key line in the m3u8 content, proxying the URI.
|
|
|
|
Args:
|
|
line (str): The key line to process.
|
|
base_url (str): The base URL to resolve relative URLs.
|
|
|
|
Returns:
|
|
str: The processed key line.
|
|
"""
|
|
# If no_proxy is enabled, just resolve relative URLs without proxying
|
|
if self.no_proxy:
|
|
uri_match = re.search(r'URI="([^"]+)"', line)
|
|
if uri_match:
|
|
original_uri = uri_match.group(1)
|
|
full_url = parse.urljoin(base_url, original_uri)
|
|
line = line.replace(f'URI="{original_uri}"', f'URI="{full_url}"')
|
|
return line
|
|
|
|
uri_match = re.search(r'URI="([^"]+)"', line)
|
|
if uri_match:
|
|
original_uri = uri_match.group(1)
|
|
uri = parse.urlparse(original_uri)
|
|
# Only substitute key_url scheme/netloc for actual EXT-X-KEY lines.
|
|
# EXT-X-MAP (init segments) and other tags must keep their original host,
|
|
# otherwise the proxied destination URL gets the wrong upstream hostname.
|
|
if self.key_url and line.startswith("#EXT-X-KEY"):
|
|
uri = uri._replace(scheme=self.key_url.scheme, netloc=self.key_url.netloc)
|
|
# Check if this is a DLHD stream with key params (needs stream endpoint for header computation)
|
|
query_params = dict(self.request.query_params)
|
|
is_dlhd_key_request = "dlhd_salt" in query_params and "/key/" in uri.geturl()
|
|
# Use stream endpoint for DLHD key URLs, manifest endpoint for others
|
|
new_uri = await self.proxy_url(
|
|
uri.geturl(), base_url, use_full_url=True, is_playlist=not is_dlhd_key_request
|
|
)
|
|
line = line.replace(f'URI="{original_uri}"', f'URI="{new_uri}"')
|
|
return line
|
|
|
|
async def proxy_content_url(self, url: str, base_url: str) -> str:
|
|
"""
|
|
Proxies a content URL based on the configured routing strategy.
|
|
|
|
Args:
|
|
url (str): The URL to proxy.
|
|
base_url (str): The base URL to resolve relative URLs.
|
|
|
|
Returns:
|
|
str: The proxied URL.
|
|
"""
|
|
full_url = parse.urljoin(base_url, url)
|
|
|
|
# If no_proxy is enabled, return the direct URL without any proxying
|
|
if self.no_proxy:
|
|
return full_url
|
|
|
|
# If key_only_proxy is enabled, return the direct URL for segments
|
|
if self.key_only_proxy and not url.endswith((".m3u", ".m3u8")):
|
|
return full_url
|
|
|
|
# Determine routing strategy based on configuration
|
|
routing_strategy = settings.m3u8_content_routing
|
|
|
|
# Check if we should force MediaFlow proxy for all playlist URLs
|
|
if self.force_playlist_proxy:
|
|
return await self.proxy_url(full_url, base_url, use_full_url=True, is_playlist=True)
|
|
|
|
# For playlist URLs, always use MediaFlow proxy regardless of strategy
|
|
# Check for actual playlist file extensions, not just substring matches
|
|
parsed_url = parse.urlparse(full_url)
|
|
is_playlist_url = parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or parse.parse_qs(
|
|
parsed_url.query
|
|
).get("type", [""])[0] in ["m3u", "m3u8", "m3u_plus"]
|
|
|
|
if is_playlist_url:
|
|
return await self.proxy_url(full_url, base_url, use_full_url=True, is_playlist=True)
|
|
|
|
# Route non-playlist content URLs (segments) based on strategy
|
|
if routing_strategy == "direct":
|
|
# Return the URL directly without any proxying
|
|
return full_url
|
|
elif routing_strategy == "stremio" and settings.stremio_proxy_url:
|
|
# Use Stremio proxy for content URLs
|
|
query_params = dict(self.request.query_params)
|
|
request_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("h_")}
|
|
response_headers = {k[2:]: v for k, v in query_params.items() if k.startswith("r_")}
|
|
|
|
return encode_stremio_proxy_url(
|
|
settings.stremio_proxy_url,
|
|
full_url,
|
|
request_headers=request_headers if request_headers else None,
|
|
response_headers=response_headers if response_headers else None,
|
|
)
|
|
else:
|
|
# Default to MediaFlow proxy (routing_strategy == "mediaflow" or fallback)
|
|
# Use stream endpoint for segment URLs
|
|
return await self.proxy_url(full_url, base_url, use_full_url=True, is_playlist=False)
|
|
|
|
def _extract_segment_urls_from_content(self, content: str, base_url: str) -> list:
|
|
"""
|
|
Extract segment URLs from HLS playlist content.
|
|
|
|
Args:
|
|
content: Raw playlist content
|
|
base_url: Base URL for resolving relative URLs
|
|
|
|
Returns:
|
|
List of absolute segment URLs
|
|
"""
|
|
segment_urls = []
|
|
for line in content.split("\n"):
|
|
line = line.strip()
|
|
if line and not line.startswith("#"):
|
|
# Absolute URL
|
|
if line.startswith("http://") or line.startswith("https://"):
|
|
segment_urls.append(line)
|
|
else:
|
|
# Relative URL - resolve against base
|
|
segment_urls.append(parse.urljoin(base_url, line))
|
|
return segment_urls
|
|
|
|
async def proxy_url(self, url: str, base_url: str, use_full_url: bool = False, is_playlist: bool = True) -> str:
|
|
"""
|
|
Proxies a URL, encoding it with the MediaFlow proxy URL.
|
|
|
|
Args:
|
|
url (str): The URL to proxy.
|
|
base_url (str): The base URL to resolve relative URLs.
|
|
use_full_url (bool): Whether to use the URL as-is (True) or join with base_url (False).
|
|
is_playlist (bool): Whether this is a playlist URL (uses manifest endpoint) or segment URL (uses stream endpoint).
|
|
|
|
Returns:
|
|
str: The proxied URL.
|
|
"""
|
|
if use_full_url:
|
|
full_url = url
|
|
else:
|
|
full_url = parse.urljoin(base_url, url)
|
|
|
|
query_params = dict(self.request.query_params)
|
|
has_encrypted = query_params.pop("has_encrypted", False)
|
|
# Remove the response headers (r_) from the query params to avoid it being added to the consecutive requests
|
|
# BUT keep rp_ (response propagate) headers as they should propagate to segments
|
|
[
|
|
query_params.pop(key, None)
|
|
for key in list(query_params.keys())
|
|
if key.lower().startswith("r_") and not key.lower().startswith("rp_")
|
|
]
|
|
# Remove manifest-only parameters to avoid them being added to subsequent requests
|
|
query_params.pop("force_playlist_proxy", None)
|
|
if not is_playlist:
|
|
query_params.pop("start_offset", None)
|
|
|
|
# Use appropriate proxy URL based on content type
|
|
if is_playlist:
|
|
proxy_url = self.mediaflow_proxy_url
|
|
else:
|
|
# Check if this is a DLHD key URL (needs /stream endpoint for header computation)
|
|
is_dlhd_key = "dlhd_salt" in query_params and "/key/" in full_url
|
|
if is_dlhd_key:
|
|
# Use /stream endpoint for DLHD key URLs
|
|
proxy_url = self.mediaflow_proxy_url.replace("/hls/manifest.m3u8", "/stream")
|
|
else:
|
|
# Determine segment extension from the URL
|
|
# Default to .ts for traditional HLS, but detect fMP4 extensions
|
|
segment_ext = "ts"
|
|
url_lower = full_url.lower()
|
|
# Check for fMP4/CMAF extensions
|
|
if url_lower.endswith(".m4s"):
|
|
segment_ext = "m4s"
|
|
elif url_lower.endswith(".mp4"):
|
|
segment_ext = "mp4"
|
|
elif url_lower.endswith(".m4a"):
|
|
segment_ext = "m4a"
|
|
elif url_lower.endswith(".m4v"):
|
|
segment_ext = "m4v"
|
|
elif url_lower.endswith(".aac"):
|
|
segment_ext = "aac"
|
|
# Build segment proxy URL with correct extension
|
|
proxy_url = f"{self.segment_proxy_base_url}.{segment_ext}"
|
|
# Remove h_range header - each segment should handle its own range requests
|
|
query_params.pop("h_range", None)
|
|
|
|
return encode_mediaflow_proxy_url(
|
|
proxy_url,
|
|
None, # No endpoint - URL is already complete
|
|
full_url,
|
|
query_params=query_params,
|
|
encryption_handler=encryption_handler if has_encrypted else None,
|
|
)
|