merged in main

This commit is contained in:
dekzter 2025-06-11 08:38:00 -04:00
commit 30b2a19eb0
12 changed files with 305 additions and 127 deletions

View file

@ -432,10 +432,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
"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}
@ -584,9 +587,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:

View file

@ -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}'")

View file

@ -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

View file

@ -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
@ -494,6 +497,12 @@ 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')
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:

View file

@ -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

View file

@ -18,6 +18,7 @@ class ChannelState:
ERROR = "error"
STOPPING = "stopping"
STOPPED = "stopped"
BUFFERING = "buffering"
# Event types
class EventType:
@ -85,6 +86,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"

View file

@ -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
@ -428,12 +443,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
@ -459,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}, "
@ -490,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")
@ -499,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()
@ -545,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

View file

@ -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
@ -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
@ -502,6 +506,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}")
@ -541,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)
@ -549,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}")

View file

@ -171,7 +171,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,

View file

@ -2,6 +2,7 @@
import json
import ipaddress
import logging
from rest_framework import viewsets, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
@ -29,6 +30,9 @@ from apps.accounts.permissions import (
from dispatcharr.utils import get_client_ip
logger = logging.getLogger(__name__)
class UserAgentViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows user agents to be viewed, created, edited, or deleted.
@ -137,14 +141,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

View file

@ -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 && (
<Box
style={{
@ -214,31 +251,22 @@ export default function FloatingVideo() {
</Text>
</Box>
)}
{/* Error message overlay */}
{!isLoading && loadError && (
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 5,
padding: '0 10px',
textAlign: 'center',
}}
>
<Text color="red" size="sm">
{loadError}
</Text>
</Box>
)}
</Box>
{/* Error message below video - doesn't block controls */}
{!isLoading && loadError && (
<Box
style={{
padding: '10px',
backgroundColor: '#2d1b2e',
borderTop: '1px solid #444',
}}
>
<Text color="red" size="xs" style={{ textAlign: 'center' }}>
{loadError}
</Text>
</Box>
)}
</div>
</Draggable>
);

View file

@ -570,28 +570,50 @@ const ChannelCard = ({
{/* Add stream information badges */}
<Group gap="xs" mt="xs">
{channel.video_codec && (
<Badge size="sm" variant="light" color="blue">
{channel.video_codec.toUpperCase()}
</Badge>
)}
{channel.resolution && (
<Badge size="sm" variant="light" color="green">
{channel.resolution}
</Badge>
<Tooltip label="Video resolution">
<Badge size="sm" variant="light" color="red">
{channel.resolution}
</Badge>
</Tooltip>
)}
{channel.source_fps && (
<Badge size="sm" variant="light" color="orange">
{channel.source_fps} FPS
</Badge>
<Tooltip label="Source frames per second">
<Badge size="sm" variant="light" color="orange">
{channel.source_fps} FPS
</Badge>
</Tooltip>
)}
{channel.video_codec && (
<Tooltip label="Video codec">
<Badge size="sm" variant="light" color="blue">
{channel.video_codec.toUpperCase()}
</Badge>
</Tooltip>
)}
{channel.audio_codec && (
<Badge size="sm" variant="light" color="purple">
{channel.audio_codec.toUpperCase()}
</Badge>
<Tooltip label="Audio codec">
<Badge size="sm" variant="light" color="pink">
{channel.audio_codec.toUpperCase()}
</Badge>
</Tooltip>
)}
{channel.audio_channels && (
<Tooltip label="Audio channel configuration">
<Badge size="sm" variant="light" color="pink">
{channel.audio_channels}
</Badge>
</Tooltip>
)}
{channel.stream_type && (
<Tooltip label="Stream type">
<Badge size="sm" variant="light" color="cyan">
{channel.stream_type.toUpperCase()}
</Badge>
</Tooltip>
)}
{channel.ffmpeg_speed && (
<Tooltip label={`Speed: ${channel.ffmpeg_speed}x realtime`}>
<Tooltip label={`Current Speed: ${channel.ffmpeg_speed}x`}>
<Badge
size="sm"
variant="light"