Squash: Log Parsing Refactor & Enhancing

This commit is contained in:
Seth Van Niekerk 2025-12-22 15:14:46 -05:00
parent bc72b2d4a3
commit aa5db6c3f4
No known key found for this signature in database
GPG key ID: AD48C3EFC4CB0768
3 changed files with 464 additions and 183 deletions

View file

@ -15,6 +15,7 @@ from ..redis_keys import RedisKeys
from ..constants import EventType, ChannelState, ChannelMetadataField
from ..url_utils import get_stream_info_for_switch
from core.utils import log_system_event
from .log_parsers import LogParserFactory
logger = logging.getLogger("ts_proxy")
@ -419,175 +420,51 @@ class ChannelService:
@staticmethod
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None):
"""Parse FFmpeg/VLC/streamlink stream info line and store in Redis metadata and database"""
"""
Parse stream info from FFmpeg/VLC/Streamlink logs and store in Redis/DB.
Uses specialized parsers for each streaming tool.
"""
try:
if stream_type == "input":
# Example lines:
# FFmpeg: Input #0, mpegts, from 'http://example.com/stream.ts':
# FFmpeg: Input #0, hls, from 'http://example.com/stream.m3u8':
# Use factory to parse the line based on stream type
parsed_data = LogParserFactory.parse(stream_type, stream_info_line)
if not parsed_data:
return
# 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
# Update Redis and database with parsed data
ChannelService._update_stream_info_in_redis(
channel_id,
parsed_data.get('video_codec'),
parsed_data.get('resolution'),
parsed_data.get('width'),
parsed_data.get('height'),
parsed_data.get('source_fps'),
parsed_data.get('pixel_format'),
parsed_data.get('video_bitrate'),
parsed_data.get('audio_codec'),
parsed_data.get('sample_rate'),
parsed_data.get('audio_channels'),
parsed_data.get('audio_bitrate'),
parsed_data.get('stream_type')
)
# 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)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(stream_id, stream_type=input_format)
logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}")
elif stream_type == "vlc":
# VLC parsing - extract codecs from TS demux output (no resolution/fps in stream-copy mode)
lower = stream_info_line.lower()
# Video codec detection
video_codec_map = {
('avc', 'h.264', 'type=0x1b'): "h264",
('hevc', 'h.265', 'type=0x24'): "hevc",
('mpeg-2', 'type=0x02'): "mpeg2video",
('mpeg-4', 'type=0x10'): "mpeg4"
}
for patterns, codec in video_codec_map.items():
if any(p in lower for p in patterns):
ChannelService._update_stream_info_in_redis(channel_id, codec, None, None, None, None, None, None, None, None, None, None, None)
if stream_id:
ChannelService._update_stream_stats_in_db(stream_id, video_codec=codec)
break
# Audio codec detection
audio_codec_map = {
('type=0xf', 'adts'): "aac",
('type=0x03', 'type=0x04'): "mp3",
('type=0x06', 'type=0x81'): "ac3",
('type=0x0b', 'lpcm'): "pcm"
}
for patterns, codec in audio_codec_map.items():
if any(p in lower for p in patterns):
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, codec, None, None, None, None)
if stream_id:
ChannelService._update_stream_stats_in_db(stream_id, audio_codec=codec)
break
elif stream_type == "streamlink":
# Streamlink parsing - extract quality/resolution
quality_match = re.search(r'(\d+p|\d+x\d+)', stream_info_line)
if quality_match:
quality = quality_match.group(1)
if 'x' in quality:
resolution = quality
width, height = map(int, quality.split('x'))
else:
resolutions = {
'2160p': ('3840x2160', 3840, 2160), '1080p': ('1920x1080', 1920, 1080),
'720p': ('1280x720', 1280, 720), '480p': ('854x480', 854, 480), '360p': ('640x360', 640, 360)
}
resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080))
ChannelService._update_stream_info_in_redis(channel_id, "h264", resolution, width, height, None, "yuv420p", None, None, None, None, None, None)
if stream_id:
ChannelService._update_stream_stats_in_db(stream_id, video_codec="h264", resolution=resolution, pixel_format="yuv420p")
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
# Extract video codec (e.g., "h264", "mpeg2video", etc.)
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") - 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))
# 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
# Extract source FPS (e.g., "29.97 fps")
fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', stream_info_line)
source_fps = float(fps_match.group(1)) if fps_match else None
# Extract pixel format (e.g., "yuv420p")
pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', stream_info_line)
pixel_format = None
if pixel_format_match:
pf = pixel_format_match.group(1).strip()
# Clean up pixel format (remove extra info in parentheses)
if '(' in pf:
pf = pf.split('(')[0].strip()
pixel_format = pf
# Extract bitrate if present (e.g., "2000 kb/s")
video_bitrate = None
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line)
if bitrate_match:
video_bitrate = float(bitrate_match.group(1))
# 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, None)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
video_codec=video_codec,
resolution=resolution,
source_fps=source_fps,
pixel_format=pixel_format,
video_bitrate=video_bitrate
)
logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, "
f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, "
f"Video Bitrate: {video_bitrate} kb/s")
elif stream_type == "audio":
# Example line:
# Stream #0:1[0x101]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 64 kb/s
# Extract audio codec (e.g., "aac", "mp3", etc.)
codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', stream_info_line)
audio_codec = codec_match.group(1) if codec_match else None
# Extract sample rate (e.g., "48000 Hz")
sample_rate_match = re.search(r'(\d+)\s*Hz', stream_info_line)
sample_rate = int(sample_rate_match.group(1)) if sample_rate_match else None
# Extract channel layout (e.g., "stereo", "5.1", "mono")
# Look for common channel layouts
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', stream_info_line, re.IGNORECASE)
channels = channel_match.group(1) if channel_match else None
# Extract audio bitrate if present (e.g., "64 kb/s")
audio_bitrate = None
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line)
if bitrate_match:
audio_bitrate = float(bitrate_match.group(1))
# 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, None)
# Save to database if stream_id is provided
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
audio_codec=audio_codec,
sample_rate=sample_rate,
audio_channels=channels,
audio_bitrate=audio_bitrate
)
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
video_codec=parsed_data.get('video_codec'),
resolution=parsed_data.get('resolution'),
source_fps=parsed_data.get('source_fps'),
pixel_format=parsed_data.get('pixel_format'),
video_bitrate=parsed_data.get('video_bitrate'),
audio_codec=parsed_data.get('audio_codec'),
sample_rate=parsed_data.get('sample_rate'),
audio_channels=parsed_data.get('audio_channels'),
audio_bitrate=parsed_data.get('audio_bitrate'),
stream_type=parsed_data.get('stream_type')
)
except Exception as e:
logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}")
logger.debug(f"Error parsing {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, input_format=None):

View file

@ -0,0 +1,410 @@
"""Log parsers for FFmpeg, Streamlink, and VLC output."""
import re
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class BaseLogParser(ABC):
"""Base class for log parsers"""
# Map of stream_type -> method_name that this parser handles
STREAM_TYPE_METHODS: Dict[str, str] = {}
@abstractmethod
def can_parse(self, line: str) -> Optional[str]:
"""
Check if this parser can handle the line.
Returns the stream_type if it can parse, None otherwise.
e.g., 'video', 'audio', 'vlc_video', 'vlc_audio', 'streamlink'
"""
pass
@abstractmethod
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
class FFmpegLogParser(BaseLogParser):
"""Parser for FFmpeg log output"""
STREAM_TYPE_METHODS = {
'input': 'parse_input_format',
'video': 'parse_video_stream',
'audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is an FFmpeg line we can parse"""
lower = line.lower()
# Input format detection
if lower.startswith('input #'):
return 'input'
# Stream info (only during input phase, but we'll let stream_manager handle phase tracking)
if 'stream #' in lower:
if 'video:' in lower:
return 'video'
elif 'audio:' in lower:
return 'audio'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg input format (e.g., mpegts, hls)"""
try:
input_match = re.search(r'Input #\d+,\s*([^,]+)', line)
input_format = input_match.group(1).strip() if input_match else None
if input_format:
logger.debug(f"Input format info - Format: {input_format}")
return {'stream_type': input_format}
except Exception as e:
logger.debug(f"Error parsing FFmpeg input format: {e}")
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg video stream info"""
try:
result = {}
# Extract codec, resolution, fps, pixel format, bitrate
codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['video_codec'] = codec_match.group(1)
resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', line)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', line)
if pixel_format_match:
pf = pixel_format_match.group(1).strip()
if '(' in pf:
pf = pf.split('(')[0].strip()
result['pixel_format'] = pf
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['video_bitrate'] = float(bitrate_match.group(1))
if result:
logger.info(f"Video stream info - Codec: {result.get('video_codec')}, "
f"Resolution: {result.get('resolution')}, "
f"Source FPS: {result.get('source_fps')}, "
f"Pixel Format: {result.get('pixel_format')}, "
f"Video Bitrate: {result.get('video_bitrate')} kb/s")
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg audio stream info"""
try:
result = {}
codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['audio_codec'] = codec_match.group(1)
sample_rate_match = re.search(r'(\d+)\s*Hz', line)
if sample_rate_match:
result['sample_rate'] = int(sample_rate_match.group(1))
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', line, re.IGNORECASE)
if channel_match:
result['audio_channels'] = channel_match.group(1)
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['audio_bitrate'] = float(bitrate_match.group(1))
if result:
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg audio stream info: {e}")
return None
class VLCLogParser(BaseLogParser):
"""Parser for VLC log output"""
STREAM_TYPE_METHODS = {
'vlc_video': 'parse_video_stream',
'vlc_audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a VLC line we can parse"""
lower = line.lower()
# VLC TS demux codec detection
if 'ts demux debug' in lower and 'type=' in lower:
if 'video' in lower:
return 'vlc_video'
elif 'audio' in lower:
return 'vlc_audio'
# VLC decoder output
if 'decoder' in lower and ('channels:' in lower or 'samplerate:' in lower or 'x' in line or 'fps' in lower):
if 'audio' in lower or 'channels:' in lower or 'samplerate:' in lower:
return 'vlc_audio'
else:
return 'vlc_video'
# VLC transcode output for resolution/FPS
if 'stream_out_transcode' in lower and ('source fps' in lower or ('source ' in lower and 'x' in line)):
return 'vlc_video'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for video"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
video_codec_map = {
('avc', 'h.264', 'type=0x1b'): "h264",
('hevc', 'h.265', 'type=0x24'): "hevc",
('mpeg-2', 'type=0x02'): "mpeg2video",
('mpeg-4', 'type=0x10'): "mpeg4"
}
for patterns, codec in video_codec_map.items():
if any(p in lower for p in patterns):
result['video_codec'] = codec
break
# Extract FPS from transcode output: "source fps 30/1"
fps_fraction_match = re.search(r'source fps\s+(\d+)/(\d+)', lower)
if fps_fraction_match:
numerator = int(fps_fraction_match.group(1))
denominator = int(fps_fraction_match.group(2))
if denominator > 0:
result['source_fps'] = numerator / denominator
# Extract resolution from transcode output: "source 1280x720"
source_res_match = re.search(r'source\s+(\d{3,4})x(\d{3,4})', lower)
if source_res_match:
width = int(source_res_match.group(1))
height = int(source_res_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
else:
# Fallback: generic resolution pattern
resolution_match = re.search(r'(\d{3,4})x(\d{3,4})', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
# Fallback: try to extract FPS from generic format
if 'source_fps' not in result:
fps_match = re.search(r'(\d+\.?\d*)\s*fps', lower)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
return result if result else None
except Exception as e:
logger.debug(f"Error parsing VLC video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for audio"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
audio_codec_map = {
('type=0xf', 'adts'): "aac",
('type=0x03', 'type=0x04'): "mp3",
('type=0x06', 'type=0x81'): "ac3",
('type=0x0b', 'lpcm'): "pcm"
}
for patterns, codec in audio_codec_map.items():
if any(p in lower for p in patterns):
result['audio_codec'] = codec
break
# VLC decoder format: "AAC channels: 2 samplerate: 48000"
if 'channels:' in lower:
channels_match = re.search(r'channels:\s*(\d+)', lower)
if channels_match:
num_channels = int(channels_match.group(1))
# Convert number to name
channel_names = {1: 'mono', 2: 'stereo', 6: '5.1', 8: '7.1'}
result['audio_channels'] = channel_names.get(num_channels, str(num_channels))
if 'samplerate:' in lower:
samplerate_match = re.search(r'samplerate:\s*(\d+)', lower)
if samplerate_match:
result['sample_rate'] = int(samplerate_match.group(1))
# Try to extract sample rate (Hz format)
sample_rate_match = re.search(r'(\d+)\s*hz', lower)
if sample_rate_match and 'sample_rate' not in result:
result['sample_rate'] = int(sample_rate_match.group(1))
# Try to extract channels (word format)
if 'audio_channels' not in result:
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', lower)
if channel_match:
result['audio_channels'] = channel_match.group(1)
return result if result else None
except Exception as e:
logger.error(f"[VLC AUDIO PARSER] Error parsing VLC audio stream info: {e}")
return None
class StreamlinkLogParser(BaseLogParser):
"""Parser for Streamlink log output"""
STREAM_TYPE_METHODS = {
'streamlink': 'parse_video_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a Streamlink line we can parse"""
lower = line.lower()
if 'opening stream:' in lower or 'available streams:' in lower:
return 'streamlink'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse Streamlink quality/resolution"""
try:
quality_match = re.search(r'(\d+p|\d+x\d+)', line)
if quality_match:
quality = quality_match.group(1)
if 'x' in quality:
resolution = quality
width, height = map(int, quality.split('x'))
else:
resolutions = {
'2160p': ('3840x2160', 3840, 2160),
'1080p': ('1920x1080', 1920, 1080),
'720p': ('1280x720', 1280, 720),
'480p': ('854x480', 854, 480),
'360p': ('640x360', 640, 360)
}
resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080))
return {
'video_codec': 'h264',
'resolution': resolution,
'width': width,
'height': height,
'pixel_format': 'yuv420p'
}
except Exception as e:
logger.debug(f"Error parsing Streamlink video info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
return None
class LogParserFactory:
"""Factory to get the appropriate log parser"""
_parsers = {
'ffmpeg': FFmpegLogParser(),
'vlc': VLCLogParser(),
'streamlink': StreamlinkLogParser()
}
@classmethod
def _get_parser_and_method(cls, stream_type: str) -> Optional[tuple[BaseLogParser, str]]:
"""Determine parser and method from stream_type"""
# Check each parser to see if it handles this stream_type
for parser in cls._parsers.values():
method_name = parser.STREAM_TYPE_METHODS.get(stream_type)
if method_name:
return (parser, method_name)
return None
@classmethod
def parse(cls, stream_type: str, line: str) -> Optional[Dict[str, Any]]:
"""
Parse a log line based on stream type.
Returns parsed data or None if parsing fails.
"""
result = cls._get_parser_and_method(stream_type)
if not result:
return None
parser, method_name = result
method = getattr(parser, method_name, None)
if method:
return method(line)
return None
@classmethod
def auto_parse(cls, line: str) -> Optional[tuple[str, Dict[str, Any]]]:
"""
Automatically detect which parser can handle this line and parse it.
Returns (stream_type, parsed_data) or None if no parser can handle it.
"""
# Try each parser to see if it can handle this line
for parser in cls._parsers.values():
stream_type = parser.can_parse(line)
if stream_type:
# Parser can handle this line, now parse it
parsed_data = cls.parse(stream_type, line)
if parsed_data:
return (stream_type, parsed_data)
return None

View file

@ -645,26 +645,20 @@ class StreamManager:
if content_lower.startswith('output #') or 'encoder' in content_lower:
self.ffmpeg_input_phase = False
# Parse VLC-specific output - look for TS demux type info for codec detection
if 'ts demux debug' in content_lower and 'type=' in content_lower and ('video' in content_lower or 'audio' in content_lower):
from .services.channel_service import ChannelService
ChannelService.parse_and_store_stream_info(self.channel_id, content, "vlc", self.current_stream_id)
# Try to auto-parse with any available parser
from .services.log_parsers import LogParserFactory
from .services.channel_service import ChannelService
# Parse streamlink-specific output
if 'opening stream:' in content_lower or 'available streams:' in content_lower:
from .services.channel_service import ChannelService
ChannelService.parse_and_store_stream_info(self.channel_id, content, "streamlink", self.current_stream_id)
# Only parse stream info if we're still in the input phase
if ("stream #" in content_lower and
("video:" in content_lower or "audio:" in content_lower) and
self.ffmpeg_input_phase):
from .services.channel_service import ChannelService
if "video:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "video", self.current_stream_id)
elif "audio:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio", self.current_stream_id)
parse_result = LogParserFactory.auto_parse(content)
if parse_result:
stream_type, parsed_data = parse_result
# For FFmpeg, only parse during input phase
if stream_type in ['video', 'audio', 'input']:
if self.ffmpeg_input_phase:
ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id)
else:
# VLC and Streamlink can be parsed anytime
ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id)
# Determine log level based on content
if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']):