Refactor channel stats fetching and enhance settings UI for better user experience

This commit is contained in:
SergeantPanda 2025-09-05 09:42:52 -05:00
parent ca79cc1a1d
commit d709d92936
4 changed files with 213 additions and 90 deletions

View file

@ -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

View file

@ -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);

View file

@ -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>

View file

@ -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',