From 6180b4ffef6aaf522e39cd6683ca8b53632471d7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 28 Jul 2025 21:40:29 -0500 Subject: [PATCH 1/9] Save stream stats to database. --- ...am_stats_stream_stream_stats_updated_at.py | 23 ++++++++ apps/channels/models.py | 13 +++++ apps/channels/serializers.py | 2 + .../ts_proxy/services/channel_service.py | 58 +++++++++++++++++-- apps/proxy/ts_proxy/stream_manager.py | 14 ++++- 5 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py diff --git a/apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py b/apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py new file mode 100644 index 00000000..1b0fdbe8 --- /dev/null +++ b/apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py @@ -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), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index f53a9875..d6c3faef 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -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') diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 9273b265..32fd4a74 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -104,6 +104,8 @@ class StreamSerializer(serializers.ModelSerializer): "is_custom", "channel_group", "stream_hash", + "stream_stats", + "stream_stats_updated_at", ] def get_fields(self): diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 026aa883..932479ea 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -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 diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py index f7c538c2..e80d4527 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -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" From b222dbe5a32eb33ee557a8452b0e8feaf1b6456f Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 11:21:44 -0500 Subject: [PATCH 2/9] Display stream qualites in channel table. --- .../components/tables/ChannelTableStreams.jsx | 134 ++++++++++++++++-- .../src/components/tables/StreamsTable.jsx | 8 +- 2 files changed, 132 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 097a2ba8..3e034fb3 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -8,7 +8,11 @@ import { Text, useMantineTheme, Center, + Badge, + Group, + Tooltip, } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; import { useReactTable, getCoreRowModel, @@ -141,6 +145,19 @@ 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]); + const table = useReactTable({ columns: useMemo( () => [ @@ -152,14 +169,114 @@ 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; + + return ( + + {stream.name} + + + {accountName} + + {stream.quality && ( + + {stream.quality} + + )} + {stream.url && ( + + { + e.stopPropagation(); + navigator.clipboard.writeText(stream.url); + notifications.show({ + title: 'URL Copied', + message: 'Stream URL copied to clipboard', + color: 'green', + }); + }} + > + URL + + + )} + + {stream.stream_stats && ( + + {/* Video Information */} + {(stream.stream_stats.video_codec || stream.stream_stats.resolution || stream.stream_stats.video_bitrate || stream.stream_stats.source_fps) && ( + <> + Video: + {stream.stream_stats.resolution && ( + + {stream.stream_stats.resolution} + + )} + {stream.stream_stats.video_bitrate && ( + + {stream.stream_stats.video_bitrate} KbPS + + )} + {stream.stream_stats.source_fps && ( + + {stream.stream_stats.source_fps} FPS + + )} + {stream.stream_stats.video_codec && ( + + {stream.stream_stats.video_codec.toUpperCase()} + + )} + + )} + + {/* Audio Information */} + {(stream.stream_stats.audio_codec || stream.stream_stats.audio_channels || stream.stream_stats.audio_bitrate) && ( + <> + Audio: + {stream.stream_stats.audio_channels && ( + + {stream.stream_stats.audio_channels} + + )} + {stream.stream_stats.audio_codec && ( + + {stream.stream_stats.audio_codec.toUpperCase()} + + )} + {stream.stream_stats.audio_bitrate && ( + + {stream.stream_stats.audio_bitrate} KbPS + + )} + + )} + + {/* Output Bitrate */} + {(stream.stream_stats.ffmpeg_output_bitrate) && ( + <> + Output Bitrate: + {stream.stream_stats.ffmpeg_output_bitrate && ( + + {stream.stream_stats.ffmpeg_output_bitrate} KbPS + + )} + + )} + + )} + + ); + }, }, { id: 'actions', @@ -178,7 +295,7 @@ const ChannelStreams = ({ channel, isExpanded }) => { ), }, ], - [data, playlists] + [data, playlists, m3uAccountsMap] ), data, state: { @@ -292,4 +409,5 @@ const ChannelStreams = ({ channel, isExpanded }) => { ); }; + export default ChannelStreams; diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index c149e2a7..616a52a3 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -102,10 +102,14 @@ const StreamRowActions = ({ 'ID:', row.original.id, 'Hash:', - row.original.stream_hash + row.original.stream_hash, + 'Quality:', + row.original.quality, + 'Stats:', + row.original.stream_stats ); 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'; From 5b076fd00bdf27acc19839e9c5e0d5ba74352d04 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 11:35:22 -0500 Subject: [PATCH 3/9] Allow for clicking url badge and copy to clipboard. --- frontend/src/components/Sidebar.jsx | 24 ++++----- .../components/tables/ChannelTableStreams.jsx | 11 ++-- .../src/components/tables/ChannelsTable.jsx | 53 ++++++++----------- frontend/src/utils.js | 13 +++-- 4 files changed, 48 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 08114d99..03ad831e 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -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'); } }; diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 3e034fb3..391f4d30 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -1,5 +1,6 @@ import React, { useMemo, useState, useEffect } from 'react'; import API from '../../api'; +import { copyToClipboard } from '../../utils'; import { GripHorizontal, SquareMinus } from 'lucide-react'; import { Box, @@ -195,13 +196,13 @@ const ChannelStreams = ({ channel, isExpanded }) => { variant="light" color="indigo" style={{ cursor: 'pointer' }} - onClick={(e) => { + onClick={async (e) => { e.stopPropagation(); - navigator.clipboard.writeText(stream.url); + const success = await copyToClipboard(stream.url); notifications.show({ - title: 'URL Copied', - message: 'Stream URL copied to clipboard', - color: 'green', + title: success ? 'URL Copied' : 'Copy Failed', + message: success ? 'Stream URL copied to clipboard' : 'Failed to copy URL to clipboard', + color: success ? 'green' : 'red', }); }} > diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index 3d34ca55..3380fbd2 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -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', }); }; diff --git a/frontend/src/utils.js b/frontend/src/utils.js index a488ce8d..c995fd89 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -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; } }; From 72ecb88c76a9b1be8f4ef88b7ef0b38c40f28cf7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 14:59:03 -0500 Subject: [PATCH 4/9] Fix bug where creating a channel from a stream that isn't displayed in the table has invalid stream name. --- frontend/src/components/tables/StreamsTable.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 616a52a3..4731c019 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -103,10 +103,6 @@ const StreamRowActions = ({ row.original.id, 'Hash:', row.original.stream_hash, - 'Quality:', - row.original.quality, - 'Stats:', - row.original.stream_stats ); handleWatchStream(row.original.stream_hash); }, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream @@ -414,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}`, From cb49172e988200210bee52aacc2902792138b0c5 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 15:02:13 -0500 Subject: [PATCH 5/9] Fix bug where creating a channel from a stream that isn't displayed in the table has invalid stream name. --- frontend/src/api.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/api.js b/frontend/src/api.js index effc0bdd..ddaccbc7 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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); + } + } } From 44f8d4576805bdf1ec9e4ffc789e13883d5cc1bf Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 15:02:35 -0500 Subject: [PATCH 6/9] Add the ability to see advanced stats for channel streams. --- .../components/tables/ChannelTableStreams.jsx | 168 +++++++++++++++++- 1 file changed, 163 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 391f4d30..373427bb 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -1,7 +1,7 @@ import React, { useMemo, useState, useEffect } from 'react'; import API from '../../api'; import { copyToClipboard } from '../../utils'; -import { GripHorizontal, SquareMinus } from 'lucide-react'; +import { GripHorizontal, SquareMinus, ChevronDown, ChevronRight } from 'lucide-react'; import { Box, ActionIcon, @@ -12,6 +12,8 @@ import { Badge, Group, Tooltip, + Collapse, + Button, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { @@ -159,6 +161,122 @@ const ChannelStreams = ({ channel, isExpanded }) => { 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'], + 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 ( + + + {categoryName} + + + {Object.entries(stats).map(([key, value]) => ( + + + {key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}: {formatStatValue(key, value)} + + + ))} + + + ); + }; + + // 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( () => [ @@ -177,6 +295,12 @@ const ChannelStreams = ({ channel, isExpanded }) => { 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 ( {stream.name} @@ -211,6 +335,8 @@ const ChannelStreams = ({ channel, isExpanded }) => { )} + + {/* Basic Stream Stats (always shown) */} {stream.stream_stats && ( {/* Video Information */} @@ -224,7 +350,7 @@ const ChannelStreams = ({ channel, isExpanded }) => { )} {stream.stream_stats.video_bitrate && ( - {stream.stream_stats.video_bitrate} KbPS + {stream.stream_stats.video_bitrate} kbps )} {stream.stream_stats.source_fps && ( @@ -256,7 +382,7 @@ const ChannelStreams = ({ channel, isExpanded }) => { )} {stream.stream_stats.audio_bitrate && ( - {stream.stream_stats.audio_bitrate} KbPS + {stream.stream_stats.audio_bitrate} kbps )} @@ -268,13 +394,45 @@ const ChannelStreams = ({ channel, isExpanded }) => { Output Bitrate: {stream.stream_stats.ffmpeg_output_bitrate && ( - {stream.stream_stats.ffmpeg_output_bitrate} KbPS + {stream.stream_stats.ffmpeg_output_bitrate} kbps )} )} )} + + {/* Advanced Stats Toggle Button */} + {hasAdvancedStats && ( + + + + )} + + {/* Advanced Stats (expandable) */} + + + {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 && ( + + Last updated: {new Date(stream.stream_stats_updated_at).toLocaleString()} + + )} + + ); }, @@ -296,7 +454,7 @@ const ChannelStreams = ({ channel, isExpanded }) => { ), }, ], - [data, playlists, m3uAccountsMap] + [data, playlists, m3uAccountsMap, expandedAdvancedStats] ), data, state: { From 7551869a2eaa8101926af557839947fa68288b6b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 15:12:14 -0500 Subject: [PATCH 7/9] Remove audio bitrate from basic stats. --- frontend/src/components/tables/ChannelTableStreams.jsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 373427bb..2da4002d 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -367,7 +367,7 @@ const ChannelStreams = ({ channel, isExpanded }) => { )} {/* Audio Information */} - {(stream.stream_stats.audio_codec || stream.stream_stats.audio_channels || stream.stream_stats.audio_bitrate) && ( + {(stream.stream_stats.audio_codec || stream.stream_stats.audio_channels) && ( <> Audio: {stream.stream_stats.audio_channels && ( @@ -380,11 +380,6 @@ const ChannelStreams = ({ channel, isExpanded }) => { {stream.stream_stats.audio_codec.toUpperCase()} )} - {stream.stream_stats.audio_bitrate && ( - - {stream.stream_stats.audio_bitrate} kbps - - )} )} From e26ecad013c0fba5f76c5df0fbecc721462871de Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 15:17:18 -0500 Subject: [PATCH 8/9] Move m3u and url badges to same line as stream name. --- frontend/src/components/tables/ChannelTableStreams.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 2da4002d..d226c52a 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -303,8 +303,8 @@ const ChannelStreams = ({ channel, isExpanded }) => { return ( - {stream.name} - + + {stream.name} {accountName} From 613c0d8bb559dd25c37229e68dc15f38ae77a914 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 29 Jul 2025 15:43:44 -0500 Subject: [PATCH 9/9] Add input_bitrate to technical for future use. --- frontend/src/components/tables/ChannelTableStreams.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index d226c52a..11fa9600 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -181,7 +181,7 @@ const ChannelStreams = ({ channel, isExpanded }) => { 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'], + technical: ['stream_type', 'container_format', 'duration', 'file_size', 'ffmpeg_output_bitrate', 'input_bitrate'], other: [] // Will catch anything not categorized above };