From 18a6c428c1a92142317bcf367501a63b231ba650 Mon Sep 17 00:00:00 2001 From: Marlon Alkan Date: Sun, 8 Jun 2025 19:26:34 +0200 Subject: [PATCH 01/12] core: api_views.py: add fallback IP geo provider Fixes #127 - add ip-api.com as fallback geo provider - fix silent JSONException by parsing only if HTTP 200 OK - add logger and log error if IP geo can't be fetched --- core/api_views.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/core/api_views.py b/core/api_views.py index 77473b5d..9efefe12 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -1,5 +1,6 @@ # core/api_views.py +import logging from rest_framework import viewsets, status from rest_framework.response import Response from django.shortcuts import get_object_or_404 @@ -13,6 +14,8 @@ import requests import os from core.tasks import rehash_streams +logger = logging.getLogger(__name__) + class UserAgentViewSet(viewsets.ModelViewSet): """ API endpoint that allows user agents to be viewed, created, edited, or deleted. @@ -77,14 +80,32 @@ def environment(request): except Exception as e: local_ip = f"Error: {e}" - # 3) If we got a valid public_ip, fetch geo info from ipapi.co + # 3) If we got a valid public_ip, fetch geo info from ipapi.co or ip-api.com if public_ip and "Error" not in public_ip: try: - geo = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5).json() - # ipapi returns fields like country_code, country_name, etc. - country_code = geo.get("country_code", "") # e.g. "US" - country_name = geo.get("country_name", "") # e.g. "United States" - except requests.RequestException as e: + # Attempt to get geo information from ipapi.co first + r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5) + + if r.status_code == requests.codes.ok: + geo = r.json() + country_code = geo.get("country_code") # e.g. "US" + country_name = geo.get("country_name") # e.g. "United States" + + else: + # If ipapi.co fails, fallback to ip-api.com + # only supports http requests for free tier + r = requests.get("http://ip-api.com/json/", timeout=5) + + if r.status_code == requests.codes.ok: + geo = r.json() + country_code = geo.get("countryCode") # e.g. "US" + country_name = geo.get("country") # e.g. "United States" + + else: + raise Exception("Geo lookup failed with both services") + + except Exception as e: + logger.error(f"Error during geo lookup: {e}") country_code = None country_name = None From efaa64d00b10324fc254982e3e173d177ea05dad Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 09:08:04 -0500 Subject: [PATCH 02/12] Fix resolution not always parsing correctly. --- apps/proxy/ts_proxy/services/channel_service.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 761d56ac..42f5efee 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -428,12 +428,17 @@ class ChannelService: codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', stream_info_line) video_codec = codec_match.group(1) if codec_match else None - # Extract resolution (e.g., "1280x720") - resolution_match = re.search(r'(\d+)x(\d+)', stream_info_line) + # Extract resolution (e.g., "1280x720") - be more specific to avoid hex values + # Look for resolution patterns that are realistic video dimensions + resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', stream_info_line) if resolution_match: width = int(resolution_match.group(1)) height = int(resolution_match.group(2)) - resolution = f"{width}x{height}" + # Validate that these look like reasonable video dimensions + if 100 <= width <= 10000 and 100 <= height <= 10000: + resolution = f"{width}x{height}" + else: + width = height = resolution = None else: width = height = resolution = None From b8992bde641a6bbdf9827f774d3f7ffff0d09a38 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 09:23:43 -0500 Subject: [PATCH 03/12] Add audio channels to stats page. --- apps/proxy/ts_proxy/channel_status.py | 3 +++ frontend/src/pages/Stats.jsx | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py index 864ddac8..77b4482d 100644 --- a/apps/proxy/ts_proxy/channel_status.py +++ b/apps/proxy/ts_proxy/channel_status.py @@ -494,6 +494,9 @@ class ChannelStatus: audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC.encode('utf-8')) if audio_codec: info['audio_codec'] = audio_codec.decode('utf-8') + audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS.encode('utf-8')) + if audio_channels: + info['audio_channels'] = audio_channels.decode('utf-8') return info except Exception as e: diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ab13c1e7..2b3fd8bb 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -497,6 +497,11 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel {channel.audio_codec.toUpperCase()} )} + {channel.audio_channels && ( + + {channel.audio_channels} + + )} {channel.ffmpeg_speed && ( Date: Tue, 10 Jun 2025 09:51:56 -0500 Subject: [PATCH 04/12] Add stream type for stream (HLS/MPEGTS, ETC) --- apps/proxy/ts_proxy/constants.py | 2 ++ .../ts_proxy/services/channel_service.py | 25 ++++++++++++++++--- apps/proxy/ts_proxy/stream_manager.py | 8 ++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py index daaf7bb3..385d17c1 100644 --- a/apps/proxy/ts_proxy/constants.py +++ b/apps/proxy/ts_proxy/constants.py @@ -85,6 +85,8 @@ class ChannelMetadataField: AUDIO_CHANNELS = "audio_channels" AUDIO_BITRATE = "audio_bitrate" + # Stream format info + STREAM_TYPE = "stream_type" # Stream info timestamp STREAM_INFO_UPDATED = "stream_info_updated" diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 42f5efee..2bf26364 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -420,7 +420,22 @@ class ChannelService: def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video"): """Parse FFmpeg stream info line and store in Redis metadata""" try: - if stream_type == "video": + if stream_type == "input": + # Example lines: + # Input #0, mpegts, from 'http://example.com/stream.ts': + # Input #0, hls, from 'http://example.com/stream.m3u8': + + # Extract input format (e.g., "mpegts", "hls", "flv", etc.) + input_match = re.search(r'Input #\d+,\s*([^,]+)', stream_info_line) + input_format = input_match.group(1).strip() if input_match else None + + # Store in Redis if we have valid data + if input_format: + ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format) + + logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}") + + elif stream_type == "video": # Example line: # Stream #0:0: Video: h264 (Main), yuv420p(tv, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 2000 kb/s, 29.97 fps, 90k tbn @@ -464,7 +479,7 @@ class ChannelService: # Store in Redis if we have valid data if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None) + ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None) logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, " f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, " @@ -495,7 +510,7 @@ class ChannelService: # Store in Redis if we have valid data if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate) + ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None) logger.info(f"Audio stream info - Codec: {audio_codec}, Sample Rate: {sample_rate} Hz, " f"Channels: {channels}, Audio Bitrate: {audio_bitrate} kb/s") @@ -504,7 +519,7 @@ class ChannelService: logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}") @staticmethod - def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None): + def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None): """Update stream info in Redis metadata""" try: proxy_server = ProxyServer.get_instance() @@ -550,6 +565,8 @@ class ChannelService: if audio_bitrate is not None: update_data[ChannelMetadataField.AUDIO_BITRATE] = str(round(audio_bitrate, 1)) + if input_format is not None: + update_data[ChannelMetadataField.STREAM_TYPE] = str(input_format) proxy_server.redis_client.hset(metadata_key, mapping=update_data) return True diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index 7f81e29e..6e3c9e73 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -6,8 +6,8 @@ import time import socket import requests import subprocess -import gevent # Add this import -import re # Add this import at the top +import gevent +import re from typing import Optional, List from django.shortcuts import get_object_or_404 from apps.proxy.config import TSConfig as Config @@ -502,6 +502,10 @@ class StreamManager: elif any(keyword in content_lower for keyword in ['input', 'output', 'stream', 'video', 'audio']): # Stream info - log at info level logger.info(f"FFmpeg info: {content}") + if content.startswith('Input #0'): + # If it's input 0, parse stream info + from .services.channel_service import ChannelService + ChannelService.parse_and_store_stream_info(self.channel_id, content, "input") else: # Everything else at debug level logger.debug(f"FFmpeg stderr: {content}") From 1f6f15ed73b1adc76cc6cdccbe80c89a7ea80939 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 10:10:05 -0500 Subject: [PATCH 05/12] Add stream type to stats page. --- apps/proxy/ts_proxy/channel_status.py | 6 ++++++ frontend/src/pages/Stats.jsx | 15 ++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py index 77b4482d..8f1d0649 100644 --- a/apps/proxy/ts_proxy/channel_status.py +++ b/apps/proxy/ts_proxy/channel_status.py @@ -317,6 +317,9 @@ class ChannelStatus: ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE.encode('utf-8')) if ffmpeg_bitrate: info['ffmpeg_bitrate'] = float(ffmpeg_bitrate.decode('utf-8')) + stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8')) + if stream_type: + info['stream_type'] = stream_type.decode('utf-8') return info @@ -497,6 +500,9 @@ class ChannelStatus: audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS.encode('utf-8')) if audio_channels: info['audio_channels'] = audio_channels.decode('utf-8') + stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8')) + if stream_type: + info['stream_type'] = stream_type.decode('utf-8') return info except Exception as e: diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 2b3fd8bb..fea8653e 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -477,11 +477,6 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel {/* Add stream information badges */} - {channel.video_codec && ( - - {channel.video_codec.toUpperCase()} - - )} {channel.resolution && ( {channel.resolution} @@ -492,6 +487,16 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel {channel.source_fps} FPS )} + {channel.video_codec && ( + + {channel.video_codec.toUpperCase()} + + )} + {channel.stream_type && ( + + {channel.stream_type.toUpperCase()} + + )} {channel.audio_codec && ( {channel.audio_codec.toUpperCase()} From 3522066867a6f4e15bde545c98330ca169131735 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 10:17:28 -0500 Subject: [PATCH 06/12] Changed some badge colors and added tooltips. --- frontend/src/pages/Stats.jsx | 50 ++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index fea8653e..ce77f9e8 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -478,37 +478,49 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel {/* Add stream information badges */} {channel.resolution && ( - - {channel.resolution} - + + + {channel.resolution} + + )} {channel.source_fps && ( - - {channel.source_fps} FPS - + + + {channel.source_fps} FPS + + )} {channel.video_codec && ( - - {channel.video_codec.toUpperCase()} - + + + {channel.video_codec.toUpperCase()} + + )} {channel.stream_type && ( - - {channel.stream_type.toUpperCase()} - + + + {channel.stream_type.toUpperCase()} + + )} {channel.audio_codec && ( - - {channel.audio_codec.toUpperCase()} - + + + {channel.audio_codec.toUpperCase()} + + )} {channel.audio_channels && ( - - {channel.audio_channels} - + + + {channel.audio_channels} + + )} {channel.ffmpeg_speed && ( - + Date: Tue, 10 Jun 2025 11:38:52 -0500 Subject: [PATCH 07/12] Move stream type to after audio. --- frontend/src/pages/Stats.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ce77f9e8..f60e14d0 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -498,13 +498,6 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel )} - {channel.stream_type && ( - - - {channel.stream_type.toUpperCase()} - - - )} {channel.audio_codec && ( @@ -519,6 +512,13 @@ const ChannelCard = ({ channel, clients, stopClient, stopChannel, logos, channel )} + {channel.stream_type && ( + + + {channel.stream_type.toUpperCase()} + + + )} {channel.ffmpeg_speed && ( Date: Tue, 10 Jun 2025 13:58:34 -0500 Subject: [PATCH 08/12] Better error messaging for unsupported codecs in the web player. Also don't block controls with error messages. --- frontend/src/components/FloatingVideo.jsx | 202 ++++++++++++---------- 1 file changed, 115 insertions(+), 87 deletions(-) diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 46c191eb..7f1e1c53 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -73,72 +73,109 @@ export default function FloatingVideo() { console.log("Attempting to play stream:", streamUrl); try { - // If the browser supports MSE for live playback, initialize mpegts.js - if (mpegts.getFeatureList().mseLivePlayback) { - // Set loading flag - setIsLoading(true); - - const player = mpegts.createPlayer({ - type: 'mpegts', // MPEG-TS format - url: streamUrl, - isLive: true, - enableWorker: true, - enableStashBuffer: false, // Try disabling stash buffer for live streams - liveBufferLatencyChasing: true, - liveSync: true, - cors: true, // Enable CORS for cross-domain requests - // Add error recovery options - autoCleanupSourceBuffer: true, - autoCleanupMaxBackwardDuration: 10, - autoCleanupMinBackwardDuration: 5, - reuseRedirectedURL: true, - }); - - player.attachMediaElement(videoRef.current); - - // Add events to track loading state - player.on(mpegts.Events.LOADING_COMPLETE, () => { - setIsLoading(false); - }); - - player.on(mpegts.Events.METADATA_ARRIVED, () => { - setIsLoading(false); - }); - - // Add error event handler - player.on(mpegts.Events.ERROR, (errorType, errorDetail) => { - setIsLoading(false); - - // Filter out aborted errors - if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) { - console.error('Player error:', errorType, errorDetail); - setLoadError(`Error: ${errorType}${errorDetail ? ` - ${errorDetail}` : ''}`); - } - }); - - player.load(); - - // Don't auto-play until we've loaded properly - player.on(mpegts.Events.MEDIA_INFO, () => { - setIsLoading(false); - try { - player.play().catch(e => { - console.log("Auto-play prevented:", e); - setLoadError("Auto-play was prevented. Click play to start."); - }); - } catch (e) { - console.log("Error during play:", e); - setLoadError(`Playback error: ${e.message}`); - } - }); - - // Store player instance so we can clean up later - playerRef.current = player; + // Check for MSE support first + if (!mpegts.getFeatureList().mseLivePlayback) { + setIsLoading(false); + setLoadError("Your browser doesn't support live video streaming. Please try Chrome or Edge."); + return; } + + // Check for basic codec support + const video = document.createElement('video'); + const h264Support = video.canPlayType('video/mp4; codecs="avc1.42E01E"'); + const aacSupport = video.canPlayType('audio/mp4; codecs="mp4a.40.2"'); + + console.log("Browser codec support - H264:", h264Support, "AAC:", aacSupport); + + // If the browser supports MSE for live playback, initialize mpegts.js + setIsLoading(true); + + const player = mpegts.createPlayer({ + type: 'mpegts', + url: streamUrl, + isLive: true, + enableWorker: true, + enableStashBuffer: false, + liveBufferLatencyChasing: true, + liveSync: true, + cors: true, + autoCleanupSourceBuffer: true, + autoCleanupMaxBackwardDuration: 10, + autoCleanupMinBackwardDuration: 5, + reuseRedirectedURL: true, + }); + + player.attachMediaElement(videoRef.current); + + // Add events to track loading state + player.on(mpegts.Events.LOADING_COMPLETE, () => { + setIsLoading(false); + }); + + player.on(mpegts.Events.METADATA_ARRIVED, () => { + setIsLoading(false); + }); + + // Enhanced error event handler with codec-specific messages + player.on(mpegts.Events.ERROR, (errorType, errorDetail) => { + setIsLoading(false); + + // Filter out aborted errors + if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) { + console.error('Player error:', errorType, errorDetail); + + // Provide specific error messages based on error type + let errorMessage = `Error: ${errorType}`; + + if (errorType === 'MediaError') { + // Try to determine if it's an audio or video codec issue + const errorString = errorDetail?.toLowerCase() || ''; + + if (errorString.includes('audio') || errorString.includes('ac3') || errorString.includes('ac-3')) { + errorMessage = "Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support."; + } else if (errorString.includes('video') || errorString.includes('h264') || errorString.includes('h.264')) { + errorMessage = "Video codec not supported by your browser. Try Chrome or Edge for better video codec support."; + } else if (errorString.includes('mse')) { + errorMessage = "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."; + } else { + errorMessage = "Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge."; + } + } else if (errorDetail) { + errorMessage += ` - ${errorDetail}`; + } + + setLoadError(errorMessage); + } + }); + + player.load(); + + // Don't auto-play until we've loaded properly + player.on(mpegts.Events.MEDIA_INFO, () => { + setIsLoading(false); + try { + player.play().catch(e => { + console.log("Auto-play prevented:", e); + setLoadError("Auto-play was prevented. Click play to start."); + }); + } catch (e) { + console.log("Error during play:", e); + setLoadError(`Playback error: ${e.message}`); + } + }); + + // Store player instance so we can clean up later + playerRef.current = player; } catch (error) { setIsLoading(false); - setLoadError(`Initialization error: ${error.message}`); console.error("Error initializing player:", error); + + // Provide helpful error message based on the error + if (error.message?.includes('codec') || error.message?.includes('format')) { + setLoadError("Codec not supported by your browser. Please try a different browser (Chrome/Edge recommended)."); + } else { + setLoadError(`Initialization error: ${error.message}`); + } } // Cleanup when component unmounts or streamUrl changes @@ -191,7 +228,7 @@ export default function FloatingVideo() { style={{ width: '100%', height: '180px', backgroundColor: '#000' }} /> - {/* Loading overlay */} + {/* Loading overlay - only show when loading */} {isLoading && ( )} - - {/* Error message overlay */} - {!isLoading && loadError && ( - - - {loadError} - - - )} + + {/* Error message below video - doesn't block controls */} + {!isLoading && loadError && ( + + + {loadError} + + + )} ); From 11d3d7a15aecca49d4ebc05dac42315c22f28757 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 14:45:49 -0500 Subject: [PATCH 09/12] Add case-insensitive attribute lookup for M3U parsing --- apps/m3u/tasks.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index ce46a2ec..d6e0755b 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -172,6 +172,13 @@ def fetch_m3u_lines(account, use_cache=False): send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg) return [], False +def get_case_insensitive_attr(attributes, key, default=""): + """Get attribute value using case-insensitive key lookup.""" + for attr_key, attr_value in attributes.items(): + if attr_key.lower() == key.lower(): + return attr_value + return default + def parse_extinf_line(line: str) -> dict: """ Parse an EXTINF line from an M3U file. @@ -193,7 +200,7 @@ def parse_extinf_line(line: str) -> dict: attributes_part, display_name = parts[0], parts[1].strip() attrs = dict(re.findall(r'([^\s]+)=["\']([^"\']+)["\']', attributes_part)) # Use tvg-name attribute if available; otherwise, use the display name. - name = attrs.get('tvg-name', display_name) + name = get_case_insensitive_attr(attrs, 'tvg-name', display_name) return { 'attributes': attrs, 'display_name': display_name, @@ -409,8 +416,8 @@ def process_m3u_batch(account_id, batch, groups, hash_keys): for stream_info in batch: try: name, url = stream_info["name"], stream_info["url"] - tvg_id, tvg_logo = stream_info["attributes"].get("tvg-id", ""), stream_info["attributes"].get("tvg-logo", "") - group_title = stream_info["attributes"].get("group-title", "Default Group") + tvg_id, tvg_logo = get_case_insensitive_attr(stream_info["attributes"], "tvg-id", ""), get_case_insensitive_attr(stream_info["attributes"], "tvg-logo", "") + group_title = get_case_insensitive_attr(stream_info["attributes"], "group-title", "Default Group") # Filter out disabled groups for this account if group_title not in groups: @@ -712,8 +719,9 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): extinf_count += 1 parsed = parse_extinf_line(line) if parsed: - if "group-title" in parsed["attributes"]: - group_name = parsed["attributes"]["group-title"] + group_title_attr = get_case_insensitive_attr(parsed["attributes"], "group-title", "") + if group_title_attr: + group_name = group_title_attr # Log new groups as they're discovered if group_name not in groups: logger.debug(f"Found new group for M3U account {account_id}: '{group_name}'") From d850166a803a6dc4916c43774f0e68cdc18002a9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 15:47:27 -0500 Subject: [PATCH 10/12] Add conditional channel_group_id assignment in ChannelViewSet. This fixes an issue where if a group isn't assigned to a stream it would fail to create a channel from the stream. Closes #122 --- apps/channels/api_views.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 890dd247..bdeb31cb 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -322,17 +322,18 @@ class ChannelViewSet(viewsets.ModelViewSet): if 'tvc-guide-stationid' in stream_custom_props: tvc_guide_stationid = stream_custom_props['tvc-guide-stationid'] - - channel_data = { 'channel_number': channel_number, 'name': name, 'tvg_id': stream.tvg_id, 'tvc_guide_stationid': tvc_guide_stationid, - 'channel_group_id': channel_group.id, 'streams': [stream_id], } + # Only add channel_group_id if the stream has a channel group + if channel_group: + channel_data['channel_group_id'] = channel_group.id + if stream.logo_url: logo, _ = Logo.objects.get_or_create(url=stream.logo_url, defaults={ "name": stream.name or stream.tvg_id @@ -453,9 +454,12 @@ class ChannelViewSet(viewsets.ModelViewSet): "name": name, 'tvc_guide_stationid': tvc_guide_stationid, "tvg_id": stream.tvg_id, - "channel_group_id": channel_group.id, } + # Only add channel_group_id if the stream has a channel group + if channel_group: + channel_data["channel_group_id"] = channel_group.id + # Attempt to find existing EPGs with the same tvg-id epgs = EPGData.objects.filter(tvg_id=stream.tvg_id) if epgs: From a2c7fc3046204a4fc067b72edb821616824166f8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 17:43:37 -0500 Subject: [PATCH 11/12] [New feature] Switch streams when buffering is detected. --- apps/proxy/config.py | 2 ++ apps/proxy/ts_proxy/config_helper.py | 8 +++++ apps/proxy/ts_proxy/constants.py | 1 + apps/proxy/ts_proxy/stream_manager.py | 48 +++++++++++++++++++++++++-- 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/proxy/config.py b/apps/proxy/config.py index b00bd224..b369a92f 100644 --- a/apps/proxy/config.py +++ b/apps/proxy/config.py @@ -11,6 +11,8 @@ class BaseConfig: BUFFER_CHUNK_SIZE = 188 * 1361 # ~256KB # Redis settings REDIS_CHUNK_TTL = 60 # Number in seconds - Chunks expire after 1 minute + BUFFERING_TIMEOUT = 15 # Seconds to wait for buffering before switching streams + BUFFER_SPEED = 1 # What speed to condsider the stream buffering, 1x is normal speed, 2x is double speed, etc. class HLSConfig(BaseConfig): MIN_SEGMENTS = 12 diff --git a/apps/proxy/ts_proxy/config_helper.py b/apps/proxy/ts_proxy/config_helper.py index 773ab378..4057a2d5 100644 --- a/apps/proxy/ts_proxy/config_helper.py +++ b/apps/proxy/ts_proxy/config_helper.py @@ -85,3 +85,11 @@ class ConfigHelper: def failover_grace_period(): """Get extra time (in seconds) to allow for stream switching before disconnecting clients""" return ConfigHelper.get('FAILOVER_GRACE_PERIOD', 20) # Default to 20 seconds + @staticmethod + def buffering_timeout(): + """Get buffering timeout in seconds""" + return ConfigHelper.get('BUFFERING_TIMEOUT', 15) # Default to 15 seconds + @staticmethod + def buffering_speed(): + """Get buffering speed in bytes per second""" + return ConfigHelper.get('BUFFERING_SPEED',1) # Default to 1x diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py index 385d17c1..55d6e006 100644 --- a/apps/proxy/ts_proxy/constants.py +++ b/apps/proxy/ts_proxy/constants.py @@ -18,6 +18,7 @@ class ChannelState: ERROR = "error" STOPPING = "stopping" STOPPED = "stopped" + BUFFERING = "buffering" # Event types class EventType: diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index 6e3c9e73..f8a7323b 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -40,6 +40,10 @@ class StreamManager: self.url_switching = False self.url_switch_start_time = 0 self.url_switch_timeout = ConfigHelper.url_switch_timeout() + self.buffering = False + self.buffering_timeout = ConfigHelper.buffering_timeout() + self.buffering_speed = ConfigHelper.buffering_speed() + self.buffering_start_time = None # Store worker_id for ownership checks self.worker_id = worker_id @@ -545,7 +549,6 @@ class StreamManager: actual_fps = None if ffmpeg_fps is not None and ffmpeg_speed is not None and ffmpeg_speed > 0: actual_fps = ffmpeg_fps / ffmpeg_speed - # Store in Redis if we have valid data if any(x is not None for x in [ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_bitrate]): self._update_ffmpeg_stats_in_redis(ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_bitrate) @@ -553,10 +556,51 @@ class StreamManager: # Fix the f-string formatting actual_fps_str = f"{actual_fps:.1f}" if actual_fps is not None else "N/A" ffmpeg_bitrate_str = f"{ffmpeg_bitrate:.1f}" if ffmpeg_bitrate is not None else "N/A" - + # Log the stats logger.debug(f"FFmpeg stats - Speed: {ffmpeg_speed}x, FFmpeg FPS: {ffmpeg_fps}, " f"Actual FPS: {actual_fps_str}, " f"Bitrate: {ffmpeg_bitrate_str} kbps") + # If we have a valid speed, check for buffering + if ffmpeg_speed is not None and ffmpeg_speed < self.buffering_speed: + if self.buffering: + # Buffering is still ongoing, check for how long + if self.buffering_start_time is None: + self.buffering_start_time = time.time() + else: + buffering_duration = time.time() - self.buffering_start_time + if buffering_duration > self.buffering_timeout: + # Buffering timeout reached, log error and try next stream + logger.error(f"Buffering timeout reached for channel {self.channel_id} after {buffering_duration:.1f} seconds") + # Send next stream request + if self._try_next_stream(): + logger.info(f"Switched to next stream for channel {self.channel_id} after buffering timeout") + # Reset buffering state + self.buffering = False + self.buffering_start_time = None + else: + logger.error(f"Failed to switch to next stream for channel {self.channel_id} after buffering timeout") + else: + # Buffering just started, set the flag and start timer + self.buffering = True + self.buffering_start_time = time.time() + logger.warning(f"Buffering started for channel {self.channel_id} - speed: {ffmpeg_speed}x") + # Log buffering warning + logger.debug(f"FFmpeg speed on channel {self.channel_id} is below {self.buffering_speed} ({ffmpeg_speed}x) - buffering detected") + # Set channel state to buffering + if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + metadata_key = RedisKeys.channel_metadata(self.channel_id) + self.buffer.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.BUFFERING) + elif ffmpeg_speed is not None and ffmpeg_speed >= self.buffering_speed: + # Speed is good, check if we were buffering + if self.buffering: + # Reset buffering state + logger.info(f"Buffering ended for channel {self.channel_id} - speed: {ffmpeg_speed}x") + self.buffering = False + self.buffering_start_time = None + # Set channel state to active if speed is good + if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + metadata_key = RedisKeys.channel_metadata(self.channel_id) + self.buffer.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.ACTIVE) except Exception as e: logger.debug(f"Error parsing FFmpeg stats: {e}") From e753d9b9f810f9b4933257cb9941512be2c9bd55 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 10 Jun 2025 19:16:52 -0500 Subject: [PATCH 12/12] Fixes a bug where stream profile name wouldn't update in stats. (Was outputting name string instead of ID --- apps/proxy/ts_proxy/url_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index e3b1c264..dbd3c5dd 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -172,7 +172,7 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] # Get transcode info from the channel's stream profile stream_profile = channel.get_stream_profile() transcode = not (stream_profile.is_proxy() or stream_profile is None) - profile_value = str(stream_profile) + profile_value = stream_profile.id return { 'url': stream_url,