diff --git a/apps/proxy/ts_proxy/client_manager.py b/apps/proxy/ts_proxy/client_manager.py
index bcc54602..d4b83d3a 100644
--- a/apps/proxy/ts_proxy/client_manager.py
+++ b/apps/proxy/ts_proxy/client_manager.py
@@ -12,6 +12,7 @@ from .constants import EventType
from .config_helper import ConfigHelper
from .redis_keys import RedisKeys
from .utils import get_logger
+from core.utils import send_websocket_update
logger = get_logger()
@@ -36,6 +37,45 @@ class ClientManager:
self._start_heartbeat_thread()
self._registered_clients = set() # Track already registered client IDs
+ def _trigger_stats_update(self):
+ """Trigger a channel stats update via WebSocket"""
+ try:
+ # Import here to avoid potential import issues
+ from apps.proxy.ts_proxy.channel_status import ChannelStatus
+ import redis
+
+ # Get all channels from Redis
+ redis_client = redis.Redis.from_url('redis://localhost:6379', decode_responses=True)
+ all_channels = []
+ cursor = 0
+
+ while True:
+ cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100)
+ for key in keys:
+ # Extract channel ID from key
+ parts = key.split(':')
+ if len(parts) >= 4:
+ ch_id = parts[2]
+ channel_info = ChannelStatus.get_basic_channel_info(ch_id)
+ if channel_info:
+ all_channels.append(channel_info)
+
+ if cursor == 0:
+ break
+
+ # Send WebSocket update using existing infrastructure
+ send_websocket_update(
+ "updates",
+ "update",
+ {
+ "success": True,
+ "type": "channel_stats",
+ "stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})
+ }
+ )
+ except Exception as e:
+ logger.debug(f"Failed to trigger stats update: {e}")
+
def _start_heartbeat_thread(self):
"""Start thread to regularly refresh client presence in Redis"""
def heartbeat_task():
@@ -260,6 +300,9 @@ class ClientManager:
json.dumps(event_data)
)
+ # Trigger channel stats update via WebSocket
+ self._trigger_stats_update()
+
# Get total clients across all workers
total_clients = self.get_total_client_count()
logger.info(f"New client connected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
@@ -274,6 +317,8 @@ class ClientManager:
def remove_client(self, client_id):
"""Remove a client from this channel and Redis"""
+ client_ip = None
+
with self.lock:
if client_id in self.clients:
self.clients.remove(client_id)
@@ -284,6 +329,14 @@ class ClientManager:
self.last_active_time = time.time()
if self.redis_client:
+ # Get client IP before removing the data
+ client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
+ client_data = self.redis_client.hgetall(client_key)
+ if client_data and b'ip_address' in client_data:
+ client_ip = client_data[b'ip_address'].decode('utf-8')
+ elif client_data and 'ip_address' in client_data:
+ client_ip = client_data['ip_address']
+
# Remove from channel's client set
self.redis_client.srem(self.client_set_key, client_id)
@@ -313,6 +366,9 @@ class ClientManager:
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
+ # Trigger channel stats update via WebSocket
+ self._trigger_stats_update()
+
total_clients = self.get_total_client_count()
logger.info(f"Client disconnected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py
index ff815bdc..582bcc49 100644
--- a/apps/proxy/ts_proxy/views.py
+++ b/apps/proxy/ts_proxy/views.py
@@ -28,6 +28,7 @@ from apps.accounts.permissions import (
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField
from .config_helper import ConfigHelper
from .services.channel_service import ChannelService
+from core.utils import send_websocket_update
from .url_utils import (
generate_stream_url,
transform_url,
@@ -633,6 +634,18 @@ def channel_status(request, channel_id=None):
if cursor == 0:
break
+ # Send WebSocket update with the stats
+ # Format it the same way the original Celery task did
+ send_websocket_update(
+ "updates",
+ "update",
+ {
+ "success": True,
+ "type": "channel_stats",
+ "stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})
+ }
+ )
+
return JsonResponse({"channels": all_channels, "count": len(all_channels)})
except Exception as e:
diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index 040e9156..9fdc83cb 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -198,10 +198,14 @@ CELERY_TASK_SERIALIZER = "json"
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
CELERY_BEAT_SCHEDULE = {
+ # Explicitly disable the old fetch-channel-statuses task
+ # This ensures it gets disabled when DatabaseScheduler syncs
"fetch-channel-statuses": {
- "task": "apps.proxy.tasks.fetch_channel_stats", # Direct task call
- "schedule": 2.0, # Every 2 seconds
+ "task": "apps.proxy.tasks.fetch_channel_stats",
+ "schedule": 2.0, # Original schedule (doesn't matter since disabled)
+ "enabled": False, # Explicitly disabled
},
+ # Keep the file scanning task
"scan-files": {
"task": "core.tasks.scan_and_process_files", # Direct task call
"schedule": 20.0, # Every 20 seconds
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index f8bd7f4c..67f7edfc 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -194,7 +194,9 @@ export const WebsocketProvider = ({ children }) => {
loading: false,
autoClose: 4000,
});
- try { await useChannelsStore.getState().fetchRecordings(); } catch {}
+ try {
+ await useChannelsStore.getState().fetchRecordings();
+ } catch {}
} else if (status === 'skipped') {
notifications.update({
id,
@@ -204,7 +206,9 @@ export const WebsocketProvider = ({ children }) => {
loading: false,
autoClose: 3000,
});
- try { await useChannelsStore.getState().fetchRecordings(); } catch {}
+ try {
+ await useChannelsStore.getState().fetchRecordings();
+ } catch {}
} else if (status === 'error') {
notifications.update({
id,
@@ -214,7 +218,9 @@ export const WebsocketProvider = ({ children }) => {
loading: false,
autoClose: 6000,
});
- try { await useChannelsStore.getState().fetchRecordings(); } catch {}
+ try {
+ await useChannelsStore.getState().fetchRecordings();
+ } catch {}
}
break;
}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 71b2d692..cb834bd5 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..de3fbe7f 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,14 +825,57 @@ 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);
+ } else {
+ console.log('API response was empty or null');
+ }
+ } catch (error) {
+ console.error('Error fetching channel stats:', error);
+ console.error('Error details:', {
+ message: error.message,
+ status: error.status,
+ body: error.body,
+ });
+ }
+ }, [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(() => {
+ console.log('Processing channel stats:', channelStats);
if (
!channelStats ||
!channelStats.channels ||
@@ -834,81 +884,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
+
+ )}
+
+
+
+