Merge pull request #274 from Dispatcharr/stream-stats

Stream stats
This commit is contained in:
SergeantPanda 2025-07-29 16:21:37 -05:00 committed by GitHub
commit 64ec6de414
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 450 additions and 68 deletions

View file

@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-07-29 02:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0022_channel_auto_created_channel_auto_created_by_and_more'),
]
operations = [
migrations.AddField(
model_name='stream',
name='stream_stats',
field=models.JSONField(blank=True, help_text='JSON object containing stream statistics like video codec, resolution, etc.', null=True),
),
migrations.AddField(
model_name='stream',
name='stream_stats_updated_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='When stream statistics were last updated', null=True),
),
]

View file

@ -95,6 +95,19 @@ class Stream(models.Model):
)
last_seen = models.DateTimeField(db_index=True, default=datetime.now)
custom_properties = models.TextField(null=True, blank=True)
# Stream statistics fields
stream_stats = models.JSONField(
null=True,
blank=True,
help_text="JSON object containing stream statistics like video codec, resolution, etc."
)
stream_stats_updated_at = models.DateTimeField(
null=True,
blank=True,
help_text="When stream statistics were last updated",
db_index=True
)
class Meta:
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account')

View file

@ -104,6 +104,8 @@ class StreamSerializer(serializers.ModelSerializer):
"is_custom",
"channel_group",
"stream_hash",
"stream_stats",
"stream_stats_updated_at",
]
def get_fields(self):

View file

@ -417,8 +417,8 @@ class ChannelService:
return False, None, None, {"error": f"Exception: {str(e)}"}
@staticmethod
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video"):
"""Parse FFmpeg stream info line and store in Redis metadata"""
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None):
"""Parse FFmpeg stream info line and store in Redis metadata and database"""
try:
if stream_type == "input":
# Example lines:
@ -432,6 +432,9 @@ class ChannelService:
# 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}")
@ -480,6 +483,16 @@ 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, 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}, "
@ -511,9 +524,15 @@ 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, None)
logger.info(f"Audio stream info - Codec: {audio_codec}, Sample Rate: {sample_rate} Hz, "
f"Channels: {channels}, Audio Bitrate: {audio_bitrate} kb/s")
# 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
)
except Exception as e:
logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}")
@ -575,6 +594,35 @@ class ChannelService:
logger.error(f"Error updating stream info in Redis: {e}")
return False
@staticmethod
def _update_stream_stats_in_db(stream_id, **stats):
"""Update stream stats in database"""
try:
from apps.channels.models import Stream
from django.utils import timezone
stream = Stream.objects.get(id=stream_id)
# Get existing stats or create new dict
current_stats = stream.stream_stats or {}
# Update with new stats
for key, value in stats.items():
if value is not None:
current_stats[key] = value
# Save updated stats and timestamp
stream.stream_stats = current_stats
stream.stream_stats_updated_at = timezone.now()
stream.save(update_fields=['stream_stats', 'stream_stats_updated_at'])
logger.debug(f"Updated stream stats in database for stream {stream_id}: {stats}")
return True
except Exception as e:
logger.error(f"Error updating stream stats in database for stream {stream_id}: {e}")
return False
# Helper methods for Redis operations
@staticmethod

View file

@ -587,9 +587,9 @@ class StreamManager:
from .services.channel_service import ChannelService
if "video:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "video")
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")
ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio", self.current_stream_id)
# Determine log level based on content
if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']):
@ -605,7 +605,7 @@ class StreamManager:
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")
ChannelService.parse_and_store_stream_info(self.channel_id, content, "input", self.current_stream_id)
else:
# Everything else at debug level
logger.debug(f"FFmpeg stderr for channel {self.channel_id}: {content}")
@ -649,6 +649,14 @@ class StreamManager:
if any(x is not None for x in [ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_output_bitrate]):
self._update_ffmpeg_stats_in_redis(ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_output_bitrate)
# Also save ffmpeg_output_bitrate to database if we have stream_id
if ffmpeg_output_bitrate is not None and self.current_stream_id:
from .services.channel_service import ChannelService
ChannelService._update_stream_stats_in_db(
self.current_stream_id,
ffmpeg_output_bitrate=ffmpeg_output_bitrate
)
# Fix the f-string formatting
actual_fps_str = f"{actual_fps:.1f}" if actual_fps is not None else "N/A"
ffmpeg_output_bitrate_str = f"{ffmpeg_output_bitrate:.1f}" if ffmpeg_output_bitrate is not None else "N/A"

View file

@ -1675,4 +1675,18 @@ export default class API {
errorNotification('Failed to trigger stream rehash', e);
}
}
static async getStreamsByIds(ids) {
try {
const params = new URLSearchParams();
params.append('ids', ids.join(','));
const response = await request(
`${host}/api/channels/streams/?${params.toString()}`
);
return response.results || response;
} catch (e) {
errorNotification('Failed to retrieve streams by IDs', e);
}
}
}

