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"
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);
+ }
+ }
}
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 097a2ba8..11fa9600 100644
--- a/frontend/src/components/tables/ChannelTableStreams.jsx
+++ b/frontend/src/components/tables/ChannelTableStreams.jsx
@@ -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 (
+
+
+ {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(
() => [
@@ -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 (
+
+
+ {stream.name}
+
+ {accountName}
+
+ {stream.quality && (
+
+ {stream.quality}
+
+ )}
+ {stream.url && (
+
+ {
+ 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
+
+
+ )}
+
+
+ {/* Basic Stream Stats (always shown) */}
+ {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) && (
+ <>
+ Audio:
+ {stream.stream_stats.audio_channels && (
+
+ {stream.stream_stats.audio_channels}
+
+ )}
+ {stream.stream_stats.audio_codec && (
+
+ {stream.stream_stats.audio_codec.toUpperCase()}
+
+ )}
+ >
+ )}
+
+ {/* Output Bitrate */}
+ {(stream.stream_stats.ffmpeg_output_bitrate) && (
+ <>
+ Output Bitrate:
+ {stream.stream_stats.ffmpeg_output_bitrate && (
+
+ {stream.stream_stats.ffmpeg_output_bitrate} kbps
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* Advanced Stats Toggle Button */}
+ {hasAdvancedStats && (
+
+ : }
+ onClick={() => toggleAdvancedStats(stream.id)}
+ c="dimmed"
+ >
+ {expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'} Advanced Stats
+
+
+ )}
+
+ {/* 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()}
+
+ )}
+
+
+
+ );
+ },
},
{
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;
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/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx
index c149e2a7..4731c019 100644
--- a/frontend/src/components/tables/StreamsTable.jsx
+++ b/frontend/src/components/tables/StreamsTable.jsx
@@ -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}`,
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;
}
};