Dispatcharr/frontend/src/WebSocket.jsx
SergeantPanda 7086f41d64 feat(epg): overhaul EPG auto-matching logic and improve performance
- Moved matching logic to a dedicated module for better organization and testability.
- Made single-channel auto-matching asynchronous, allowing for larger EPG libraries without hitting HTTP timeouts.
- Enhanced memory management and throughput during EPG matching, including optimizations for fuzzy matching and bulk processing.
- Fixed various reliability issues in the auto-matching process, ensuring accurate channel assignments and improved UI feedback.
- Updated API views and frontend components to reflect changes in the matching process and provide real-time notifications.
- Added tests for EPG matching functionality and name normalization.
- Single-channel and selected-channel auto-match always run, even when the channel already has EPG assigned; match-all (no channel IDs) still only processes channels without EPG.
2026-06-08 18:12:29 -05:00

1125 lines
41 KiB
JavaScript

import React, {
useState,
useEffect,
useRef,
createContext,
useContext,
useMemo,
useCallback,
} from 'react';
import { notifications } from '@mantine/notifications';
import useChannelsStore from './store/channels';
import useLogosStore from './store/logos';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import { Box, Button, Stack, Alert, Group } from '@mantine/core';
import API from './api';
import useSettingsStore from './store/settings';
import useAuthStore from './store/auth';
import useUsersStore from './store/users';
export const WebsocketContext = createContext([false, () => {}, null]);
// Debounce: coalesces rapid recording WS events into a single fetchRecordings()
// call (400 ms window) to prevent redundant re-renders in the TV Guide.
let _recordingFetchTimer = null;
function scheduleRecordingFetch() {
if (_recordingFetchTimer) clearTimeout(_recordingFetchTimer);
_recordingFetchTimer = setTimeout(async () => {
_recordingFetchTimer = null;
try {
await useChannelsStore.getState().fetchRecordings();
} catch (e) {
console.warn('Failed to refresh recordings:', e);
}
}, 400);
}
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
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const epgs = useEPGsStore((s) => s.epgs);
const updateEPG = useEPGsStore((s) => s.updateEPG);
const updatePlaylist = usePlaylistsStore((s) => s.updatePlaylist);
// 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;
const appPort = window.location.port;
// In development mode, connect directly to the WebSocket server on port 8001
if (env_mode === 'dev') {
return `${protocol}//${host}:8001/ws/?token=${accessToken}`;
} else {
// In production mode, use the same port as the main application
// This allows nginx to handle the WebSocket forwarding
return appPort
? `${protocol}//${host}:${appPort}/ws/?token=${accessToken}`
: `${protocol}//${host}/ws/?token=${accessToken}`;
}
}, [env_mode, accessToken]);
// 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 'comskip_status': {
const rid = parsedEvent.data.recording_id;
const id = `comskip-${rid}`;
const status = parsedEvent.data.status;
const title = parsedEvent.data.title || 'Recording';
if (status === 'started') {
notifications.show({
id,
title: 'Removing commercials',
message: `Processing ${title}...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (status === 'completed') {
notifications.update({
id,
title: 'Commercials removed',
message: `${title} — kept ${parsedEvent.data.segments_kept} segments`,
color: 'green.5',
loading: false,
autoClose: 4000,
});
scheduleRecordingFetch();
} else if (status === 'skipped') {
const reasonMap = {
no_commercials_detected:
'No commercials were detected in this recording',
no_commercials:
'No commercials were detected in this recording',
};
notifications.update({
id,
title: 'No commercials to remove',
message:
reasonMap[parsedEvent.data.reason] ||
parsedEvent.data.reason ||
'',
color: 'teal',
loading: false,
autoClose: 3000,
});
scheduleRecordingFetch();
} else if (status === 'error') {
notifications.update({
id,
title: 'Comskip failed',
message: parsedEvent.data.reason || 'Unknown error',
color: 'red',
loading: false,
autoClose: 6000,
});
scheduleRecordingFetch();
}
break;
}
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_refresh':
// Update the store with progress information
setRefreshProgress(parsedEvent.data);
// Update the playlist status whenever we receive a status update
// Not just when progress is 100% or status is pending_setup
if (parsedEvent.data.status && parsedEvent.data.account) {
// Get fresh playlists from store to avoid stale state from React render cycle
const currentPlaylists = usePlaylistsStore.getState().playlists;
const isArray = Array.isArray(currentPlaylists);
const playlist = isArray
? currentPlaylists.find(
(p) => p.id === parsedEvent.data.account
)
: currentPlaylists[parsedEvent.data.account];
if (playlist) {
// When we receive a "success" status with 100% progress, this is a completed refresh
// So we should also update the updated_at timestamp
const updateData = {
...playlist,
status: parsedEvent.data.status,
last_message:
parsedEvent.data.message || playlist.last_message,
};
// Update the timestamp when we complete a successful refresh
if (
parsedEvent.data.status === 'success' &&
parsedEvent.data.progress === 100
) {
updateData.updated_at = new Date().toISOString();
// Log successful completion for debugging
console.log(
'M3U refresh completed successfully:',
updateData
);
fetchPlaylists(); // Refresh playlists to ensure UI is up-to-date
fetchChannelProfiles(); // Ensure channel profiles are updated
}
updatePlaylist(updateData);
} else {
// Playlist not in store yet - this happens when backend sends websocket
// updates immediately after creating the playlist, before the API response
// returns. The frontend will receive a 'playlist_created' event shortly
// which will trigger a fetchPlaylists() to sync the store.
console.log(
`Received update for playlist ID ${parsedEvent.data.account} not yet in store. ` +
`Waiting for playlist_created event to sync...`
);
}
}
break;
case 'channel_stats':
setChannelStats(JSON.parse(parsedEvent.data.stats));
break;
case 'vod_stats':
setVodStats(JSON.parse(parsedEvent.data.stats));
break;
case 'vod_started':
case 'vod_stopped': {
const { content_name, client_ip, user_id } = parsedEvent.data;
const isStart = parsedEvent.data.type === 'vod_started';
let identity = client_ip || 'unknown';
if (user_id && user_id !== '0') {
const allUsers = useUsersStore.getState().users;
const matched = allUsers.find(
(u) => String(u.id) === String(user_id)
);
if (matched?.username)
identity = `${matched.username} (${client_ip})`;
}
notifications.show({
title: isStart ? 'VOD started' : 'VOD ended',
message: (
<>
<div>{content_name}</div>
<div style={{ marginTop: 2 }}>{identity}</div>
</>
),
color: 'blue.5',
});
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 epg = epgs[parsedEvent.data.source_id];
if (epg) {
updateEPG({
...epg,
status: 'success',
});
}
}
fetchEPGData();
break;
case 'single_channel_epg_match': {
const matchResult = parsedEvent.data;
if (matchResult.channel) {
useChannelsStore.getState().updateChannel(matchResult.channel);
}
window.dispatchEvent(
new CustomEvent('single-channel-epg-match', {
detail: matchResult,
})
);
break;
}
case 'epg_match':
notifications.show({
message: parsedEvent.data.message || 'EPG match is complete!',
color: 'green.5',
});
// Celery already applied assignments server-side; refresh local state.
fetchEPGData();
API.requeryChannels();
break;
case 'epg_matching_progress': {
const progress = parsedEvent.data;
const id = 'epg-matching-progress';
if (progress.stage === 'starting') {
notifications.show({
id,
title: 'EPG Matching in Progress',
message: `Starting to match ${progress.total} channels...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.stage === 'matching') {
let message = `Matched ${progress.matched} of ${progress.total} channels`;
if (progress.remaining > 0) {
message += ` (${progress.remaining} remaining)`;
}
if (progress.current_channel) {
message += `\nCurrently processing: ${progress.current_channel}`;
}
notifications.update({
id,
title: 'EPG Matching in Progress',
message,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.stage === 'completed') {
notifications.update({
id,
title: 'EPG Matching Complete',
message: `Successfully matched ${progress.matched} of ${progress.total} channels (${progress.progress_percent}%)`,
color: progress.matched > 0 ? 'green.5' : 'orange',
loading: false,
autoClose: 6000,
});
}
break;
}
case 'epg_logo_setting_progress': {
const progress = parsedEvent.data;
const id = 'epg-logo-setting-progress';
if (progress.status === 'running' && progress.progress === 0) {
// Initial message
notifications.show({
id,
title: 'Setting Logos from EPG',
message: `Processing ${progress.total} channels...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'running') {
// Progress update
let message = `Processed ${progress.progress} of ${progress.total} channels`;
if (progress.updated_count !== undefined) {
message += ` (${progress.updated_count} updated)`;
}
if (progress.created_logos_count !== undefined) {
message += `, created ${progress.created_logos_count} logos`;
}
notifications.update({
id,
title: 'Setting Logos from EPG',
message,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'completed') {
notifications.update({
id,
title: 'Logo Setting Complete',
message: `Successfully updated ${progress.updated_count || 0} channel logos${progress.created_logos_count ? `, created ${progress.created_logos_count} new logos` : ''}`,
color: progress.updated_count > 0 ? 'green.5' : 'orange',
loading: false,
autoClose: 6000,
});
// Refresh channels data and logos
try {
await API.requeryChannels();
await useChannelsStore.getState().fetchChannelIds();
// Get updated channel data and extract logo IDs to load
const channels = useChannelsStore.getState().channels;
const logoIds = Object.values(channels)
.filter((channel) => channel.logo_id)
.map((channel) => channel.logo_id);
// Fetch the specific logos that were just assigned
if (logoIds.length > 0) {
await useLogosStore.getState().fetchLogosByIds(logoIds);
}
} catch (e) {
console.warn(
'Failed to refresh channels after logo setting:',
e
);
}
}
break;
}
case 'epg_name_setting_progress': {
const progress = parsedEvent.data;
const id = 'epg-name-setting-progress';
if (progress.status === 'running' && progress.progress === 0) {
// Initial message
notifications.show({
id,
title: 'Setting Names from EPG',
message: `Processing ${progress.total} channels...`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'running') {
// Progress update
let message = `Processed ${progress.progress} of ${progress.total} channels`;
if (progress.updated_count !== undefined) {
message += ` (${progress.updated_count} updated)`;
}
notifications.update({
id,
title: 'Setting Names from EPG',
message,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (progress.status === 'completed') {
notifications.update({
id,
title: 'Name Setting Complete',
message: `Successfully updated ${progress.updated_count || 0} channel names from EPG data`,
color: progress.updated_count > 0 ? 'green.5' : 'orange',
loading: false,
autoClose: 6000,
});
// Refresh channels data
try {
await API.requeryChannels();
await useChannelsStore.getState().fetchChannelIds();
} catch (e) {
console.warn(
'Failed to refresh channels after name setting:',
e
);
}
}
break;
}
case 'm3u_profile_test':
setProfilePreview(
parsedEvent.data.search_preview,
parsedEvent.data.result
);
break;
case 'recording_updated':
scheduleRecordingFetch();
break;
case 'recordings_refreshed':
scheduleRecordingFetch();
break;
case 'recording_started':
notifications.show({
title: 'Recording started!',
message: `Started recording channel ${parsedEvent.data.channel}`,
});
scheduleRecordingFetch();
break;
case 'recording_ended':
notifications.show({
title: 'Recording finished!',
message: `Stopped recording channel ${parsedEvent.data.channel}`,
});
scheduleRecordingFetch();
break;
case 'recording_stopped':
notifications.show({
title: 'Recording stopped',
message: `Recording stopped early for ${parsedEvent.data.channel || 'channel'}. Partial content has been saved.`,
color: 'yellow',
});
scheduleRecordingFetch();
break;
case 'recording_extended':
scheduleRecordingFetch();
break;
case 'recording_cancelled':
notifications.show({
title: parsedEvent.data.was_in_progress
? 'Recording cancelled'
: 'Recording deleted',
message: parsedEvent.data.was_in_progress
? 'Recording cancelled and content removed.'
: 'Recording deleted.',
color: 'red',
});
// Surgical removal by ID avoids a full fetchRecordings() re-render.
// Fall back to a full refresh if the ID is missing (e.g. older server).
if (parsedEvent.data.recording_id != null) {
useChannelsStore
.getState()
.removeRecording(parsedEvent.data.recording_id);
} else {
scheduleRecordingFetch();
}
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 epg = epgs[parsedEvent.data.source_id];
if (epg) {
updateEPG({
...epg,
status: 'error',
last_message: parsedEvent.data.message,
});
}
}
break;
case 'epg_refresh': {
const sourceId =
parsedEvent.data.source || parsedEvent.data.account;
if (!sourceId) break;
// Read from the store directly. connectWebSocket closes over a stale
// epgs snapshot, so a newly created source is missed and the old early-
// return path never reached fetchEPGData on parsing_channels completion.
let {
epgs: epgsState,
updateEPG,
updateEPGProgress,
fetchEPGs,
fetchEPGData,
} = useEPGsStore.getState();
if (!epgsState[sourceId]) {
try {
await fetchEPGs();
} catch (e) {
console.warn(
'Failed to refresh EPG sources for progress update:',
e
);
}
epgsState = useEPGsStore.getState().epgs;
}
updateEPGProgress(parsedEvent.data);
const epg = epgsState[sourceId];
if (!epg) break;
const hasError =
parsedEvent.data.status === 'error' ||
!!parsedEvent.data.error ||
(parsedEvent.data.message &&
parsedEvent.data.message.toLowerCase().includes('error'));
if (hasError) {
const errorMessage =
parsedEvent.data.error ||
parsedEvent.data.message ||
'Unknown error occurred';
updateEPG({
...epg,
status: 'error',
last_message: errorMessage,
});
notifications.show({
title: 'EPG Refresh Error',
message: errorMessage,
color: 'red.5',
});
} else if (parsedEvent.data.progress === 100) {
updateEPG({
...epg,
status: parsedEvent.data.status || 'success',
last_message: parsedEvent.data.message || epg.last_message,
...(parsedEvent.data.updated_at && {
updated_at: parsedEvent.data.updated_at,
}),
});
if (parsedEvent.data.action === 'parsing_channels') {
notifications.show({
message: 'EPG channels updated!',
color: 'green.5',
});
await fetchEPGData();
} else if (parsedEvent.data.action === 'parsing_programs') {
notifications.show({
title: 'EPG Processing Complete',
message: 'EPG data has been updated successfully',
color: 'green.5',
});
await fetchEPGData();
}
}
break;
}
case 'epg_sources_changed':
// A plugin or backend process signaled that the EPG sources changed
try {
await fetchEPGs();
} catch (e) {
console.warn(
'Failed to refresh EPG sources after change notification:',
e
);
}
break;
case 'epg_data_created':
// A new EPG data entry was created (e.g., for a dummy EPG)
// Fetch EPG data so the channel form can immediately assign it
try {
await fetchEPGData();
} catch (e) {
console.warn('Failed to refresh EPG data after creation:', e);
}
break;
case 'stream_rehash':
// Handle stream rehash progress updates
if (parsedEvent.data.action === 'starting') {
notifications.show({
id: 'stream-rehash-progress', // Persistent ID
title: 'Stream Rehash Started',
message: parsedEvent.data.message,
color: 'blue.5',
autoClose: false, // Don't auto-close
withCloseButton: false, // No close button during processing
loading: true, // Show loading indicator
});
} else if (parsedEvent.data.action === 'processing') {
// Update the existing notification
notifications.update({
id: 'stream-rehash-progress',
title: 'Stream Rehash in Progress',
message: `${parsedEvent.data.progress}% complete - ${parsedEvent.data.processed} streams processed, ${parsedEvent.data.duplicates_merged} duplicates merged`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (parsedEvent.data.action === 'completed') {
// Update to completion state
notifications.update({
id: 'stream-rehash-progress',
title: 'Stream Rehash Complete',
message: `Processed ${parsedEvent.data.total_processed} streams, merged ${parsedEvent.data.duplicates_merged} duplicates. Final count: ${parsedEvent.data.final_count}`,
color: 'green.5',
autoClose: 8000, // Auto-close after completion
withCloseButton: true, // Allow manual close
loading: false, // Remove loading indicator
});
// Requery streams and channels after rehash completes
try {
await API.requeryChannels();
await API.requeryStreams();
await useChannelsStore.getState().fetchChannelIds();
} catch (error) {
console.error(
'Error refreshing channels/streams after rehash:',
error
);
}
} else if (parsedEvent.data.action === 'blocked') {
// Handle blocked rehash attempt
notifications.show({
title: 'Stream Rehash Blocked',
message: parsedEvent.data.message,
color: 'orange.5',
autoClose: 8000,
});
}
break;
case 'logo_processing_summary':
notifications.show({
title: 'Logo Processing Summary',
message: `${parsedEvent.data.message}`,
color: 'blue',
autoClose: 5000,
});
fetchLogos();
break;
case 'account_info_refresh_success':
notifications.show({
title: 'Account Info Refreshed',
message: `Successfully updated account information for ${parsedEvent.data.profile_name}`,
color: 'green',
autoClose: 4000,
});
// Trigger refresh of playlists to update the UI
fetchPlaylists();
break;
case 'account_info_refresh_error':
notifications.show({
title: 'Account Info Refresh Failed',
message:
parsedEvent.data.error ||
'Failed to refresh account information',
color: 'red',
autoClose: 8000,
});
break;
case 'channels_created':
// General notification for channel creation
notifications.show({
title: 'Channels Created',
message: `Successfully created ${parsedEvent.data.count || 'multiple'} channel(s)`,
color: 'green',
autoClose: 4000,
});
// Refresh the channels table to show new channels
try {
await API.requeryChannels();
await API.requeryStreams();
useChannelsStore.getState().fetchChannelIds();
await fetchChannelProfiles();
console.log('Channels refreshed after bulk creation');
} catch (error) {
console.error(
'Error refreshing channels after creation:',
error
);
}
break;
case 'playlist_created':
// Backend signals that a new playlist has been created and we should refresh
console.log(
'Playlist created event received, refreshing playlists...'
);
fetchPlaylists();
break;
case 'bulk_channel_creation_progress': {
// Handle progress updates with persistent notifications like stream rehash
const data = parsedEvent.data;
if (data.status === 'starting') {
notifications.show({
id: 'bulk-channel-creation-progress', // Persistent ID
title: 'Bulk Channel Creation Started',
message: data.message || 'Starting bulk channel creation...',
color: 'blue.5',
autoClose: false, // Don't auto-close
withCloseButton: false, // No close button during processing
loading: true, // Show loading indicator
});
} else if (
data.status === 'processing' ||
data.status === 'creating_logos' ||
data.status === 'creating_channels'
) {
// Calculate progress percentage
const progressPercent =
data.total > 0
? Math.round((data.progress / data.total) * 100)
: 0;
// Update the existing notification with progress
notifications.update({
id: 'bulk-channel-creation-progress',
title: 'Bulk Channel Creation in Progress',
message: `${progressPercent}% complete - ${data.message}`,
color: 'blue.5',
autoClose: false,
withCloseButton: false,
loading: true,
});
} else if (data.status === 'completed') {
// Hide the progress notification since channels_created will show success
notifications.hide('bulk-channel-creation-progress');
} else if (data.status === 'failed') {
// Update to error state
notifications.update({
id: 'bulk-channel-creation-progress',
title: 'Bulk Channel Creation Failed',
message:
data.error ||
'An error occurred during bulk channel creation',
color: 'red.5',
autoClose: 12000, // Auto-close after longer delay for errors
withCloseButton: true, // Allow manual close
loading: false, // Remove loading indicator
});
}
// Pass through to individual components for any additional handling
setVal(parsedEvent);
break;
}
case 'system_notification': {
// Handle real-time system notifications (version updates, setting recommendations, etc.)
const notificationData = parsedEvent.data.notification;
if (notificationData) {
// Import and update the notifications store
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore
.getState()
.addNotification(notificationData);
// Show a toast notification for high priority items
if (
notificationData.priority === 'high' ||
notificationData.priority === 'critical'
) {
const color =
notificationData.notification_type === 'version_update'
? 'green'
: notificationData.notification_type === 'warning'
? 'orange'
: 'blue';
notifications.show({
title: notificationData.title,
message: notificationData.message,
color,
autoClose: 10000,
});
}
}
break;
}
case 'notification_dismissed': {
// Handle notification dismissed from another session
const { notification_key } = parsedEvent.data;
if (notification_key) {
const { default: useNotificationsStore } =
await import('./store/notifications');
useNotificationsStore
.getState()
.dismissNotification(notification_key);
}
break;
}
case 'notifications_cleared': {
// Handle bulk notification clearing (e.g., when version is updated)
API.getNotifications();
break;
}
case 'ip_lookup_complete': {
const { type: _t, ...ipData } = parsedEvent.data;
useSettingsStore.getState().setEnvironmentFields(ipData);
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(() => {
// Only attempt to connect if the user is authenticated
if (isAuthenticated && accessToken) {
connectWebSocket();
} else if (ws.current) {
// Close the connection if the user logs out
clearReconnectTimer();
console.log('Closing WebSocket connection due to logout');
ws.current.onclose = null;
ws.current.close();
ws.current = null;
setIsReady(false);
}
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, isAuthenticated, accessToken]);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
const setVodStats = useChannelsStore((s) => s.setVodStats);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview);
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
const fetchLogos = useLogosStore((s) => s.fetchAllLogos);
const fetchChannelProfiles = useChannelsStore((s) => s.fetchChannelProfiles);
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>
);
};
export const useWebSocket = () => {
const socket = useContext(WebsocketContext);
return socket;
};