diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 32ebc3e3..15479379 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -29,6 +29,7 @@ from core.models import CoreSettings, UserAgent from asgiref.sync import async_to_sync from core.xtream_codes import Client as XCClient from core.utils import send_websocket_update +from .utils import normalize_stream_url logger = logging.getLogger(__name__) @@ -219,10 +220,10 @@ def fetch_m3u_lines(account, use_cache=False): # Has HTTP URLs, might be a simple M3U without headers is_valid_m3u = True logger.info("Content validated as M3U: contains HTTP URLs") - elif any(line.strip().startswith('rtsp') for line in content_lines): - # Has HTTP URLs, might be a simple M3U without headers + elif any(line.strip().startswith(('rtsp', 'rtp', 'udp')) for line in content_lines): + # Has RTSP/RTP/UDP URLs, might be a simple M3U without headers is_valid_m3u = True - logger.info("Content validated as M3U: contains RTSP URLs") + logger.info("Content validated as M3U: contains RTSP/RTP/UDP URLs") if not is_valid_m3u: # Log what we actually received for debugging @@ -1403,10 +1404,12 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): ) problematic_lines.append((line_index + 1, line[:200])) - elif extinf_data and (line.startswith("http") or line.startswith("rtsp")): + elif extinf_data and (line.startswith("http") or line.startswith("rtsp") or line.startswith("rtp") or line.startswith("udp")): url_count += 1 + # Normalize UDP URLs only (e.g., remove VLC-specific @ prefix) + normalized_url = normalize_stream_url(line) if line.startswith("udp") else line # Associate URL with the last EXTINF line - extinf_data[-1]["url"] = line + extinf_data[-1]["url"] = normalized_url valid_stream_count += 1 # Periodically log progress for large files diff --git a/apps/m3u/utils.py b/apps/m3u/utils.py index 4e1027b2..598ef713 100644 --- a/apps/m3u/utils.py +++ b/apps/m3u/utils.py @@ -8,6 +8,34 @@ lock = threading.Lock() active_streams_map = {} logger = logging.getLogger(__name__) + +def normalize_stream_url(url): + """ + Normalize stream URLs for compatibility with FFmpeg. + + Handles VLC-specific syntax like udp://@239.0.0.1:1234 by removing the @ symbol. + FFmpeg doesn't recognize the @ prefix for multicast addresses. + + Args: + url (str): The stream URL to normalize + + Returns: + str: The normalized URL + """ + if not url: + return url + + # Handle VLC-style UDP multicast URLs: udp://@239.0.0.1:1234 -> udp://239.0.0.1:1234 + # The @ symbol in VLC means "listen on all interfaces" but FFmpeg doesn't use this syntax + if url.startswith('udp://@'): + normalized = url.replace('udp://@', 'udp://', 1) + logger.debug(f"Normalized VLC-style UDP URL: {url} -> {normalized}") + return normalized + + # Could add other normalizations here in the future (rtp://@, etc.) + return url + + def increment_stream_count(account): with lock: current_usage = active_streams_map.get(account.id, 0) diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py index 436062ce..7baa9e1c 100644 --- a/apps/proxy/ts_proxy/constants.py +++ b/apps/proxy/ts_proxy/constants.py @@ -34,6 +34,7 @@ class EventType: class StreamType: HLS = "hls" RTSP = "rtsp" + UDP = "udp" TS = "ts" UNKNOWN = "unknown" diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index 83f05c5a..3b81a34e 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -228,11 +228,11 @@ class StreamManager: # Check stream type before connecting stream_type = detect_stream_type(self.url) - if self.transcode == False and stream_type in (StreamType.HLS, StreamType.RTSP): - stream_type_name = "HLS" if stream_type == StreamType.HLS else "RTSP/RTP" + if self.transcode == False and stream_type in (StreamType.HLS, StreamType.RTSP, StreamType.UDP): + stream_type_name = "HLS" if stream_type == StreamType.HLS else ("RTSP/RTP" if stream_type == StreamType.RTSP else "UDP") logger.info(f"Detected {stream_type_name} stream: {self.url} for channel {self.channel_id}") logger.info(f"{stream_type_name} streams require FFmpeg for channel {self.channel_id}") - # Enable transcoding for HLS and RTSP/RTP streams + # Enable transcoding for HLS, RTSP/RTP, and UDP streams self.transcode = True # We'll override the stream profile selection with ffmpeg in the transcoding section self.force_ffmpeg = True @@ -421,7 +421,7 @@ class StreamManager: from core.models import StreamProfile try: stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True) - logger.info("Using FFmpeg stream profile for HLS content") + logger.info("Using FFmpeg stream profile for unsupported proxy content (HLS/RTSP/UDP)") except StreamProfile.DoesNotExist: # Fall back to channel's profile if FFmpeg not found stream_profile = channel.get_stream_profile() @@ -949,10 +949,10 @@ class StreamManager: logger.debug(f"Updated m3u profile for channel {self.channel_id} to use profile from stream {stream_id}") else: logger.warning(f"Failed to update stream profile for channel {self.channel_id}") - + except Exception as e: logger.error(f"Error updating stream profile for channel {self.channel_id}: {e}") - + finally: # Always close database connection after profile update try: diff --git a/apps/proxy/ts_proxy/utils.py b/apps/proxy/ts_proxy/utils.py index 704057a3..20a6e140 100644 --- a/apps/proxy/ts_proxy/utils.py +++ b/apps/proxy/ts_proxy/utils.py @@ -7,20 +7,24 @@ logger = logging.getLogger("ts_proxy") def detect_stream_type(url): """ - Detect if stream URL is HLS, RTSP/RTP, or TS format. + Detect if stream URL is HLS, RTSP/RTP, UDP, or TS format. Args: url (str): The stream URL to analyze Returns: - str: 'hls', 'rtsp', or 'ts' depending on detected format + str: 'hls', 'rtsp', 'udp', or 'ts' depending on detected format """ if not url: return 'unknown' url_lower = url.lower() - # Check for RTSP/RTP streams first (requires FFmpeg) + # Check for UDP streams (requires FFmpeg) + if url_lower.startswith('udp://'): + return 'udp' + + # Check for RTSP/RTP streams (requires FFmpeg) if url_lower.startswith('rtsp://') or url_lower.startswith('rtp://'): return 'rtsp' diff --git a/core/utils.py b/core/utils.py index fac8a557..f21e734b 100644 --- a/core/utils.py +++ b/core/utils.py @@ -377,13 +377,13 @@ def validate_flexible_url(value): import re # More flexible pattern for non-FQDN hostnames with paths - # Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1 - # Also matches FQDNs for rtsp/rtp protocols: rtsp://FQDN/path?query=value - non_fqdn_pattern = r'^(rts?p|https?)://([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' + # Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234 + # Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value + non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' non_fqdn_match = re.match(non_fqdn_pattern, value) if non_fqdn_match: - return # Accept non-FQDN hostnames and rtsp/rtp URLs + return # Accept non-FQDN hostnames and rtsp/rtp/udp URLs # If it doesn't match our flexible patterns, raise the original error raise ValidationError("Enter a valid URL.")