diff --git a/backend/stream_check_utils.py b/backend/stream_check_utils.py index c5025f87..1851e732 100644 --- a/backend/stream_check_utils.py +++ b/backend/stream_check_utils.py @@ -362,7 +362,12 @@ def get_stream_info_and_bitrate(url: str, duration: int = 30, timeout: int = 30, 'fps': 0, 'bitrate_kbps': None, 'status': 'OK', - 'elapsed_time': 0 + 'elapsed_time': 0, + 'video_profile': None, + 'video_bit_depth': None, + 'audio_language': None, + 'sample_rate': None, + 'audio_channels': None, } # Add buffer to timeout to account for ffmpeg startup, network latency, and shutdown overhead @@ -435,6 +440,25 @@ def get_stream_info_and_bitrate(url: str, duration: int = 30, timeout: int = 30, if fps_match: result_data['fps'] = round(float(fps_match.group(1)), 2) logger.debug(f" → Detected FPS: {result_data['fps']}") + + # Extract video profile — e.g. "h264 (High)" or "hevc (Main 10)" + # Skip if it looks like a codec alias (contains / or 0x) + profile_match = re.search(r'Video:\s+\w+\s+\(([^)]+)\)', line) + if profile_match: + candidate = profile_match.group(1).strip() + if '/' not in candidate and '0x' not in candidate: + result_data['video_profile'] = candidate + logger.debug(f" → Detected video profile: {candidate}") + + # Extract bit depth from pixel format + # e.g. yuv420p10le → 10, yuv420p12le → 12, yuv420p → 8 + depth_match = re.search(r'yuv[j\d]+p(\d{2})', line) + if depth_match: + result_data['video_bit_depth'] = int(depth_match.group(1)) + logger.debug(f" → Detected bit depth: {result_data['video_bit_depth']}") + elif re.search(r'yuv[j\d]+p\b', line): + result_data['video_bit_depth'] = 8 + logger.debug(f" → Detected bit depth: 8 (standard yuv)") except (ValueError, AttributeError) as e: logger.debug(f" → Error parsing video stream line: {e}") @@ -454,6 +478,32 @@ def get_stream_info_and_bitrate(url: str, duration: int = 30, timeout: int = 30, if audio_codec != 'N/A': result_data['audio_codec'] = audio_codec logger.debug(f" → Final audio codec: {result_data['audio_codec']}") + + # Extract audio language from stream specifier e.g. "Stream #0:1(eng):" + lang_match = re.search(r'Stream #\d+:\d+\((\w+)\):', line) + if lang_match: + lang = lang_match.group(1) + if lang.lower() not in ('und', 'unknown'): + result_data['audio_language'] = lang + logger.debug(f" → Detected audio language: {lang}") + + # Extract sample rate e.g. "48000 Hz" + sr_match = re.search(r'(\d+)\s+Hz', line) + if sr_match: + result_data['sample_rate'] = int(sr_match.group(1)) + logger.debug(f" → Detected sample rate: {result_data['sample_rate']}") + + # Extract channel layout e.g. "stereo", "5.1(side)", "7.1" + # Strip trailing (side)/(back) qualifiers so "5.1(side)" → "5.1" + ch_match = re.search(r'\d+\s+Hz,\s+([^,\s]+)', line) + if ch_match: + layout = ch_match.group(1) + paren = layout.find('(') + if paren > 0: + layout = layout[:paren] + if layout: + result_data['audio_channels'] = layout.strip() + logger.debug(f" → Detected audio channels: {result_data['audio_channels']}") except (ValueError, AttributeError) as e: logger.debug(f" → Error parsing audio stream line: {e}") @@ -537,6 +587,7 @@ def get_stream_info_and_bitrate(url: str, duration: int = 30, timeout: int = 30, return result_data + def get_stream_bitrate(url: str, duration: int = 30, timeout: int = 30, user_agent: str = 'VLC/3.0.14', stream_startup_buffer: int = 10) -> Tuple[Optional[float], str, float]: """ Get stream bitrate using ffmpeg to analyze actual stream data. @@ -762,7 +813,7 @@ def analyze_stream( stream_startup_buffer=stream_startup_buffer ) - # Build result dictionary with metadata + # Build result dictionary result = { 'stream_id': stream_id, 'stream_name': stream_name, @@ -773,7 +824,12 @@ def analyze_stream( 'resolution': result_data['resolution'], 'fps': result_data['fps'], 'bitrate_kbps': result_data['bitrate_kbps'], - 'status': result_data['status'] + 'status': result_data['status'], + 'video_profile': result_data.get('video_profile'), + 'video_bit_depth': result_data.get('video_bit_depth'), + 'audio_language': result_data.get('audio_language'), + 'sample_rate': result_data.get('sample_rate'), + 'audio_channels': result_data.get('audio_channels'), } # Log results diff --git a/backend/stream_checker_service.py b/backend/stream_checker_service.py index b66f68d3..8804649a 100644 --- a/backend/stream_checker_service.py +++ b/backend/stream_checker_service.py @@ -1460,15 +1460,23 @@ def _update_stream_stats(self, stream_data: Dict) -> bool: "video_codec": stream_data.get("video_codec"), "audio_codec": stream_data.get("audio_codec"), "ffmpeg_output_bitrate": int(stream_data.get("bitrate_kbps")) if stream_data.get("bitrate_kbps") not in ["N/A", None] else None, + # Extended stream metadata + "video_profile": stream_data.get("video_profile"), + "video_level": stream_data.get("video_level"), + "video_bit_depth": stream_data.get("video_bit_depth"), + "video_ref_frames": stream_data.get("video_ref_frames"), + "audio_language": stream_data.get("audio_language"), + "sample_rate": stream_data.get("sample_rate"), + "audio_channels": stream_data.get("audio_channels"), } - + # Clean up the payload, removing any None values or N/A values stream_stats_payload = {k: v for k, v in stream_stats_payload.items() if v not in [None, "N/A"]} - + if not stream_stats_payload: logger.debug(f"No data to update for stream {stream_id}. Skipping.") return False - + # Construct the URL for the specific stream stream_url = f"{base_url}/api/channels/streams/{int(stream_id)}/" @@ -1535,15 +1543,23 @@ def _prepare_stream_stats_for_batch(self, stream_data: Dict) -> Optional[Dict[st "video_codec": stream_data.get("video_codec"), "audio_codec": stream_data.get("audio_codec"), "ffmpeg_output_bitrate": int(stream_data.get("bitrate_kbps")) if stream_data.get("bitrate_kbps") not in ["N/A", None] else None, + # Extended stream metadata + "video_profile": stream_data.get("video_profile"), + "video_level": stream_data.get("video_level"), + "video_bit_depth": stream_data.get("video_bit_depth"), + "video_ref_frames": stream_data.get("video_ref_frames"), + "audio_language": stream_data.get("audio_language"), + "sample_rate": stream_data.get("sample_rate"), + "audio_channels": stream_data.get("audio_channels"), } - + # Clean up the payload, removing any None values or N/A values stream_stats_payload = {k: v for k, v in stream_stats_payload.items() if v not in [None, "N/A"]} - + if not stream_stats_payload: logger.debug(f"No data to update for stream {stream_id}. Skipping.") return None - + return { 'stream_id': stream_id, 'stream_stats': stream_stats_payload