View file

@ -1,5 +1,6 @@
import React, { useRef, useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
ListOrdered,
Play,
@ -27,6 +28,7 @@ import {
ActionIcon,
Menu,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
@ -168,19 +170,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}, []);
const copyPublicIP = async () => {
try {
await navigator.clipboard.writeText(environment.public_ip);
} catch (err) {
const inputElement = publicIPRef.current; // Get the actual input
console.log(inputElement);
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
}
const success = await copyToClipboard(environment.public_ip);
if (success) {
notifications.show({
title: 'Success',
message: 'Public IP copied to clipboard',
color: 'green',
});
} else {
console.error('Failed to copy public IP to clipboard');
}
};

View file

@ -1,6 +1,7 @@
import React, { useMemo, useState, useEffect } from 'react';
import API from '../../api';
import { GripHorizontal, SquareMinus } from 'lucide-react';
import { copyToClipboard } from '../../utils';
import { GripHorizontal, SquareMinus, ChevronDown, ChevronRight } from 'lucide-react';
import {
Box,
ActionIcon,
@ -8,7 +9,13 @@ import {
Text,
useMantineTheme,
Center,
Badge,
Group,
Tooltip,
Collapse,
Button,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
useReactTable,
getCoreRowModel,
@ -141,6 +148,135 @@ const ChannelStreams = ({ channel, isExpanded }) => {
await API.requeryChannels();
};
// Create M3U account map for quick lookup
const m3uAccountsMap = useMemo(() => {
const map = {};
if (playlists && Array.isArray(playlists)) {
playlists.forEach((account) => {
if (account.id) {
map[account.id] = account.name;
}
});
}
return map;
}, [playlists]);
// Add state for tracking which streams have advanced stats expanded
const [expandedAdvancedStats, setExpandedAdvancedStats] = useState(new Set());
// Helper function to categorize stream stats
const categorizeStreamStats = (stats) => {
if (!stats) return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
const categories = {
basic: {},
video: {},
audio: {},
technical: {},
other: {}
};
// Define which stats go in which category
const categoryMapping = {
basic: ['resolution', 'video_codec', 'source_fps', 'audio_codec', 'audio_channels'],
video: ['video_bitrate', 'pixel_format', 'width', 'height', 'aspect_ratio', 'frame_rate'],
audio: ['audio_bitrate', 'sample_rate', 'audio_format', 'audio_channels_layout'],
technical: ['stream_type', 'container_format', 'duration', 'file_size', 'ffmpeg_output_bitrate', 'input_bitrate'],
other: [] // Will catch anything not categorized above
};
// Categorize each stat
Object.entries(stats).forEach(([key, value]) => {
let categorized = false;
for (const [category, keys] of Object.entries(categoryMapping)) {
if (keys.includes(key)) {
categories[category][key] = value;
categorized = true;
break;
}
}
// If not categorized, put it in 'other'
if (!categorized) {
categories.other[key] = value;
}
});
return categories;
};
// Function to format stat values for display
const formatStatValue = (key, value) => {
if (value === null || value === undefined) return 'N/A';
// Handle specific formatting cases
switch (key) {
case 'video_bitrate':
case 'audio_bitrate':
case 'ffmpeg_output_bitrate':
return `${value} kbps`;
case 'source_fps':
case 'frame_rate':
return `${value} fps`;
case 'sample_rate':
return `${value} Hz`;
case 'file_size':
// Convert bytes to appropriate unit
if (typeof value === 'number') {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(2)} MB`;
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return value;
case 'duration':
// Format duration if it's in seconds
if (typeof value === 'number') {
const hours = Math.floor(value / 3600);
const minutes = Math.floor((value % 3600) / 60);
const seconds = Math.floor(value % 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return value;
default:
return value.toString();
}
};
// Function to render a stats category
const renderStatsCategory = (categoryName, stats) => {
if (!stats || Object.keys(stats).length === 0) return null;
return (
<Box key={categoryName} mb="xs">
<Text size="xs" fw={600} mb={4} tt="uppercase" c="dimmed">
{categoryName}
</Text>
<Group gap={4} mb="xs">
{Object.entries(stats).map(([key, value]) => (
<Tooltip key={key} label={`${key}: ${formatStatValue(key, value)}`}>
<Badge size="xs" variant="light" color="gray">
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}: {formatStatValue(key, value)}
</Badge>
</Tooltip>
))}
</Group>
</Box>
);
};
// Function to toggle advanced stats for a stream
const toggleAdvancedStats = (streamId) => {
const newExpanded = new Set(expandedAdvancedStats);
if (newExpanded.has(streamId)) {
newExpanded.delete(streamId);
} else {
newExpanded.add(streamId);
}
setExpandedAdvancedStats(newExpanded);
};
const table = useReactTable({
columns: useMemo(
() => [
@ -152,14 +288,149 @@ const ChannelStreams = ({ channel, isExpanded }) => {
},
{
id: 'name',
header: 'Name',
header: 'Stream Info',
accessorKey: 'name',
},
{
id: 'm3u',
header: 'M3U',
accessorFn: (row) =>
playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
cell: ({ row }) => {
const stream = row.original;
const playlistName = playlists[stream.m3u_account]?.name || 'Unknown';
const accountName = m3uAccountsMap[stream.m3u_account] || playlistName;
// Categorize stream stats
const categorizedStats = categorizeStreamStats(stream.stream_stats);
const hasAdvancedStats = Object.values(categorizedStats).some(category =>
Object.keys(category).length > 0
);
return (
<Box>
<Group gap="xs" align="center">
<Text fw={500} size="sm">{stream.name}</Text>
<Badge size="xs" variant="light" color="teal">
{accountName}
</Badge>
{stream.quality && (
<Badge size="xs" variant="light" color="gray">
{stream.quality}
</Badge>
)}
{stream.url && (
<Tooltip label={stream.url}>
<Badge
size="xs"
variant="light"
color="indigo"
style={{ cursor: 'pointer' }}
onClick={async (e) => {
e.stopPropagation();
const success = await copyToClipboard(stream.url);
notifications.show({
title: success ? 'URL Copied' : 'Copy Failed',
message: success ? 'Stream URL copied to clipboard' : 'Failed to copy URL to clipboard',
color: success ? 'green' : 'red',
});
}}
>
URL
</Badge>
</Tooltip>
)}
</Group>
{/* Basic Stream Stats (always shown) */}
{stream.stream_stats && (
<Group gap="xs" mt={4} align="center">
{/* Video Information */}
{(stream.stream_stats.video_codec || stream.stream_stats.resolution || stream.stream_stats.video_bitrate || stream.stream_stats.source_fps) && (
<>
<Text size="xs" c="dimmed" fw={500}>Video:</Text>
{stream.stream_stats.resolution && (
<Badge size="xs" variant="light" color="red">
{stream.stream_stats.resolution}
</Badge>
)}
{stream.stream_stats.video_bitrate && (
<Badge size="xs" variant="light" color="orange" style={{ textTransform: 'none' }}>
{stream.stream_stats.video_bitrate} kbps
</Badge>
)}
{stream.stream_stats.source_fps && (
<Badge size="xs" variant="light" color="orange">
{stream.stream_stats.source_fps} FPS
</Badge>
)}
{stream.stream_stats.video_codec && (
<Badge size="xs" variant="light" color="blue">
{stream.stream_stats.video_codec.toUpperCase()}
</Badge>
)}
</>
)}
{/* Audio Information */}
{(stream.stream_stats.audio_codec || stream.stream_stats.audio_channels) && (
<>
<Text size="xs" c="dimmed" fw={500}>Audio:</Text>
{stream.stream_stats.audio_channels && (
<Badge size="xs" variant="light" color="pink">
{stream.stream_stats.audio_channels}
</Badge>
)}
{stream.stream_stats.audio_codec && (
<Badge size="xs" variant="light" color="pink">
{stream.stream_stats.audio_codec.toUpperCase()}
</Badge>
)}
</>
)}
{/* Output Bitrate */}
{(stream.stream_stats.ffmpeg_output_bitrate) && (
<>
<Text size="xs" c="dimmed" fw={500}>Output Bitrate:</Text>
{stream.stream_stats.ffmpeg_output_bitrate && (
<Badge size="xs" variant="light" color="orange" style={{ textTransform: 'none' }}>
{stream.stream_stats.ffmpeg_output_bitrate} kbps
</Badge>
)}
</>
)}
</Group>
)}
{/* Advanced Stats Toggle Button */}
{hasAdvancedStats && (
<Group gap="xs" mt={6}>
<Button
variant="subtle"
size="xs"
leftSection={expandedAdvancedStats.has(stream.id) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
onClick={() => toggleAdvancedStats(stream.id)}
c="dimmed"
>
{expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'} Advanced Stats
</Button>
</Group>
)}
{/* Advanced Stats (expandable) */}
<Collapse in={expandedAdvancedStats.has(stream.id)}>
<Box mt="sm" p="xs" style={{ backgroundColor: 'rgba(0,0,0,0.1)', borderRadius: '4px' }}>
{renderStatsCategory('Video', categorizedStats.video)}
{renderStatsCategory('Audio', categorizedStats.audio)}
{renderStatsCategory('Technical', categorizedStats.technical)}
{renderStatsCategory('Other', categorizedStats.other)}
{/* Show when stats were last updated */}
{stream.stream_stats_updated_at && (
<Text size="xs" c="dimmed" mt="xs">
Last updated: {new Date(stream.stream_stats_updated_at).toLocaleString()}
</Text>
)}
</Box>
</Collapse>
</Box>
);
},
},
{
id: 'actions',
@ -178,7 +449,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
),
},
],
[data, playlists]
[data, playlists, m3uAccountsMap, expandedAdvancedStats]
),
data,
state: {
@ -292,4 +563,5 @@ const ChannelStreams = ({ channel, isExpanded }) => {
);
};
export default ChannelStreams;

View file

@ -524,24 +524,12 @@ const ChannelsTable = ({ }) => {
};
const handleCopy = async (textToCopy, ref) => {
try {
await navigator.clipboard.writeText(textToCopy);
notifications.show({
title: 'Copied!',
// style: { width: '200px', left: '200px' },
});
} catch (err) {
const inputElement = ref.current; // Get the actual input
if (inputElement) {
inputElement.focus();
inputElement.select();
// For older browsers
document.execCommand('copy');
notifications.show({ title: 'Copied!' });
}
}
const success = await copyToClipboard(textToCopy);
notifications.show({
title: success ? 'Copied!' : 'Copy Failed',
message: success ? undefined : 'Failed to copy to clipboard',
color: success ? 'green' : 'red',
});
};
// Build URLs with parameters
const buildM3UUrl = () => {
@ -564,25 +552,30 @@ const ChannelsTable = ({ }) => {
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
};
// Example copy URLs
const copyM3UUrl = () => {
copyToClipboard(buildM3UUrl());
const copyM3UUrl = async () => {
const success = await copyToClipboard(buildM3UUrl());
notifications.show({
title: 'M3U URL Copied!',
message: 'The M3U URL has been copied to your clipboard.',
title: success ? 'M3U URL Copied!' : 'Copy Failed',
message: success ? 'The M3U URL has been copied to your clipboard.' : 'Failed to copy M3U URL to clipboard',
color: success ? 'green' : 'red',
});
};
const copyEPGUrl = () => {
copyToClipboard(buildEPGUrl());
const copyEPGUrl = async () => {
const success = await copyToClipboard(buildEPGUrl());
notifications.show({
title: 'EPG URL Copied!',
message: 'The EPG URL has been copied to your clipboard.',
title: success ? 'EPG URL Copied!' : 'Copy Failed',
message: success ? 'The EPG URL has been copied to your clipboard.' : 'Failed to copy EPG URL to clipboard',
color: success ? 'green' : 'red',
});
};
const copyHDHRUrl = () => {
copyToClipboard(hdhrUrl);
const copyHDHRUrl = async () => {
const success = await copyToClipboard(hdhrUrl);
notifications.show({
title: 'HDHR URL Copied!',
message: 'The HDHR URL has been copied to your clipboard.',
title: success ? 'HDHR URL Copied!' : 'Copy Failed',
message: success ? 'The HDHR URL has been copied to your clipboard.' : 'Failed to copy HDHR URL to clipboard',
color: success ? 'green' : 'red',
});
};

View file

@ -102,10 +102,10 @@ const StreamRowActions = ({
'ID:',
row.original.id,
'Hash:',
row.original.stream_hash
row.original.stream_hash,
);
handleWatchStream(row.original.stream_hash);
}, [row.original.id]); // Add proper dependency to ensure correct stream
}, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream
const iconSize =
tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md';
@ -410,8 +410,16 @@ const StreamsTable = ({ }) => {
try {
const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId;
// Try to fetch the actual stream data for selected streams
let streamsData = [];
try {
streamsData = await API.getStreamsByIds(selectedStreamIds);
} catch (error) {
console.warn('Could not fetch stream details, using IDs only:', error);
}
const streamData = selectedStreamIds.map(streamId => {
const stream = data.find(s => s.id === streamId);
const stream = streamsData.find(s => s.id === streamId);
return {
stream_id: streamId,
name: stream?.name || `Stream ${streamId}`,

View file

@ -65,24 +65,27 @@ export const getDescendantProp = (obj, path) =>
path.split('.').reduce((acc, part) => acc && acc[part], obj);
export const copyToClipboard = async (value) => {
let copied = false;
if (navigator.clipboard) {
// Modern method, using navigator.clipboard
try {
await navigator.clipboard.writeText(value);
copied = true;
return true;
} catch (err) {
console.error('Failed to copy: ', err);
}
}
if (!copied) {
// Fallback method for environments without clipboard support
// Fallback method for environments without clipboard support
try {
const textarea = document.createElement('textarea');
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
return successful;
} catch (err) {
console.error('Failed to copy with fallback method: ', err);
return false;
}
};