diff --git a/dispatcharr/consumers.py b/dispatcharr/consumers.py
index 8d92c4fa..f7d4a47c 100644
--- a/dispatcharr/consumers.py
+++ b/dispatcharr/consumers.py
@@ -6,12 +6,35 @@ logger = logging.getLogger(__name__)
class MyWebSocketConsumer(AsyncWebsocketConsumer):
async def connect(self):
- await self.accept()
- self.room_name = "updates"
- await self.channel_layer.group_add(self.room_name, self.channel_name)
+ try:
+ await self.accept()
+ self.room_name = "updates"
+ await self.channel_layer.group_add(self.room_name, self.channel_name)
+ # Send a connection confirmation to the client with consistent format
+ await self.send(text_data=json.dumps({
+ 'type': 'connection_established',
+ 'data': {
+ 'success': True,
+ 'message': 'WebSocket connection established successfully'
+ }
+ }))
+ except Exception as e:
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.error(f"Error in WebSocket connect: {str(e)}")
+ # If an error occurs during connection, attempt to close
+ try:
+ await self.close(code=1011) # Internal server error
+ except:
+ pass
async def disconnect(self, close_code):
- await self.channel_layer.group_discard(self.room_name, self.channel_name)
+ try:
+ await self.channel_layer.group_discard(self.room_name, self.channel_name)
+ except Exception as e:
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.error(f"Error in WebSocket disconnect: {str(e)}")
async def receive(self, text_data):
data = json.loads(text_data)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e13c5af8..1c032ab3 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -65,14 +65,20 @@ const App = () => {
// Authentication check
useEffect(() => {
const checkAuth = async () => {
- const loggedIn = await initializeAuth();
- if (loggedIn) {
- await initData();
- setIsAuthenticated(true);
- } else {
+ try {
+ const loggedIn = await initializeAuth();
+ if (loggedIn) {
+ await initData();
+ setIsAuthenticated(true);
+ } else {
+ await logout();
+ }
+ } catch (error) {
+ console.error("Auth check failed:", error);
await logout();
}
};
+
checkAuth();
}, [initializeAuth, initData, setIsAuthenticated, logout]);
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 0533f13a..1e2b0278 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -5,13 +5,14 @@ import React, {
createContext,
useContext,
useMemo,
+ useCallback,
} from 'react';
import useStreamsStore from './store/streams';
import { notifications } from '@mantine/notifications';
import useChannelsStore from './store/channels';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
-import { Box, Button, Stack } from '@mantine/core';
+import { Box, Button, Stack, Alert } from '@mantine/core';
import API from './api';
export const WebsocketContext = createContext([false, () => { }, null]);
@@ -19,6 +20,299 @@ export const WebsocketContext = createContext([false, () => { }, null]);
export const WebsocketProvider = ({ children }) => {
const [isReady, setIsReady] = useState(false);
const [val, setVal] = useState(null);
+ const ws = useRef(null);
+ const reconnectTimerRef = useRef(null);
+ const [reconnectAttempts, setReconnectAttempts] = useState(0);
+ const [connectionError, setConnectionError] = useState(null);
+ const maxReconnectAttempts = 5;
+ const initialBackoffDelay = 1000; // 1 second initial delay
+
+ // Calculate reconnection delay with exponential backoff
+ const getReconnectDelay = useCallback(() => {
+ return Math.min(initialBackoffDelay * Math.pow(1.5, reconnectAttempts), 30000); // max 30 seconds
+ }, [reconnectAttempts]);
+
+ // Clear any existing reconnect timers
+ const clearReconnectTimer = useCallback(() => {
+ if (reconnectTimerRef.current) {
+ clearTimeout(reconnectTimerRef.current);
+ reconnectTimerRef.current = null;
+ }
+ }, []);
+
+ // Function to get WebSocket URL that works with both HTTP and HTTPS
+ const getWebSocketUrl = useCallback(() => {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const host = window.location.hostname;
+
+ // WebSockets always run on port 8001
+ return `${protocol}//${host}:8001/ws/`;
+ }, []);
+
+ // Function to handle websocket connection
+ const connectWebSocket = useCallback(() => {
+ // Clear any existing timers to avoid multiple reconnection attempts
+ clearReconnectTimer();
+
+ // Clear old websocket if exists
+ if (ws.current) {
+ // Remove event handlers to prevent duplicate events
+ ws.current.onclose = null;
+ ws.current.onerror = null;
+ ws.current.onopen = null;
+ ws.current.onmessage = null;
+
+ try {
+ ws.current.close();
+ } catch (e) {
+ console.warn("Error closing existing WebSocket:", e);
+ }
+ }
+
+ try {
+ console.log(`Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`);
+
+ // Use the function to get the correct WebSocket URL
+ const wsUrl = getWebSocketUrl();
+ console.log(`Connecting to WebSocket at: ${wsUrl}`);
+
+ // Create new WebSocket connection
+ const socket = new WebSocket(wsUrl);
+
+ socket.onopen = () => {
+ console.log("WebSocket connected successfully");
+ setIsReady(true);
+ setConnectionError(null);
+ setReconnectAttempts(0);
+ };
+
+ socket.onerror = (error) => {
+ console.error("WebSocket connection error:", error);
+
+ // Don't show error notification on initial page load,
+ // only show it after a connection was established then lost
+ if (reconnectAttempts > 0 || isReady) {
+ setConnectionError("Failed to connect to WebSocket server.");
+ } else {
+ console.log("Initial connection attempt failed, will retry...");
+ }
+ };
+
+ socket.onclose = (event) => {
+ console.warn("WebSocket connection closed", event);
+ setIsReady(false);
+
+ // Only attempt reconnect if we haven't reached max attempts
+ if (reconnectAttempts < maxReconnectAttempts) {
+ const delay = getReconnectDelay();
+ setConnectionError(`Connection lost. Reconnecting in ${Math.ceil(delay / 1000)} seconds...`);
+ console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`);
+
+ // Store timer reference so we can cancel it if needed
+ reconnectTimerRef.current = setTimeout(() => {
+ setReconnectAttempts(prev => prev + 1);
+ connectWebSocket();
+ }, delay);
+ } else {
+ setConnectionError("Maximum reconnection attempts reached. Please reload the page.");
+ console.error("Maximum reconnection attempts reached. WebSocket connection failed.");
+ }
+ };
+
+ // Message handler
+ socket.onmessage = async (event) => {
+ try {
+ const parsedEvent = JSON.parse(event.data);
+
+ // Handle connection_established event
+ if (parsedEvent.type === 'connection_established') {
+ console.log('WebSocket connection established:', parsedEvent.data?.message);
+ // Don't need to do anything else for this event type
+ return;
+ }
+
+ // Handle standard message format for other event types
+ switch (parsedEvent.data?.type) {
+ case 'epg_file':
+ fetchEPGs();
+ notifications.show({
+ title: 'EPG File Detected',
+ message: `Processing ${parsedEvent.data.filename}`,
+ });
+ break;
+
+ case 'm3u_file':
+ fetchPlaylists();
+ notifications.show({
+ title: 'M3U File Detected',
+ message: `Processing ${parsedEvent.data.filename}`,
+ });
+ break;
+
+ case 'm3u_group_refresh':
+ fetchChannelGroups();
+ fetchPlaylists();
+
+ notifications.show({
+ title: 'Group processing finished!',
+ autoClose: 5000,
+ message: (
+
+ Refresh M3U or filter out groups to pull in streams.
+
+
+ ),
+ color: 'green.5',
+ });
+ break;
+
+ case 'm3u_refresh':
+ setRefreshProgress(parsedEvent.data);
+ break;
+
+ case 'channel_stats':
+ setChannelStats(JSON.parse(parsedEvent.data.stats));
+ break;
+
+ case 'epg_channels':
+ notifications.show({
+ message: 'EPG channels updated!',
+ color: 'green.5',
+ });
+
+ // If source_id is provided, update that specific EPG's status
+ if (parsedEvent.data.source_id) {
+ const epgsState = useEPGsStore.getState();
+ const epg = epgsState.epgs[parsedEvent.data.source_id];
+ if (epg) {
+ epgsState.updateEPG({
+ ...epg,
+ status: 'success'
+ });
+ }
+ }
+
+ fetchEPGData();
+ break;
+
+ case 'epg_match':
+ notifications.show({
+ message: parsedEvent.data.message || 'EPG match is complete!',
+ color: 'green.5',
+ });
+
+ // Check if we have associations data and use the more efficient batch API
+ if (parsedEvent.data.associations && parsedEvent.data.associations.length > 0) {
+ API.batchSetEPG(parsedEvent.data.associations);
+ }
+ break;
+
+ case 'm3u_profile_test':
+ setProfilePreview(parsedEvent.data.search_preview, parsedEvent.data.result);
+ break;
+
+ case 'recording_started':
+ notifications.show({
+ title: 'Recording started!',
+ message: `Started recording channel ${parsedEvent.data.channel}`,
+ });
+ break;
+
+ case 'recording_ended':
+ notifications.show({
+ title: 'Recording finished!',
+ message: `Stopped recording channel ${parsedEvent.data.channel}`,
+ });
+ break;
+
+ case 'epg_fetch_error':
+ notifications.show({
+ title: 'EPG Source Error',
+ message: parsedEvent.data.message,
+ color: 'orange.5',
+ autoClose: 8000,
+ });
+
+ // Update EPG status in store
+ if (parsedEvent.data.source_id) {
+ const epgsState = useEPGsStore.getState();
+ const epg = epgsState.epgs[parsedEvent.data.source_id];
+ if (epg) {
+ epgsState.updateEPG({
+ ...epg,
+ status: 'error',
+ last_error: parsedEvent.data.message
+ });
+ }
+ }
+ break;
+
+ case 'epg_refresh':
+ // Update the store with progress information
+ const epgsState = useEPGsStore.getState();
+ epgsState.updateEPGProgress(parsedEvent.data);
+
+ // If progress is complete (100%), show a notification and refresh EPG data
+ if (parsedEvent.data.progress === 100 && parsedEvent.data.action === "parsing_programs") {
+ notifications.show({
+ title: 'EPG Processing Complete',
+ message: 'EPG data has been updated successfully',
+ color: 'green.5',
+ });
+
+ fetchEPGData();
+ }
+ break;
+
+ default:
+ console.error(`Unknown websocket event type: ${parsedEvent.data?.type}`);
+ break;
+ }
+ } catch (error) {
+ console.error('Error processing WebSocket message:', error, event.data);
+ }
+ };
+
+ ws.current = socket;
+ } catch (error) {
+ console.error("Error creating WebSocket connection:", error);
+ setConnectionError(`WebSocket error: ${error.message}`);
+
+ // Schedule a reconnect if we haven't reached max attempts
+ if (reconnectAttempts < maxReconnectAttempts) {
+ const delay = getReconnectDelay();
+ reconnectTimerRef.current = setTimeout(() => {
+ setReconnectAttempts(prev => prev + 1);
+ connectWebSocket();
+ }, delay);
+ }
+ }
+ }, [reconnectAttempts, clearReconnectTimer, getReconnectDelay, getWebSocketUrl, isReady]);
+
+ // Initial connection and cleanup
+ useEffect(() => {
+ connectWebSocket();
+
+ return () => {
+ clearReconnectTimer(); // Clear any pending reconnect timers
+
+ if (ws.current) {
+ console.log("Closing WebSocket connection due to component unmount");
+ ws.current.onclose = null; // Remove handlers to avoid reconnection
+ ws.current.close();
+ ws.current = null;
+ }
+ };
+ }, [connectWebSocket, clearReconnectTimer]);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
@@ -28,198 +322,28 @@ export const WebsocketProvider = ({ children }) => {
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
- const ws = useRef(null);
-
- useEffect(() => {
- let wsUrl = `${window.location.host}/ws/`;
- if (import.meta.env.DEV) {
- wsUrl = `${window.location.hostname}:8001/ws/`;
- }
-
- if (window.location.protocol.match(/https/)) {
- wsUrl = `wss://${wsUrl}`;
- } else {
- wsUrl = `ws://${wsUrl}`;
- }
-
- const socket = new WebSocket(wsUrl);
-
- socket.onopen = () => {
- console.log('websocket connected');
- setIsReady(true);
- };
-
- // Reconnection logic
- socket.onclose = () => {
- setIsReady(false);
- setTimeout(() => {
- const reconnectWs = new WebSocket(wsUrl);
- reconnectWs.onopen = () => setIsReady(true);
- }, 3000); // Attempt to reconnect every 3 seconds
- };
-
- socket.onmessage = async (event) => {
- event = JSON.parse(event.data);
- switch (event.data.type) {
- case 'epg_file':
- fetchEPGs();
- notifications.show({
- title: 'EPG File Detected',
- message: `Processing ${event.data.filename}`,
- });
- break;
-
- case 'm3u_file':
- fetchPlaylists();
- notifications.show({
- title: 'M3U File Detected',
- message: `Processing ${event.data.filename}`,
- });
- break;
-
- case 'm3u_group_refresh':
- fetchChannelGroups();
- fetchPlaylists();
-
- notifications.show({
- title: 'Group processing finished!',
- autoClose: 5000,
- message: (
-
- Refresh M3U or filter out groups to pull in streams.
-
-
- ),
- color: 'green.5',
- });
- break;
-
- case 'm3u_refresh':
- setRefreshProgress(event.data);
- break;
-
- case 'channel_stats':
- setChannelStats(JSON.parse(event.data.stats));
- break;
-
- case 'epg_channels':
- notifications.show({
- message: 'EPG channels updated!',
- color: 'green.5',
- });
-
- // If source_id is provided, update that specific EPG's status
- if (event.data.source_id) {
- const epgsState = useEPGsStore.getState();
- const epg = epgsState.epgs[event.data.source_id];
- if (epg) {
- epgsState.updateEPG({
- ...epg,
- status: 'success'
- });
- }
- }
-
- fetchEPGData();
- break;
-
- case 'epg_match':
- notifications.show({
- message: event.data.message || 'EPG match is complete!',
- color: 'green.5',
- });
-
- // Check if we have associations data and use the more efficient batch API
- if (event.data.associations && event.data.associations.length > 0) {
- API.batchSetEPG(event.data.associations);
- }
- break;
-
- case 'm3u_profile_test':
- setProfilePreview(event.data.search_preview, event.data.result);
- break;
-
- case 'recording_started':
- notifications.show({
- title: 'Recording started!',
- message: `Started recording channel ${event.data.channel}`,
- });
- break;
-
- case 'recording_ended':
- notifications.show({
- title: 'Recording finished!',
- message: `Stopped recording channel ${event.data.channel}`,
- });
- break;
-
- case 'epg_fetch_error':
- notifications.show({
- title: 'EPG Source Error',
- message: event.data.message,
- color: 'orange.5',
- autoClose: 8000,
- });
-
- // Update EPG status in store
- if (event.data.source_id) {
- const epgsState = useEPGsStore.getState();
- const epg = epgsState.epgs[event.data.source_id];
- if (epg) {
- epgsState.updateEPG({
- ...epg,
- status: 'error',
- last_error: event.data.message
- });
- }
- }
- break;
-
- case 'epg_refresh':
- // Update the store with progress information
- const epgsState = useEPGsStore.getState();
- epgsState.updateEPGProgress(event.data);
-
- // If progress is complete (100%), show a notification and refresh EPG data
- if (event.data.progress === 100 && event.data.action === "parsing_programs") {
- notifications.show({
- title: 'EPG Processing Complete',
- message: 'EPG data has been updated successfully',
- color: 'green.5',
- });
-
- fetchEPGData();
- }
- break;
-
- default:
- console.error(`Unknown websocket event type: ${event.type}`);
- break;
- }
- };
-
- ws.current = socket;
-
- return () => {
- socket.close();
- };
- }, []);
-
const ret = useMemo(() => {
return [isReady, ws.current?.send.bind(ws.current), val];
}, [isReady, val]);
return (
+ {connectionError && !isReady && reconnectAttempts >= maxReconnectAttempts && (
+
+ {connectionError}
+
+
+ )}
+ {connectionError && !isReady && reconnectAttempts < maxReconnectAttempts && reconnectAttempts > 0 && (
+
+ {connectionError}
+
+ )}
{children}
);
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index eb5a2226..688ce3a6 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -218,6 +218,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
label="Public IP"
ref={publicIPRef}
value={environment.public_ip}
+ readOnly={true}
leftSection={
environment.country_code && (
{
+const ChannelForm = ({ channel = null, isOpen, onClose }) => {
const theme = useMantineTheme();
const listRef = useRef(null);
@@ -59,7 +59,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const [epgPopoverOpened, setEpgPopoverOpened] = useState(false);
const [logoPopoverOpened, setLogoPopoverOpened] = useState(false);
- const [selectedEPG, setSelectedEPG] = useState({});
+ const [selectedEPG, setSelectedEPG] = useState('');
const [tvgFilter, setTvgFilter] = useState('');
const [logoFilter, setLogoFilter] = useState('');
const [logoOptions, setLogoOptions] = useState([]);
@@ -94,11 +94,11 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
name: '',
- channel_number: 0,
- channel_group_id: Object.keys(channelGroups)[0],
+ channel_number: '', // Change from 0 to empty string for consistency
+ channel_group_id: Object.keys(channelGroups).length > 0 ? Object.keys(channelGroups)[0] : '',
stream_profile_id: '0',
tvg_id: '',
- tvc_guide_stationid: '',
+ tvc_guide_stationid: '',
epg_data_id: '',
logo_id: '',
},
@@ -177,26 +177,26 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
useEffect(() => {
if (channel) {
if (channel.epg_data_id) {
- const epgSource = epgs[tvgsById[channel.epg_data_id].epg_source];
- setSelectedEPG(`${epgSource.id}`);
+ const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source];
+ setSelectedEPG(epgSource ? `${epgSource.id}` : '');
}
formik.setValues({
- name: channel.name,
- channel_number: channel.channel_number,
+ name: channel.name || '',
+ channel_number: channel.channel_number !== null ? channel.channel_number : '',
channel_group_id: channel.channel_group_id
? `${channel.channel_group_id}`
: '',
stream_profile_id: channel.stream_profile_id
? `${channel.stream_profile_id}`
: '0',
- tvg_id: channel.tvg_id,
- tvc_guide_stationid: channel.tvc_guide_stationid,
+ tvg_id: channel.tvg_id || '',
+ tvc_guide_stationid: channel.tvc_guide_stationid || '',
epg_data_id: channel.epg_data_id ?? '',
- logo_id: `${channel.logo_id}`,
+ logo_id: channel.logo_id ? `${channel.logo_id}` : '',
});
- setChannelStreams(channel.streams);
+ setChannelStreams(channel.streams || []);
} else {
formik.resetForm();
setTvgFilter('');
@@ -678,7 +678,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
error={formik.errors.tvg_id ? formik.touched.tvg_id : ''}
size="xs"
/>
-
+
{
);
};
-export default Channel;
+export default ChannelForm;
diff --git a/frontend/src/components/forms/EPG.jsx b/frontend/src/components/forms/EPG.jsx
index c5d5b6e2..bc4439e3 100644
--- a/frontend/src/components/forms/EPG.jsx
+++ b/frontend/src/components/forms/EPG.jsx
@@ -134,7 +134,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {