diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index 040e9156..1448ebd1 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -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
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 71b2d692..f8e2e1ec 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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);
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 43425fbd..85ea85f1 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -429,10 +429,18 @@ 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'
+ }
/>
{
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'
+ }
/>
{
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'
+ }
/>
{
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'
+ }
/>
-
-
+
+
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index 97ff2db1..1a27d163 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -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 (
+
+
+ Active Channels
+
+ setRefreshIntervalSeconds(value || 0)}
+ min={0}
+ max={300}
+ step={1}
+ size="xs"
+ style={{ width: 120 }}
+ description={
+ refreshIntervalSeconds === 0 ? 'Disabled' : 'Auto-refresh'
+ }
+ />
+ {isPollingActive && refreshInterval > 0 && (
+
+ Refreshing every {refreshIntervalSeconds}s
+
+ )}
+
+
+
+