mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Fixes a lot of "You provided a value prop to a form field without an onChange handler" errors.
Reworks websocket connection to be more robust and notify user of connection errors. Will retry if websocket connection dies.
This commit is contained in:
parent
d2c8389f74
commit
509f2be3a8
11 changed files with 433 additions and 270 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
<Stack>
|
||||
Refresh M3U or filter out groups to pull in streams.
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
API.refreshPlaylist(parsedEvent.data.account);
|
||||
setRefreshProgress(parsedEvent.data.account, 0);
|
||||
}}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</Stack>
|
||||
),
|
||||
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: (
|
||||
<Stack>
|
||||
Refresh M3U or filter out groups to pull in streams.
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
API.refreshPlaylist(event.data.account);
|
||||
setRefreshProgress(event.data.account, 0);
|
||||
}}
|
||||
>
|
||||
Refresh Now
|
||||
</Button>
|
||||
</Stack>
|
||||
),
|
||||
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 (
|
||||
<WebsocketContext.Provider value={ret}>
|
||||
{connectionError && !isReady && reconnectAttempts >= maxReconnectAttempts && (
|
||||
<Alert color="red" title="WebSocket Connection Failed" style={{ position: 'fixed', bottom: 10, right: 10, zIndex: 1000, maxWidth: 350 }}>
|
||||
{connectionError}
|
||||
<Button size="xs" mt={10} onClick={() => {
|
||||
setReconnectAttempts(0);
|
||||
connectWebSocket();
|
||||
}}>
|
||||
Try Again
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
{connectionError && !isReady && reconnectAttempts < maxReconnectAttempts && reconnectAttempts > 0 && (
|
||||
<Alert color="orange" title="WebSocket Reconnecting" style={{ position: 'fixed', bottom: 10, right: 10, zIndex: 1000, maxWidth: 350 }}>
|
||||
{connectionError}
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
</WebsocketContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import useEPGsStore from '../../store/epgs';
|
|||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
const Channel = ({ channel = null, isOpen, onClose }) => {
|
||||
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"
|
||||
/>
|
||||
|
||||
|
||||
<TextInput
|
||||
id="tvc_guide_stationid"
|
||||
name="tvc_guide_stationid"
|
||||
|
|
@ -839,4 +839,4 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Channel;
|
||||
export default ChannelForm;
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
|
|||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={form.submiting}
|
||||
disabled={form.submitting} // Fix the typo: submiting -> submitting
|
||||
size="small"
|
||||
>
|
||||
Submit
|
||||
|
|
|
|||
|
|
@ -31,16 +31,6 @@ const LoginForm = () => {
|
|||
navigate('/channels'); // Or any other route you'd like
|
||||
};
|
||||
|
||||
// // Handle form submission
|
||||
// const handleSubmit = async (e) => {
|
||||
// e.preventDefault();
|
||||
// setLoading(true);
|
||||
// setError(''); // Reset error on each new submission
|
||||
|
||||
// await login(username, password)
|
||||
// navigate('/channels'); // Or any other route you'd like
|
||||
// };
|
||||
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
|
|
@ -73,8 +63,8 @@ const LoginForm = () => {
|
|||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" size="sm" sx={{ pt: 1 }}>
|
||||
Submit
|
||||
<Button type="submit" mt="sm">
|
||||
Login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { TextInput, Center, Button, Paper, Title, Stack } from '@mantine/core';
|
|||
import API from '../../api';
|
||||
import useAuthStore from '../../store/auth';
|
||||
|
||||
function SuperuserForm({}) {
|
||||
function SuperuserForm() {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
|
|
@ -34,11 +34,7 @@ function SuperuserForm({}) {
|
|||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
// let msg = 'Failed to create superuser.';
|
||||
// if (err.response && err.response.data && err.response.data.error) {
|
||||
// msg += ` ${err.response.data.error}`;
|
||||
// }
|
||||
// setError(msg);
|
||||
setError('Failed to create superuser.');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -290,9 +290,9 @@ const SettingsPage = () => {
|
|||
},
|
||||
|
||||
validate: {
|
||||
'default-user-agent': isNotEmpty('Select a channel'),
|
||||
'default-stream-profile': isNotEmpty('Select a start time'),
|
||||
'preferred-region': isNotEmpty('Select an end time'),
|
||||
'default-user-agent': isNotEmpty('Select a user agent'),
|
||||
'default-stream-profile': isNotEmpty('Select a stream profile'),
|
||||
'preferred-region': isNotEmpty('Select a region'),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -399,9 +399,9 @@ const SettingsPage = () => {
|
|||
searchable
|
||||
{...form.getInputProps('default-user-agent')}
|
||||
key={form.key('default-user-agent')}
|
||||
id={settings['default-user-agent']?.id}
|
||||
name={settings['default-user-agent']?.key}
|
||||
label={settings['default-user-agent']?.name}
|
||||
id={settings['default-user-agent']?.id || 'default-user-agent'}
|
||||
name={settings['default-user-agent']?.key || 'default-user-agent'}
|
||||
label={settings['default-user-agent']?.name || 'Default User Agent'}
|
||||
data={userAgents.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
|
|
@ -412,9 +412,9 @@ const SettingsPage = () => {
|
|||
searchable
|
||||
{...form.getInputProps('default-stream-profile')}
|
||||
key={form.key('default-stream-profile')}
|
||||
id={settings['default-stream-profile']?.id}
|
||||
name={settings['default-stream-profile']?.key}
|
||||
label={settings['default-stream-profile']?.name}
|
||||
id={settings['default-stream-profile']?.id || 'default-stream-profile'}
|
||||
name={settings['default-stream-profile']?.key || 'default-stream-profile'}
|
||||
label={settings['default-stream-profile']?.name || 'Default Stream Profile'}
|
||||
data={streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
|
|
@ -426,9 +426,7 @@ const SettingsPage = () => {
|
|||
key={form.key('preferred-region')}
|
||||
id={settings['preferred-region']?.id || 'preferred-region'}
|
||||
name={settings['preferred-region']?.key || 'preferred-region'}
|
||||
label={
|
||||
settings['preferred-region']?.name || 'Preferred Region'
|
||||
}
|
||||
label={settings['preferred-region']?.name || 'Preferred Region'}
|
||||
data={regionChoices.map((r) => ({
|
||||
label: r.label,
|
||||
value: `${r.value}`,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
import api from '../api';
|
||||
import useSettingsStore from './settings';
|
||||
import useChannelsStore from './channels';
|
||||
import useUserAgentsStore from './userAgents';
|
||||
import usePlaylistsStore from './playlists';
|
||||
import useEPGsStore from './epgs';
|
||||
import useStreamProfilesStore from './streamProfiles';
|
||||
import useSettingsStore from './settings';
|
||||
import useUserAgentsStore from './userAgents';
|
||||
|
||||
const decodeToken = (token) => {
|
||||
if (!token) return null;
|
||||
|
|
@ -20,32 +20,46 @@ const isTokenExpired = (expirationTime) => {
|
|||
};
|
||||
|
||||
const useAuthStore = create((set, get) => ({
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
needsSuperuser: false,
|
||||
user: {
|
||||
username: '',
|
||||
email: '',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
initData: async () => {
|
||||
// Ensure settings are loaded first
|
||||
await useSettingsStore.getState().fetchSettings();
|
||||
|
||||
try {
|
||||
// Only after settings are loaded, fetch the dependent data
|
||||
await Promise.all([
|
||||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
useEPGsStore.getState().fetchEPGs(),
|
||||
useEPGsStore.getState().fetchEPGData(),
|
||||
useChannelsStore.getState().fetchLogos(),
|
||||
useStreamProfilesStore.getState().fetchProfiles(),
|
||||
useUserAgentsStore.getState().fetchUserAgents(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error initializing data:", error);
|
||||
}
|
||||
},
|
||||
|
||||
accessToken: localStorage.getItem('accessToken') || null,
|
||||
refreshToken: localStorage.getItem('refreshToken') || null,
|
||||
tokenExpiration: localStorage.getItem('tokenExpiration') || null,
|
||||
superuserExists: true,
|
||||
isAuthenticated: false,
|
||||
|
||||
setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
|
||||
|
||||
setSuperuserExists: (superuserExists) => set({ superuserExists }),
|
||||
|
||||
initData: async () => {
|
||||
await Promise.all([
|
||||
useChannelsStore.getState().fetchChannels(),
|
||||
useChannelsStore.getState().fetchChannelGroups(),
|
||||
useChannelsStore.getState().fetchLogos(),
|
||||
useChannelsStore.getState().fetchChannelProfiles(),
|
||||
useChannelsStore.getState().fetchRecordings(),
|
||||
useUserAgentsStore.getState().fetchUserAgents(),
|
||||
usePlaylistsStore.getState().fetchPlaylists(),
|
||||
useEPGsStore.getState().fetchEPGs(),
|
||||
useEPGsStore.getState().fetchEPGData(),
|
||||
useStreamProfilesStore.getState().fetchProfiles(),
|
||||
useSettingsStore.getState().fetchSettings(),
|
||||
]);
|
||||
},
|
||||
|
||||
getToken: async () => {
|
||||
const tokenExpiration = localStorage.getItem('tokenExpiration');
|
||||
let accessToken = null;
|
||||
|
|
@ -61,7 +75,7 @@ const useAuthStore = create((set, get) => ({
|
|||
// Action to login
|
||||
login: async ({ username, password }) => {
|
||||
try {
|
||||
const response = await API.login(username, password);
|
||||
const response = await api.login(username, password);
|
||||
if (response.access) {
|
||||
const expiration = decodeToken(response.access);
|
||||
set({
|
||||
|
|
@ -83,11 +97,11 @@ const useAuthStore = create((set, get) => ({
|
|||
// Action to refresh the token
|
||||
getRefreshToken: async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (!refreshToken) return;
|
||||
if (!refreshToken) return false; // Add explicit return here
|
||||
|
||||
try {
|
||||
const data = await API.refreshToken(refreshToken);
|
||||
if (data.access) {
|
||||
const data = await api.refreshToken(refreshToken);
|
||||
if (data && data.access) {
|
||||
set({
|
||||
accessToken: data.access,
|
||||
tokenExpiration: decodeToken(data.access),
|
||||
|
|
@ -98,12 +112,12 @@ const useAuthStore = create((set, get) => ({
|
|||
|
||||
return data.access;
|
||||
}
|
||||
return false; // Add explicit return for when data.access is not available
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
get().logout();
|
||||
return false; // Add explicit return after error
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
// Action to logout
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import api from '../api';
|
|||
|
||||
const useSettingsStore = create((set) => ({
|
||||
settings: {},
|
||||
environment: {},
|
||||
environment: {
|
||||
// Add default values for environment settings
|
||||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
|
|
@ -18,7 +24,12 @@ const useSettingsStore = create((set) => ({
|
|||
return acc;
|
||||
}, {}),
|
||||
isLoading: false,
|
||||
environment: env,
|
||||
environment: env || {
|
||||
public_ip: '',
|
||||
country_code: '',
|
||||
country_name: '',
|
||||
env_mode: 'prod',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: 'Failed to load settings.', isLoading: false });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue