mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Refactor channel stats fetching and enhance settings UI for better user experience
This commit is contained in:
parent
ca79cc1a1d
commit
d709d92936
4 changed files with 213 additions and 90 deletions
|
|
@ -198,10 +198,8 @@ CELERY_TASK_SERIALIZER = "json"
|
|||
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"fetch-channel-statuses": {
|
||||
"task": "apps.proxy.tasks.fetch_channel_stats", # Direct task call
|
||||
"schedule": 2.0, # Every 2 seconds
|
||||
},
|
||||
# Remove the frequent fetch-channel-statuses task
|
||||
# Stats are now fetched via API calls from the frontend
|
||||
"scan-files": {
|
||||
"task": "core.tasks.scan_and_process_files", # Direct task call
|
||||
"schedule": 20.0, # Every 20 seconds
|
||||
|
|
|
|||
|
|
@ -1317,6 +1317,16 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async fetchActiveChannelStats() {
|
||||
try {
|
||||
const response = await request(`${host}/proxy/ts/status/`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to fetch active channel stats', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async getLogos(params = {}) {
|
||||
try {
|
||||
const queryParams = new URLSearchParams(params);
|
||||
|
|
|
|||
|
|
@ -429,10 +429,18 @@ const SettingsPage = () => {
|
|||
<Stack gap="sm">
|
||||
<Switch
|
||||
label="Enable Comskip (remove commercials after recording)"
|
||||
{...form.getInputProps('dvr-comskip-enabled', { type: 'checkbox' })}
|
||||
{...form.getInputProps('dvr-comskip-enabled', {
|
||||
type: 'checkbox',
|
||||
})}
|
||||
key={form.key('dvr-comskip-enabled')}
|
||||
id={settings['dvr-comskip-enabled']?.id || 'dvr-comskip-enabled'}
|
||||
name={settings['dvr-comskip-enabled']?.key || 'dvr-comskip-enabled'}
|
||||
id={
|
||||
settings['dvr-comskip-enabled']?.id ||
|
||||
'dvr-comskip-enabled'
|
||||
}
|
||||
name={
|
||||
settings['dvr-comskip-enabled']?.key ||
|
||||
'dvr-comskip-enabled'
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="TV Path Template"
|
||||
|
|
@ -440,8 +448,12 @@ const SettingsPage = () => {
|
|||
placeholder="Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"
|
||||
{...form.getInputProps('dvr-tv-template')}
|
||||
key={form.key('dvr-tv-template')}
|
||||
id={settings['dvr-tv-template']?.id || 'dvr-tv-template'}
|
||||
name={settings['dvr-tv-template']?.key || 'dvr-tv-template'}
|
||||
id={
|
||||
settings['dvr-tv-template']?.id || 'dvr-tv-template'
|
||||
}
|
||||
name={
|
||||
settings['dvr-tv-template']?.key || 'dvr-tv-template'
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="TV Fallback Template"
|
||||
|
|
@ -449,8 +461,14 @@ const SettingsPage = () => {
|
|||
placeholder="Recordings/TV_Shows/{show}/{start}.mkv"
|
||||
{...form.getInputProps('dvr-tv-fallback-template')}
|
||||
key={form.key('dvr-tv-fallback-template')}
|
||||
id={settings['dvr-tv-fallback-template']?.id || 'dvr-tv-fallback-template'}
|
||||
name={settings['dvr-tv-fallback-template']?.key || 'dvr-tv-fallback-template'}
|
||||
id={
|
||||
settings['dvr-tv-fallback-template']?.id ||
|
||||
'dvr-tv-fallback-template'
|
||||
}
|
||||
name={
|
||||
settings['dvr-tv-fallback-template']?.key ||
|
||||
'dvr-tv-fallback-template'
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Movie Path Template"
|
||||
|
|
@ -458,8 +476,14 @@ const SettingsPage = () => {
|
|||
placeholder="Recordings/Movies/{title} ({year}).mkv"
|
||||
{...form.getInputProps('dvr-movie-template')}
|
||||
key={form.key('dvr-movie-template')}
|
||||
id={settings['dvr-movie-template']?.id || 'dvr-movie-template'}
|
||||
name={settings['dvr-movie-template']?.key || 'dvr-movie-template'}
|
||||
id={
|
||||
settings['dvr-movie-template']?.id ||
|
||||
'dvr-movie-template'
|
||||
}
|
||||
name={
|
||||
settings['dvr-movie-template']?.key ||
|
||||
'dvr-movie-template'
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Movie Fallback Template"
|
||||
|
|
@ -467,11 +491,24 @@ const SettingsPage = () => {
|
|||
placeholder="Recordings/Movies/{start}.mkv"
|
||||
{...form.getInputProps('dvr-movie-fallback-template')}
|
||||
key={form.key('dvr-movie-fallback-template')}
|
||||
id={settings['dvr-movie-fallback-template']?.id || 'dvr-movie-fallback-template'}
|
||||
name={settings['dvr-movie-fallback-template']?.key || 'dvr-movie-fallback-template'}
|
||||
id={
|
||||
settings['dvr-movie-fallback-template']?.id ||
|
||||
'dvr-movie-fallback-template'
|
||||
}
|
||||
name={
|
||||
settings['dvr-movie-fallback-template']?.key ||
|
||||
'dvr-movie-fallback-template'
|
||||
}
|
||||
/>
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" variant="default">Save</Button>
|
||||
<Flex
|
||||
mih={50}
|
||||
gap="xs"
|
||||
justify="flex-end"
|
||||
align="flex-end"
|
||||
>
|
||||
<Button type="submit" variant="default">
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
|
|
@ -12,9 +13,9 @@ import {
|
|||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
useMantineTheme,
|
||||
Select,
|
||||
Badge,
|
||||
NumberInput,
|
||||
} from '@mantine/core';
|
||||
import { TableHelper } from '../helpers';
|
||||
import API from '../api';
|
||||
|
|
@ -197,7 +198,7 @@ const ChannelCard = ({
|
|||
...client,
|
||||
}))
|
||||
);
|
||||
}, [clients]);
|
||||
}, [clients, channel.channel_id]);
|
||||
|
||||
const renderHeaderCell = (header) => {
|
||||
switch (header.id) {
|
||||
|
|
@ -718,17 +719,23 @@ const ChannelCard = ({
|
|||
};
|
||||
|
||||
const ChannelsPage = () => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const channels = useChannelsStore((s) => s.channels);
|
||||
const channelsByUUID = useChannelsStore((s) => s.channelsByUUID);
|
||||
const channelStats = useChannelsStore((s) => s.stats);
|
||||
const logos = useLogosStore((s) => s.logos); // Add logos from the store
|
||||
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchSettings);
|
||||
|
||||
const [activeChannels, setActiveChannels] = useState({});
|
||||
const [clients, setClients] = useState([]);
|
||||
const [isPollingActive, setIsPollingActive] = useState(false);
|
||||
|
||||
// Use localStorage for stats refresh interval (in seconds)
|
||||
const [refreshIntervalSeconds, setRefreshIntervalSeconds] = useLocalStorage(
|
||||
'stats-refresh-interval',
|
||||
5
|
||||
);
|
||||
const refreshInterval = refreshIntervalSeconds * 1000; // Convert to milliseconds
|
||||
|
||||
const channelsColumns = useMemo(
|
||||
() => [
|
||||
|
|
@ -818,12 +825,47 @@ const ChannelsPage = () => {
|
|||
await API.stopClient(channelId, clientId);
|
||||
};
|
||||
|
||||
// The main clientsTable is no longer needed since each channel card has its own table
|
||||
// Function to fetch channel stats from API
|
||||
const fetchChannelStats = useCallback(async () => {
|
||||
try {
|
||||
const response = await API.fetchActiveChannelStats();
|
||||
if (response) {
|
||||
setChannelStats(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching channel stats:', error);
|
||||
}
|
||||
}, [setChannelStats]);
|
||||
|
||||
// Fetch settings on component mount
|
||||
// Set up polling for stats when on stats page
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
const location = window.location;
|
||||
const isOnStatsPage = location.pathname === '/stats';
|
||||
|
||||
if (isOnStatsPage && refreshInterval > 0) {
|
||||
setIsPollingActive(true);
|
||||
|
||||
// Initial fetch
|
||||
fetchChannelStats();
|
||||
|
||||
// Set up interval
|
||||
const interval = setInterval(() => {
|
||||
fetchChannelStats();
|
||||
}, refreshInterval);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
setIsPollingActive(false);
|
||||
};
|
||||
} else {
|
||||
setIsPollingActive(false);
|
||||
}
|
||||
}, [refreshInterval, fetchChannelStats]);
|
||||
|
||||
// Fetch initial stats on component mount (for immediate data when navigating to page)
|
||||
useEffect(() => {
|
||||
fetchChannelStats();
|
||||
}, [fetchChannelStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -834,81 +876,117 @@ const ChannelsPage = () => {
|
|||
) {
|
||||
console.log('No channel stats available:', channelStats);
|
||||
// Clear active channels when there are no stats
|
||||
if (Object.keys(activeChannels).length > 0) {
|
||||
setActiveChannels({});
|
||||
setClients([]);
|
||||
}
|
||||
setActiveChannels((prevActiveChannels) => {
|
||||
if (Object.keys(prevActiveChannels).length > 0) {
|
||||
setClients([]);
|
||||
return {};
|
||||
}
|
||||
return prevActiveChannels;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a completely new object based only on current channel stats
|
||||
const stats = {};
|
||||
// Use functional update to access previous state without dependency
|
||||
setActiveChannels((prevActiveChannels) => {
|
||||
// Create a completely new object based only on current channel stats
|
||||
const stats = {};
|
||||
|
||||
// Track which channels are currently active according to channelStats
|
||||
const currentActiveChannelIds = new Set(
|
||||
channelStats.channels.map((ch) => ch.channel_id).filter(Boolean)
|
||||
);
|
||||
|
||||
channelStats.channels.forEach((ch) => {
|
||||
// Make sure we have a valid channel_id
|
||||
if (!ch.channel_id) {
|
||||
console.warn('Found channel without channel_id:', ch);
|
||||
return;
|
||||
}
|
||||
|
||||
let bitrates = [];
|
||||
if (activeChannels[ch.channel_id]) {
|
||||
bitrates = [...(activeChannels[ch.channel_id].bitrates || [])];
|
||||
const bitrate =
|
||||
ch.total_bytes - activeChannels[ch.channel_id].total_bytes;
|
||||
if (bitrate > 0) {
|
||||
bitrates.push(bitrate);
|
||||
channelStats.channels.forEach((ch) => {
|
||||
// Make sure we have a valid channel_id
|
||||
if (!ch.channel_id) {
|
||||
console.warn('Found channel without channel_id:', ch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bitrates.length > 15) {
|
||||
bitrates = bitrates.slice(1);
|
||||
let bitrates = [];
|
||||
if (prevActiveChannels[ch.channel_id]) {
|
||||
bitrates = [...(prevActiveChannels[ch.channel_id].bitrates || [])];
|
||||
const bitrate =
|
||||
ch.total_bytes - prevActiveChannels[ch.channel_id].total_bytes;
|
||||
if (bitrate > 0) {
|
||||
bitrates.push(bitrate);
|
||||
}
|
||||
|
||||
if (bitrates.length > 15) {
|
||||
bitrates = bitrates.slice(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find corresponding channel data
|
||||
const channelData =
|
||||
channelsByUUID && ch.channel_id
|
||||
? channels[channelsByUUID[ch.channel_id]]
|
||||
: null;
|
||||
// Find corresponding channel data
|
||||
const channelData =
|
||||
channelsByUUID && ch.channel_id
|
||||
? channels[channelsByUUID[ch.channel_id]]
|
||||
: null;
|
||||
|
||||
// Find stream profile
|
||||
const streamProfile = streamProfiles.find(
|
||||
(profile) => profile.id == parseInt(ch.stream_profile)
|
||||
);
|
||||
|
||||
stats[ch.channel_id] = {
|
||||
...ch,
|
||||
...(channelData || {}), // Safely merge channel data if available
|
||||
bitrates,
|
||||
stream_profile: streamProfile || { name: 'Unknown' },
|
||||
// Make sure stream_id is set from the active stream info
|
||||
stream_id: ch.stream_id || null,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Processed active channels:', stats);
|
||||
setActiveChannels(stats);
|
||||
|
||||
const clientStats = Object.values(stats).reduce((acc, ch) => {
|
||||
if (ch.clients && Array.isArray(ch.clients)) {
|
||||
return acc.concat(
|
||||
ch.clients.map((client) => ({
|
||||
...client,
|
||||
channel: ch,
|
||||
}))
|
||||
// Find stream profile
|
||||
const streamProfile = streamProfiles.find(
|
||||
(profile) => profile.id == parseInt(ch.stream_profile)
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
setClients(clientStats);
|
||||
|
||||
stats[ch.channel_id] = {
|
||||
...ch,
|
||||
...(channelData || {}), // Safely merge channel data if available
|
||||
bitrates,
|
||||
stream_profile: streamProfile || { name: 'Unknown' },
|
||||
// Make sure stream_id is set from the active stream info
|
||||
stream_id: ch.stream_id || null,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Processed active channels:', stats);
|
||||
|
||||
// Update clients based on new stats
|
||||
const clientStats = Object.values(stats).reduce((acc, ch) => {
|
||||
if (ch.clients && Array.isArray(ch.clients)) {
|
||||
return acc.concat(
|
||||
ch.clients.map((client) => ({
|
||||
...client,
|
||||
channel: ch,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
setClients(clientStats);
|
||||
|
||||
return stats;
|
||||
});
|
||||
}, [channelStats, channels, channelsByUUID, streamProfiles]);
|
||||
return (
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Box style={{ padding: '10px', borderBottom: '1px solid #444' }}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={3}>Active Channels</Title>
|
||||
<Group align="center">
|
||||
<NumberInput
|
||||
label="Refresh Interval (seconds)"
|
||||
value={refreshIntervalSeconds}
|
||||
onChange={(value) => setRefreshIntervalSeconds(value || 0)}
|
||||
min={0}
|
||||
max={300}
|
||||
step={1}
|
||||
size="xs"
|
||||
style={{ width: 120 }}
|
||||
description={
|
||||
refreshIntervalSeconds === 0 ? 'Disabled' : 'Auto-refresh'
|
||||
}
|
||||
/>
|
||||
{isPollingActive && refreshInterval > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Refreshing every {refreshIntervalSeconds}s
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={fetchChannelStats}
|
||||
loading={false}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue