mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-29 04:50:20 +00:00
commit
64ec6de414
11 changed files with 450 additions and 68 deletions
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ class StreamSerializer(serializers.ModelSerializer):
|
|||
"is_custom",
|
||||
"channel_group",
|
||||
"stream_hash",
|
||||
"stream_stats",
|
||||
"stream_stats_updated_at",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue