From 29739ede36acba0606d5e07709187e2d697aebd4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 23 Apr 2026 18:03:02 -0500 Subject: [PATCH] Enhancement: VOD start/stop notifications and system events. --- CHANGELOG.md | 1 + .../0003_alter_eventsubscription_event.py | 18 ++++ apps/connect/models.py | 2 + .../multi_worker_connection_manager.py | 96 +++++++++++++++++-- apps/proxy/vod_proxy/views.py | 38 +++++--- .../0023_alter_systemevent_event_type.py | 18 ++++ core/models.py | 2 + frontend/src/WebSocket.jsx | 51 +++++++++- frontend/src/components/SystemEvents.jsx | 30 +++--- frontend/src/constants.js | 2 + frontend/src/pages/Stats.jsx | 7 +- frontend/src/store/channels.jsx | 5 + 12 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 apps/connect/migrations/0003_alter_eventsubscription_event.py create mode 100644 core/migrations/0023_alter_systemevent_event_type.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49adf1ea..38c590bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **VOD start/stop notifications**: the frontend now shows a toast notification when a VOD stream starts or stops. `vod_started` and `vod_stopped` WebSocket events are fired from the backend when a new provider connection is opened or the last active stream on a session ends. The 1-second delayed-cleanup window is used as a settle period before firing `vod_stopped`, so seek reconnects that re-establish `active_streams` within that window suppress the notification. A `vod_stats` WebSocket push is sent alongside every event to keep the Stats page connection table in sync in real time. `vod_start` and `vod_stop` system events are also written to the system event log for each transition, and are now selectable as integration trigger events (webhook/script) in the Connect page. - **Channel Profiles column in Users table**: users can now see all channel profiles assigned to each user directly in the Users table. Added tooltips for profile names to handle overflow, adjusted column sizing to prevent overflow between columns, and improved the table layout for better readability. (Closes #819) — Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) - **Plugin warning & disclaimer components**: extracted shared plugin warning UI into a new `PluginWarnings.jsx` component and normalized warning/disclaimer usage across all plugin action modals. New reusable components: `PluginSecurityWarning` (untrusted code), `PluginSupportDisclaimer` (community support scope), `PluginDowngradeWarning` (version downgrade), `PluginInfoNote` (informational), and `PluginRestartWarning` (backend restart on import). Updated `Plugins.jsx`, `AvailablePluginCard.jsx`, and `PluginCard.jsx` to use the shared components. — Thanks [@sethwv](https://github.com/sethwv) - **Plugin file sizes**: the plugin hub now displays the download size of a plugin directly on the Install / Update / Downgrade / Overwrite buttons (e.g. `Install 142 KB`) when the repository manifest includes size data. The size is also shown in the version detail panel. Falls back gracefully to a plain button when no size is provided. A `formatKB` utility was added to convert raw KB values to human-readable strings (KB/MB). A "Publish Your Plugin" button linking to the contributing guide was added to the plugin store toolbar. — Thanks [@sethwv](https://github.com/sethwv) diff --git a/apps/connect/migrations/0003_alter_eventsubscription_event.py b/apps/connect/migrations/0003_alter_eventsubscription_event.py new file mode 100644 index 00000000..00e8f073 --- /dev/null +++ b/apps/connect/migrations/0003_alter_eventsubscription_event.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-23 23:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_connect', '0002_alter_eventsubscription_event'), + ] + + operations = [ + migrations.AlterField( + model_name='eventsubscription', + name='event', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], max_length=100), + ), + ] diff --git a/apps/connect/models.py b/apps/connect/models.py index 0b33a85e..e4b08bfc 100644 --- a/apps/connect/models.py +++ b/apps/connect/models.py @@ -16,6 +16,8 @@ SUPPORTED_EVENTS = { "login_failed": "Login Failed", "epg_blocked": "EPG Blocked", "m3u_blocked": "M3U Blocked", + "vod_start": "VOD Started", + "vod_stop": "VOD Stopped", } class Integration(models.Model): diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 073df2ca..7d367531 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -797,6 +797,67 @@ class MultiWorkerVODConnectionManager: logger.error(f"Error incrementing profile connections: {e}") return None + def _trigger_vod_stats_update(self): + """Trigger a VOD stats WebSocket update in a background thread.""" + threading.Thread(target=self._do_vod_stats_update, daemon=True).start() + + def _send_vod_event(self, event_type, session_id, content_name, content_uuid, client_ip, user_id, username=None): + """Send a vod_started or vod_stopped WebSocket event, log a system event, then update stats.""" + try: + from core.utils import send_websocket_update, log_system_event + if not self.redis_client: + return + + send_websocket_update( + "updates", + "update", + { + "type": event_type, + "content_name": content_name, + "content_uuid": content_uuid, + "client_ip": client_ip, + "user_id": user_id, + } + ) + + system_event_type = 'vod_start' if event_type == 'vod_started' else 'vod_stop' + try: + log_system_event( + system_event_type, + content_name=content_name, + content_uuid=content_uuid, + client_ip=client_ip, + username=username, + ) + except Exception as e: + logger.error(f"Could not log system event {system_event_type}: {e}") + + self._trigger_vod_stats_update() + + except Exception as e: + logger.error(f"Failed to send {event_type}: {e}") + + def _do_vod_stats_update(self): + """Collect active VOD connections (with full DB metadata) and push via WebSocket.""" + try: + from core.utils import send_websocket_update + from apps.proxy.vod_proxy.views import build_vod_stats_data + if not self.redis_client: + return + + stats = build_vod_stats_data(self.redis_client) + + send_websocket_update( + "updates", + "update", + { + "type": "vod_stats", + "stats": json.dumps(stats) + } + ) + except Exception as e: + logger.error(f"Failed to trigger VOD stats update: {e}") + def _decrement_profile_connections(self, m3u_profile_id: int): """Decrement profile connection count. @@ -1005,6 +1066,16 @@ class MultiWorkerVODConnectionManager: # to prevent cleanup race conditions with GeneratorExit. if not existing_state: redis_connection.increment_active_streams() + threading.Thread( + target=self._send_vod_event, + args=( + 'vod_started', client_id, content_name, + content_uuid, client_ip, + str(user.id) if user else '0', + user.username if user else None + ), + daemon=True + ).start() else: logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path") @@ -1061,12 +1132,18 @@ class MultiWorkerVODConnectionManager: def delayed_cleanup(): time.sleep(1) # Wait 1 second - # Smart cleanup: check active streams and ownership + # Re-check active_streams: a seeking/reconnecting client may + # have incremented it within the settle window. + if not redis_connection.has_active_streams(): + self._send_vod_event( + 'vod_stopped', client_id, content_name, + content_uuid, client_ip, + str(user.id) if user else '0', + user.username if user else None + ) logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion") - # No connection_manager — profile already decremented above redis_connection.cleanup(current_worker_id=self.worker_id) - import threading cleanup_thread = threading.Thread(target=delayed_cleanup) cleanup_thread.daemon = True cleanup_thread.start() @@ -1090,12 +1167,18 @@ class MultiWorkerVODConnectionManager: def delayed_cleanup(): time.sleep(1) # Wait 1 second - # Smart cleanup: check active streams and ownership + # Re-check active_streams: a seeking/reconnecting client may + # have incremented it within the settle window. + if not redis_connection.has_active_streams(): + self._send_vod_event( + 'vod_stopped', client_id, content_name, + content_uuid, client_ip, + str(user.id) if user else '0', + user.username if user else None + ) logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect") - # No connection_manager — profile already decremented above redis_connection.cleanup(current_worker_id=self.worker_id) - import threading cleanup_thread = threading.Thread(target=delayed_cleanup) cleanup_thread.daemon = True cleanup_thread.start() @@ -1142,7 +1225,6 @@ class MultiWorkerVODConnectionManager: # No connection_manager — profile already decremented above redis_connection.cleanup(current_worker_id=self.worker_id) - import threading cleanup_thread = threading.Thread(target=delayed_cleanup) cleanup_thread.daemon = True cleanup_thread.start() diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index a1dcf9a3..924f6906 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -684,17 +684,13 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True) return HttpResponse(f"HEAD error: {str(e)}", status=500) -@api_view(["GET"]) -@permission_classes([IsAdmin]) -def vod_stats(request): - """Get current VOD connection statistics""" +def build_vod_stats_data(redis_client): + """ + Build the full VOD stats payload (with DB lookups) from Redis connection data. + Returns a dict: {'vod_connections': [...], 'total_connections': N, 'timestamp': T} + Used by both the vod_stats API view and the WebSocket push in _do_vod_stats_update. + """ try: - connection_manager = MultiWorkerVODConnectionManager.get_instance() - redis_client = connection_manager.redis_client - - if not redis_client: - return JsonResponse({'error': 'Redis not available'}, status=500) - # Get all VOD persistent connections (consolidated data) pattern = "vod_persistent_connection:*" cursor = 0 @@ -936,11 +932,29 @@ def vod_stats(request): content_stats[content_key]['connection_count'] += 1 content_stats[content_key]['connections'].append(conn) - return JsonResponse({ + return { 'vod_connections': list(content_stats.values()), 'total_connections': len(connections), 'timestamp': current_time - }) + } + + except Exception as e: + logger.error(f"Error building VOD stats: {e}") + return {'vod_connections': [], 'total_connections': 0, 'timestamp': time.time()} + + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def vod_stats(request): + """Get current VOD connection statistics""" + try: + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return JsonResponse({'error': 'Redis not available'}, status=500) + + return JsonResponse(build_vod_stats_data(redis_client)) except Exception as e: logger.error(f"Error getting VOD stats: {e}") diff --git a/core/migrations/0023_alter_systemevent_event_type.py b/core/migrations/0023_alter_systemevent_event_type.py new file mode 100644 index 00000000..b4a34beb --- /dev/null +++ b/core/migrations/0023_alter_systemevent_event_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-23 22:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '022_default_user_limit_settings'), + ] + + operations = [ + migrations.AlterField( + model_name='systemevent', + name='event_type', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), ('login_success', 'Login Successful'), ('login_failed', 'Login Failed'), ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked'), ('vod_start', 'VOD Started'), ('vod_stop', 'VOD Stopped')], db_index=True, max_length=50), + ), + ] diff --git a/core/models.py b/core/models.py index 5e86e98f..713fc9a4 100644 --- a/core/models.py +++ b/core/models.py @@ -399,6 +399,8 @@ class SystemEvent(models.Model): ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked'), + ('vod_start', 'VOD Started'), + ('vod_stop', 'VOD Stopped'), ] event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True) diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 1df2e295..31ad1fc5 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -16,6 +16,7 @@ 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]); @@ -211,13 +212,18 @@ export const WebsocketProvider = ({ children }) => { 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', + 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 || '', + message: + reasonMap[parsedEvent.data.reason] || + parsedEvent.data.reason || + '', color: 'teal', loading: false, autoClose: 3000, @@ -311,6 +317,36 @@ export const WebsocketProvider = ({ children }) => { 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: ( + <> +
{content_name}
+
{identity}
+ + ), + color: 'blue.5', + }); + break; + } + case 'epg_channels': notifications.show({ message: 'EPG channels updated!', @@ -559,7 +595,9 @@ export const WebsocketProvider = ({ children }) => { case 'recording_cancelled': notifications.show({ - title: parsedEvent.data.was_in_progress ? 'Recording cancelled' : 'Recording deleted', + title: parsedEvent.data.was_in_progress + ? 'Recording cancelled' + : 'Recording deleted', message: parsedEvent.data.was_in_progress ? 'Recording cancelled and content removed.' : 'Recording deleted.', @@ -568,7 +606,9 @@ export const WebsocketProvider = ({ children }) => { // 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); + useChannelsStore + .getState() + .removeRecording(parsedEvent.data.recording_id); } else { scheduleRecordingFetch(); } @@ -990,6 +1030,7 @@ export const WebsocketProvider = ({ children }) => { }, [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); diff --git a/frontend/src/components/SystemEvents.jsx b/frontend/src/components/SystemEvents.jsx index 1fcfd9db..861f678a 100644 --- a/frontend/src/components/SystemEvents.jsx +++ b/frontend/src/components/SystemEvents.jsx @@ -53,6 +53,10 @@ const getEventIcon = (eventType) => { return