This commit is contained in:
UrloMythus
2026-02-19 20:15:03 +01:00
parent 7785e8c604
commit cfc6bbabc9
181 changed files with 32141 additions and 4629 deletions

View File

@@ -1,7 +1,9 @@
import asyncio
import codecs
import logging
import re
from typing import AsyncGenerator
from typing import AsyncGenerator, List, Optional
from urllib import parse
from mediaflow_proxy.configs import settings
@@ -9,9 +11,121 @@ 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):
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.
@@ -21,21 +135,65 @@ class M3U8Processor:
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.
@@ -45,35 +203,131 @@ class M3U8Processor:
"""
# Store the playlist URL for prebuffering
self.playlist_url = base_url
lines = content.splitlines()
processed_lines = []
for line in 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))
elif not line.startswith("#") and line.strip():
processed_lines.append(await self.proxy_content_url(line, base_url))
else:
processed_lines.append(line)
# Pre-buffer segments if enabled and this is a playlist
if (settings.enable_hls_prebuffer and
"#EXTM3U" in content and
self.playlist_url):
# Extract headers from request for pre-buffering
headers = {}
for key, value in self.request.query_params.items():
if key.startswith("h_"):
headers[key[2:]] = value
# Start pre-buffering in background using the actual playlist URL
asyncio.create_task(
hls_prebuffer.prebuffer_playlist(self.playlist_url, headers)
)
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]:
@@ -81,20 +335,37 @@ class M3U8Processor:
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_prebuffer_started = 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:
@@ -104,6 +375,24 @@ class M3U8Processor:
# 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:
@@ -114,40 +403,246 @@ class M3U8Processor:
if len(lines) > 1:
# Process all complete lines except the last one
for line in lines[:-1]:
if line: # Skip empty lines
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]
# Start pre-buffering early once we detect this is a playlist
# This avoids waiting until the entire playlist is processed
if (settings.enable_hls_prebuffer and
is_playlist_detected and
not is_prebuffer_started and
self.playlist_url):
# Extract headers from request for pre-buffering
headers = {}
for key, value in self.request.query_params.items():
if key.startswith("h_"):
headers[key[2:]] = value
# Start pre-buffering in background using the actual playlist URL
asyncio.create_task(
hls_prebuffer.prebuffer_playlist(self.playlist_url, headers)
# 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
)
is_prebuffer_started = True
)
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
processed_line = await self.process_line(buffer, base_url)
yield processed_line
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:
"""
@@ -186,14 +681,23 @@ class M3U8Processor:
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)
if self.key_url:
# 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)
new_uri = await self.proxy_url(uri.geturl(), base_url)
# 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
@@ -223,16 +727,19 @@ class M3U8Processor:
# 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)
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)
if (parsed_url.path.endswith((".m3u", ".m3u8", ".m3u_plus")) or
parse.parse_qs(parsed_url.query).get("type", [""])[0] in ["m3u", "m3u8", "m3u_plus"]):
return await self.proxy_url(full_url, base_url, use_full_url=True)
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"]
# Route non-playlist content URLs based on strategy
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
@@ -250,9 +757,33 @@ class M3U8Processor:
)
else:
# Default to MediaFlow proxy (routing_strategy == "mediaflow" or fallback)
return await self.proxy_url(full_url, base_url, use_full_url=True)
# Use stream endpoint for segment URLs
return await self.proxy_url(full_url, base_url, use_full_url=True, is_playlist=False)
async def proxy_url(self, url: str, base_url: str, use_full_url: bool = False) -> str:
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.
@@ -260,6 +791,7 @@ class M3U8Processor:
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.
@@ -271,15 +803,52 @@ class M3U8Processor:
query_params = dict(self.request.query_params)
has_encrypted = query_params.pop("has_encrypted", False)
# Remove the response headers from the query params to avoid it being added to the consecutive requests
[query_params.pop(key, None) for key in list(query_params.keys()) if key.startswith("r_")]
# Remove force_playlist_proxy to avoid it being added to subsequent requests
# 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(
self.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,
)
)