diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py
index 3943cf16..5a9528a7 100755
--- a/apps/channels/tasks.py
+++ b/apps/channels/tasks.py
@@ -1434,6 +1434,18 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
logger.info(f"Starting recording for channel {channel.name}")
+ # Log system event for recording start
+ try:
+ from core.utils import log_system_event
+ log_system_event(
+ 'recording_start',
+ channel_id=channel.uuid,
+ channel_name=channel.name,
+ recording_id=recording_id
+ )
+ except Exception as e:
+ logger.error(f"Could not log recording start event: {e}")
+
# Try to resolve the Recording row up front
recording_obj = None
try:
@@ -1827,6 +1839,20 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
# After the loop, the file and response are closed automatically.
logger.info(f"Finished recording for channel {channel.name}")
+ # Log system event for recording end
+ try:
+ from core.utils import log_system_event
+ log_system_event(
+ 'recording_end',
+ channel_id=channel.uuid,
+ channel_name=channel.name,
+ recording_id=recording_id,
+ interrupted=interrupted,
+ bytes_written=bytes_written
+ )
+ except Exception as e:
+ logger.error(f"Could not log recording end event: {e}")
+
# Remux TS to MKV container
remux_success = False
try:
diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py
index b6350686..59d658b1 100644
--- a/apps/epg/tasks.py
+++ b/apps/epg/tasks.py
@@ -24,7 +24,7 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from .models import EPGSource, EPGData, ProgramData
-from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory
+from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event
logger = logging.getLogger(__name__)
@@ -1496,6 +1496,15 @@ def parse_programs_for_source(epg_source, tvg_id=None):
epg_source.updated_at = timezone.now()
epg_source.save(update_fields=['status', 'last_message', 'updated_at'])
+ # Log system event for EPG refresh
+ log_system_event(
+ event_type='epg_refresh',
+ source_name=epg_source.name,
+ programs=program_count,
+ channels=channel_count,
+ updated=updated_count,
+ )
+
# Send completion notification with status
send_epg_update(epg_source.id, "parsing_programs", 100,
status="success",
diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py
index 8bd30361..cb82402e 100644
--- a/apps/m3u/tasks.py
+++ b/apps/m3u/tasks.py
@@ -24,6 +24,7 @@ from core.utils import (
acquire_task_lock,
release_task_lock,
natural_sort_key,
+ log_system_event,
)
from core.models import CoreSettings, UserAgent
from asgiref.sync import async_to_sync
@@ -2840,6 +2841,17 @@ def refresh_single_m3u_account(account_id):
account.updated_at = timezone.now()
account.save(update_fields=["status", "last_message", "updated_at"])
+ # Log system event for M3U refresh
+ log_system_event(
+ event_type='m3u_refresh',
+ account_name=account.name,
+ elapsed_time=round(elapsed_time, 2),
+ streams_created=streams_created,
+ streams_updated=streams_updated,
+ streams_deleted=streams_deleted,
+ total_processed=streams_processed,
+ )
+
# Send final update with complete metrics and explicitly include success status
send_m3u_update(
account_id,
diff --git a/apps/output/views.py b/apps/output/views.py
index df18b349..327311d8 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -23,23 +23,64 @@ from django.db.models.functions import Lower
import os
from apps.m3u.utils import calculate_tuner_count
import regex
+from core.utils import log_system_event
+import hashlib
logger = logging.getLogger(__name__)
+def get_client_identifier(request):
+ """Get client information including IP, user agent, and a unique hash identifier
+
+ Returns:
+ tuple: (client_id_hash, client_ip, user_agent)
+ """
+ # Get client IP (handle proxies)
+ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+ if x_forwarded_for:
+ client_ip = x_forwarded_for.split(',')[0].strip()
+ else:
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+
+ # Get user agent
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+
+ # Create a hash for a shorter cache key
+ client_str = f"{client_ip}:{user_agent}"
+ client_id_hash = hashlib.md5(client_str.encode()).hexdigest()[:12]
+
+ return client_id_hash, client_ip, user_agent
+
def m3u_endpoint(request, profile_name=None, user=None):
+ logger.debug("m3u_endpoint called: method=%s, profile=%s", request.method, profile_name)
if not network_access_allowed(request, "M3U_EPG"):
return JsonResponse({"error": "Forbidden"}, status=403)
+ # Handle HEAD requests efficiently without generating content
+ if request.method == "HEAD":
+ logger.debug("Handling HEAD request for M3U")
+ response = HttpResponse(content_type="audio/x-mpegurl")
+ response["Content-Disposition"] = 'attachment; filename="channels.m3u"'
+ return response
+
return generate_m3u(request, profile_name, user)
def epg_endpoint(request, profile_name=None, user=None):
+ logger.debug("epg_endpoint called: method=%s, profile=%s", request.method, profile_name)
if not network_access_allowed(request, "M3U_EPG"):
return JsonResponse({"error": "Forbidden"}, status=403)
+ # Handle HEAD requests efficiently without generating content
+ if request.method == "HEAD":
+ logger.debug("Handling HEAD request for EPG")
+ response = HttpResponse(content_type="application/xml")
+ response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"'
+ response["Cache-Control"] = "no-cache"
+ return response
+
return generate_epg(request, profile_name, user)
@csrf_exempt
-@require_http_methods(["GET", "POST"])
+@require_http_methods(["GET", "POST", "HEAD"])
def generate_m3u(request, profile_name=None, user=None):
"""
Dynamically generate an M3U file from channels.
@@ -47,7 +88,19 @@ def generate_m3u(request, profile_name=None, user=None):
Supports both GET and POST methods for compatibility with IPTVSmarters.
"""
# Check if this is a POST request and the body is not empty (which we don't want to allow)
- logger.debug("Generating M3U for profile: %s, user: %s", profile_name, user.username if user else "Anonymous")
+ logger.debug("Generating M3U for profile: %s, user: %s, method: %s", profile_name, user.username if user else "Anonymous", request.method)
+
+ # Check cache for recent identical request (helps with double-GET from browsers)
+ from django.core.cache import cache
+ cache_params = f"{profile_name or 'all'}:{user.username if user else 'anonymous'}:{request.GET.urlencode()}"
+ content_cache_key = f"m3u_content:{cache_params}"
+
+ cached_content = cache.get(content_cache_key)
+ if cached_content:
+ logger.debug("Serving M3U from cache")
+ response = HttpResponse(cached_content, content_type="audio/x-mpegurl")
+ response["Content-Disposition"] = 'attachment; filename="channels.m3u"'
+ return response
# Check if this is a POST request with data (which we don't want to allow)
if request.method == "POST" and request.body:
if request.body.decode() != '{}':
@@ -184,6 +237,23 @@ def generate_m3u(request, profile_name=None, user=None):
m3u_content += extinf_line + stream_url + "\n"
+ # Cache the generated content for 2 seconds to handle double-GET requests
+ cache.set(content_cache_key, m3u_content, 2)
+
+ # Log system event for M3U download (with deduplication based on client)
+ client_id, client_ip, user_agent = get_client_identifier(request)
+ event_cache_key = f"m3u_download:{user.username if user else 'anonymous'}:{profile_name or 'all'}:{client_id}"
+ if not cache.get(event_cache_key):
+ log_system_event(
+ event_type='m3u_download',
+ profile=profile_name or 'all',
+ user=user.username if user else 'anonymous',
+ channels=channels.count(),
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
+ cache.set(event_cache_key, True, 2) # Prevent duplicate events for 2 seconds
+
response = HttpResponse(m3u_content, content_type="audio/x-mpegurl")
response["Content-Disposition"] = 'attachment; filename="channels.m3u"'
return response
@@ -1126,8 +1196,22 @@ def generate_epg(request, profile_name=None, user=None):
by their associated EPGData record.
This version filters data based on the 'days' parameter and sends keep-alives during processing.
"""
+ # Check cache for recent identical request (helps with double-GET from browsers)
+ from django.core.cache import cache
+ cache_params = f"{profile_name or 'all'}:{user.username if user else 'anonymous'}:{request.GET.urlencode()}"
+ content_cache_key = f"epg_content:{cache_params}"
+
+ cached_content = cache.get(content_cache_key)
+ if cached_content:
+ logger.debug("Serving EPG from cache")
+ response = HttpResponse(cached_content, content_type="application/xml")
+ response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"'
+ response["Cache-Control"] = "no-cache"
+ return response
+
def epg_generator():
- """Generator function that yields EPG data with keep-alives during processing""" # Send initial HTTP headers as comments (these will be ignored by XML parsers but keep connection alive)
+ """Generator function that yields EPG data with keep-alives during processing"""
+ # Send initial HTTP headers as comments (these will be ignored by XML parsers but keep connection alive)
xml_lines = []
xml_lines.append('')
@@ -1286,7 +1370,8 @@ def generate_epg(request, profile_name=None, user=None):
xml_lines.append(" ")
# Send all channel definitions
- yield '\n'.join(xml_lines) + '\n'
+ channel_xml = '\n'.join(xml_lines) + '\n'
+ yield channel_xml
xml_lines = [] # Clear to save memory
# Process programs for each channel
@@ -1676,7 +1761,8 @@ def generate_epg(request, profile_name=None, user=None):
# Send batch when full or send keep-alive
if len(program_batch) >= batch_size:
- yield '\n'.join(program_batch) + '\n'
+ batch_xml = '\n'.join(program_batch) + '\n'
+ yield batch_xml
program_batch = []
# Move to next chunk
@@ -1684,12 +1770,40 @@ def generate_epg(request, profile_name=None, user=None):
# Send remaining programs in batch
if program_batch:
- yield '\n'.join(program_batch) + '\n'
+ batch_xml = '\n'.join(program_batch) + '\n'
+ yield batch_xml
# Send final closing tag and completion message
- yield "\n" # Return streaming response
+ yield "\n"
+
+ # Log system event for EPG download after streaming completes (with deduplication based on client)
+ client_id, client_ip, user_agent = get_client_identifier(request)
+ event_cache_key = f"epg_download:{user.username if user else 'anonymous'}:{profile_name or 'all'}:{client_id}"
+ if not cache.get(event_cache_key):
+ log_system_event(
+ event_type='epg_download',
+ profile=profile_name or 'all',
+ user=user.username if user else 'anonymous',
+ channels=channels.count(),
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
+ cache.set(event_cache_key, True, 2) # Prevent duplicate events for 2 seconds
+
+ # Wrapper generator that collects content for caching
+ def caching_generator():
+ collected_content = []
+ for chunk in epg_generator():
+ collected_content.append(chunk)
+ yield chunk
+ # After streaming completes, cache the full content
+ full_content = ''.join(collected_content)
+ cache.set(content_cache_key, full_content, 300)
+ logger.debug("Cached EPG content (%d bytes)", len(full_content))
+
+ # Return streaming response
response = StreamingHttpResponse(
- streaming_content=epg_generator(),
+ streaming_content=caching_generator(),
content_type="application/xml"
)
response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"'
diff --git a/apps/proxy/ts_proxy/client_manager.py b/apps/proxy/ts_proxy/client_manager.py
index 3d89b3b8..bffecdde 100644
--- a/apps/proxy/ts_proxy/client_manager.py
+++ b/apps/proxy/ts_proxy/client_manager.py
@@ -34,6 +34,10 @@ class ClientManager:
self.heartbeat_interval = ConfigHelper.get('CLIENT_HEARTBEAT_INTERVAL', 10)
self.last_heartbeat_time = {}
+ # Get ProxyServer instance for ownership checks
+ from .server import ProxyServer
+ self.proxy_server = ProxyServer.get_instance()
+
# Start heartbeat thread for local clients
self._start_heartbeat_thread()
self._registered_clients = set() # Track already registered client IDs
@@ -337,16 +341,30 @@ class ClientManager:
self._notify_owner_of_activity()
- # Publish client disconnected event
- event_data = json.dumps({
- "event": EventType.CLIENT_DISCONNECTED, # Use constant instead of string
- "channel_id": self.channel_id,
- "client_id": client_id,
- "worker_id": self.worker_id or "unknown",
- "timestamp": time.time(),
- "remaining_clients": remaining
- })
- self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
+ # Check if we're the owner - if so, handle locally; if not, publish event
+ am_i_owner = self.proxy_server and self.proxy_server.am_i_owner(self.channel_id)
+
+ if am_i_owner:
+ # We're the owner - handle the disconnect directly
+ logger.debug(f"Owner handling CLIENT_DISCONNECTED for client {client_id} locally (not publishing)")
+ if remaining == 0:
+ # Trigger shutdown check directly via ProxyServer method
+ logger.debug(f"No clients left - triggering immediate shutdown check")
+ # Spawn greenlet to avoid blocking
+ import gevent
+ gevent.spawn(self.proxy_server.handle_client_disconnect, self.channel_id)
+ else:
+ # We're not the owner - publish event so owner can handle it
+ logger.debug(f"Non-owner publishing CLIENT_DISCONNECTED event for client {client_id} on channel {self.channel_id} from worker {self.worker_id}")
+ event_data = json.dumps({
+ "event": EventType.CLIENT_DISCONNECTED,
+ "channel_id": self.channel_id,
+ "client_id": client_id,
+ "worker_id": self.worker_id or "unknown",
+ "timestamp": time.time(),
+ "remaining_clients": remaining
+ })
+ self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
# Trigger channel stats update via WebSocket
self._trigger_stats_update()
diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py
index 0b07b4ae..db5b3d57 100644
--- a/apps/proxy/ts_proxy/server.py
+++ b/apps/proxy/ts_proxy/server.py
@@ -19,7 +19,7 @@ import gevent # Add gevent import
from typing import Dict, Optional, Set
from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel, Stream
-from core.utils import RedisClient
+from core.utils import RedisClient, log_system_event
from redis.exceptions import ConnectionError, TimeoutError
from .stream_manager import StreamManager
from .stream_buffer import StreamBuffer
@@ -194,35 +194,11 @@ class ProxyServer:
self.redis_client.delete(disconnect_key)
elif event_type == EventType.CLIENT_DISCONNECTED:
- logger.debug(f"Owner received {EventType.CLIENT_DISCONNECTED} event for channel {channel_id}")
- # Check if any clients remain
- if channel_id in self.client_managers:
- # VERIFY REDIS CLIENT COUNT DIRECTLY
- client_set_key = RedisKeys.clients(channel_id)
- total = self.redis_client.scard(client_set_key) or 0
-
- if total == 0:
- logger.debug(f"No clients left after disconnect event - stopping channel {channel_id}")
- # Set the disconnect timer for other workers to see
- disconnect_key = RedisKeys.last_client_disconnect(channel_id)
- self.redis_client.setex(disconnect_key, 60, str(time.time()))
-
- # Get configured shutdown delay or default
- shutdown_delay = ConfigHelper.channel_shutdown_delay()
-
- if shutdown_delay > 0:
- logger.info(f"Waiting {shutdown_delay}s before stopping channel...")
- gevent.sleep(shutdown_delay) # REPLACE: time.sleep(shutdown_delay)
-
- # Re-check client count before stopping
- total = self.redis_client.scard(client_set_key) or 0
- if total > 0:
- logger.info(f"New clients connected during shutdown delay - aborting shutdown")
- self.redis_client.delete(disconnect_key)
- return
-
- # Stop the channel directly
- self.stop_channel(channel_id)
+ client_id = data.get("client_id")
+ worker_id = data.get("worker_id")
+ logger.debug(f"Owner received {EventType.CLIENT_DISCONNECTED} event for channel {channel_id}, client {client_id} from worker {worker_id}")
+ # Delegate to dedicated method
+ self.handle_client_disconnect(channel_id)
elif event_type == EventType.STREAM_SWITCH:
@@ -646,6 +622,29 @@ class ProxyServer:
logger.info(f"Created StreamManager for channel {channel_id} with stream ID {channel_stream_id}")
self.stream_managers[channel_id] = stream_manager
+ # Log channel start event
+ try:
+ channel_obj = Channel.objects.get(uuid=channel_id)
+
+ # Get stream name if stream_id is available
+ stream_name = None
+ if channel_stream_id:
+ try:
+ stream_obj = Stream.objects.get(id=channel_stream_id)
+ stream_name = stream_obj.name
+ except Exception:
+ pass
+
+ log_system_event(
+ 'channel_start',
+ channel_id=channel_id,
+ channel_name=channel_obj.name,
+ stream_name=stream_name,
+ stream_id=channel_stream_id
+ )
+ except Exception as e:
+ logger.error(f"Could not log channel start event: {e}")
+
# Create client manager with channel_id, redis_client AND worker_id (only if not already exists)
if channel_id not in self.client_managers:
client_manager = ClientManager(
@@ -800,6 +799,44 @@ class ProxyServer:
logger.error(f"Error cleaning zombie channel {channel_id}: {e}", exc_info=True)
return False
+ def handle_client_disconnect(self, channel_id):
+ """
+ Handle client disconnect event - check if channel should shut down.
+ Can be called directly by owner or via PubSub from non-owner workers.
+ """
+ if channel_id not in self.client_managers:
+ return
+
+ try:
+ # VERIFY REDIS CLIENT COUNT DIRECTLY
+ client_set_key = RedisKeys.clients(channel_id)
+ total = self.redis_client.scard(client_set_key) or 0
+
+ if total == 0:
+ logger.debug(f"No clients left after disconnect event - stopping channel {channel_id}")
+ # Set the disconnect timer for other workers to see
+ disconnect_key = RedisKeys.last_client_disconnect(channel_id)
+ self.redis_client.setex(disconnect_key, 60, str(time.time()))
+
+ # Get configured shutdown delay or default
+ shutdown_delay = ConfigHelper.channel_shutdown_delay()
+
+ if shutdown_delay > 0:
+ logger.info(f"Waiting {shutdown_delay}s before stopping channel...")
+ gevent.sleep(shutdown_delay)
+
+ # Re-check client count before stopping
+ total = self.redis_client.scard(client_set_key) or 0
+ if total > 0:
+ logger.info(f"New clients connected during shutdown delay - aborting shutdown")
+ self.redis_client.delete(disconnect_key)
+ return
+
+ # Stop the channel directly
+ self.stop_channel(channel_id)
+ except Exception as e:
+ logger.error(f"Error handling client disconnect for channel {channel_id}: {e}")
+
def stop_channel(self, channel_id):
"""Stop a channel with proper ownership handling"""
try:
@@ -847,6 +884,41 @@ class ProxyServer:
self.release_ownership(channel_id)
logger.info(f"Released ownership of channel {channel_id}")
+ # Log channel stop event (after cleanup, before releasing ownership section ends)
+ try:
+ channel_obj = Channel.objects.get(uuid=channel_id)
+
+ # Calculate runtime and get total bytes from metadata
+ runtime = None
+ total_bytes = None
+ if self.redis_client:
+ metadata_key = RedisKeys.channel_metadata(channel_id)
+ metadata = self.redis_client.hgetall(metadata_key)
+ if metadata:
+ # Calculate runtime from init_time
+ if b'init_time' in metadata:
+ try:
+ init_time = float(metadata[b'init_time'].decode('utf-8'))
+ runtime = round(time.time() - init_time, 2)
+ except Exception:
+ pass
+ # Get total bytes transferred
+ if b'total_bytes' in metadata:
+ try:
+ total_bytes = int(metadata[b'total_bytes'].decode('utf-8'))
+ except Exception:
+ pass
+
+ log_system_event(
+ 'channel_stop',
+ channel_id=channel_id,
+ channel_name=channel_obj.name,
+ runtime=runtime,
+ total_bytes=total_bytes
+ )
+ except Exception as e:
+ logger.error(f"Could not log channel stop event: {e}")
+
# Always clean up local resources - WITH SAFE CHECKS
if channel_id in self.stream_managers:
del self.stream_managers[channel_id]
@@ -968,6 +1040,13 @@ class ProxyServer:
# If in connecting or waiting_for_clients state, check grace period
if channel_state in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
+ # Check if channel is already stopping
+ if self.redis_client:
+ stop_key = RedisKeys.channel_stopping(channel_id)
+ if self.redis_client.exists(stop_key):
+ logger.debug(f"Channel {channel_id} is already stopping - skipping monitor shutdown")
+ continue
+
# Get connection_ready_time from metadata (indicates if channel reached ready state)
connection_ready_time = None
if metadata and b'connection_ready_time' in metadata:
@@ -1048,6 +1127,13 @@ class ProxyServer:
logger.info(f"Channel {channel_id} activated with {total_clients} clients after grace period")
# If active and no clients, start normal shutdown procedure
elif channel_state not in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS] and total_clients == 0:
+ # Check if channel is already stopping
+ if self.redis_client:
+ stop_key = RedisKeys.channel_stopping(channel_id)
+ if self.redis_client.exists(stop_key):
+ logger.debug(f"Channel {channel_id} is already stopping - skipping monitor shutdown")
+ continue
+
# Check if there's a pending no-clients timeout
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
disconnect_time = None
diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py
index 551e2d27..6484cd3f 100644
--- a/apps/proxy/ts_proxy/services/channel_service.py
+++ b/apps/proxy/ts_proxy/services/channel_service.py
@@ -14,6 +14,7 @@ from ..server import ProxyServer
from ..redis_keys import RedisKeys
from ..constants import EventType, ChannelState, ChannelMetadataField
from ..url_utils import get_stream_info_for_switch
+from core.utils import log_system_event
logger = logging.getLogger("ts_proxy")
@@ -598,7 +599,7 @@ class ChannelService:
def _update_stream_stats_in_db(stream_id, **stats):
"""Update stream stats in database"""
from django.db import connection
-
+
try:
from apps.channels.models import Stream
from django.utils import timezone
@@ -624,7 +625,7 @@ class ChannelService:
except Exception as e:
logger.error(f"Error updating stream stats in database for stream {stream_id}: {e}")
return False
-
+
finally:
# Always close database connection after update
try:
@@ -700,6 +701,7 @@ class ChannelService:
RedisKeys.events_channel(channel_id),
json.dumps(switch_request)
)
+
return True
@staticmethod
diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py
index 5d4f661f..50404f1d 100644
--- a/apps/proxy/ts_proxy/stream_generator.py
+++ b/apps/proxy/ts_proxy/stream_generator.py
@@ -8,6 +8,8 @@ import logging
import threading
import gevent # Add this import at the top of your file
from apps.proxy.config import TSConfig as Config
+from apps.channels.models import Channel
+from core.utils import log_system_event
from .server import ProxyServer
from .utils import create_ts_packet, get_logger
from .redis_keys import RedisKeys
@@ -88,6 +90,20 @@ class StreamGenerator:
if not self._setup_streaming():
return
+ # Log client connect event
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'client_connect',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ client_ip=self.client_ip,
+ client_id=self.client_id,
+ user_agent=self.client_user_agent[:100] if self.client_user_agent else None
+ )
+ except Exception as e:
+ logger.error(f"Could not log client connect event: {e}")
+
# Main streaming loop
for chunk in self._stream_data_generator():
yield chunk
@@ -439,6 +455,22 @@ class StreamGenerator:
total_clients = client_manager.get_total_client_count()
logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})")
+ # Log client disconnect event
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'client_disconnect',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ client_ip=self.client_ip,
+ client_id=self.client_id,
+ user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
+ duration=round(elapsed, 2),
+ bytes_sent=self.bytes_sent
+ )
+ except Exception as e:
+ logger.error(f"Could not log client disconnect event: {e}")
+
# Schedule channel shutdown if no clients left
if not stream_released: # Only if we haven't already released the stream
self._schedule_channel_shutdown_if_needed(local_clients)
diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py
index c717398c..bbeb4bb7 100644
--- a/apps/proxy/ts_proxy/stream_manager.py
+++ b/apps/proxy/ts_proxy/stream_manager.py
@@ -16,6 +16,7 @@ from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings
+from core.utils import log_system_event
from .stream_buffer import StreamBuffer
from .utils import detect_stream_type, get_logger
from .redis_keys import RedisKeys
@@ -260,6 +261,20 @@ class StreamManager:
# Store connection start time to measure success duration
connection_start_time = time.time()
+ # Log reconnection event if this is a retry (not first attempt)
+ if self.retry_count > 0:
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'channel_reconnect',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ attempt=self.retry_count + 1,
+ max_attempts=self.max_retries
+ )
+ except Exception as e:
+ logger.error(f"Could not log reconnection event: {e}")
+
# Successfully connected - read stream data until disconnect/error
self._process_stream_data()
# If we get here, the connection was closed/failed
@@ -289,6 +304,20 @@ class StreamManager:
if self.retry_count >= self.max_retries:
url_failed = True
logger.warning(f"Maximum retry attempts ({self.max_retries}) reached for URL: {self.url} for channel: {self.channel_id}")
+
+ # Log connection error event
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'channel_error',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ error_type='connection_failed',
+ url=self.url[:100] if self.url else None,
+ attempts=self.max_retries
+ )
+ except Exception as e:
+ logger.error(f"Could not log connection error event: {e}")
else:
# Wait with exponential backoff before retrying
timeout = min(.25 * self.retry_count, 3) # Cap at 3 seconds
@@ -302,6 +331,21 @@ class StreamManager:
if self.retry_count >= self.max_retries:
url_failed = True
+
+ # Log connection error event with exception details
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'channel_error',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ error_type='connection_exception',
+ error_message=str(e)[:200],
+ url=self.url[:100] if self.url else None,
+ attempts=self.max_retries
+ )
+ except Exception as log_error:
+ logger.error(f"Could not log connection error event: {log_error}")
else:
# Wait with exponential backoff before retrying
timeout = min(.25 * self.retry_count, 3) # Cap at 3 seconds
@@ -702,6 +746,19 @@ class StreamManager:
# Reset buffering state
self.buffering = False
self.buffering_start_time = None
+
+ # Log failover event
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'channel_failover',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ reason='buffering_timeout',
+ duration=buffering_duration
+ )
+ except Exception as e:
+ logger.error(f"Could not log failover event: {e}")
else:
logger.error(f"Failed to switch to next stream for channel {self.channel_id} after buffering timeout")
else:
@@ -709,6 +766,19 @@ class StreamManager:
self.buffering = True
self.buffering_start_time = time.time()
logger.warning(f"Buffering started for channel {self.channel_id} - speed: {ffmpeg_speed}x")
+
+ # Log system event for buffering
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'channel_buffering',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ speed=ffmpeg_speed
+ )
+ except Exception as e:
+ logger.error(f"Could not log buffering event: {e}")
+
# Log buffering warning
logger.debug(f"FFmpeg speed on channel {self.channel_id} is below {self.buffering_speed} ({ffmpeg_speed}x) - buffering detected")
# Set channel state to buffering
@@ -1004,6 +1074,19 @@ class StreamManager:
except Exception as e:
logger.warning(f"Failed to reset buffer position: {e}")
+ # Log stream switch event
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'stream_switch',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ new_url=new_url[:100] if new_url else None,
+ stream_id=stream_id
+ )
+ except Exception as e:
+ logger.error(f"Could not log stream switch event: {e}")
+
return True
except Exception as e:
logger.error(f"Error during URL update for channel {self.channel_id}: {e}", exc_info=True)
@@ -1122,6 +1205,19 @@ class StreamManager:
if connection_result:
self.connection_start_time = time.time()
logger.info(f"Reconnect successful for channel {self.channel_id}")
+
+ # Log reconnection event
+ try:
+ channel_obj = Channel.objects.get(uuid=self.channel_id)
+ log_system_event(
+ 'channel_reconnect',
+ channel_id=self.channel_id,
+ channel_name=channel_obj.name,
+ reason='health_monitor'
+ )
+ except Exception as e:
+ logger.error(f"Could not log reconnection event: {e}")
+
return True
else:
logger.warning(f"Reconnect failed for channel {self.channel_id}")
@@ -1199,25 +1295,17 @@ class StreamManager:
logger.debug(f"Error closing socket for channel {self.channel_id}: {e}")
pass
- # Enhanced transcode process cleanup with more aggressive termination
+ # Enhanced transcode process cleanup with immediate termination
if self.transcode_process:
try:
- # First try polite termination
- logger.debug(f"Terminating transcode process for channel {self.channel_id}")
- self.transcode_process.terminate()
+ logger.debug(f"Killing transcode process for channel {self.channel_id}")
+ self.transcode_process.kill()
- # Give it a short time to terminate gracefully
+ # Give it a very short time to die
try:
- self.transcode_process.wait(timeout=1.0)
+ self.transcode_process.wait(timeout=0.5)
except subprocess.TimeoutExpired:
- # If it doesn't terminate quickly, kill it
- logger.warning(f"Transcode process didn't terminate within timeout, killing forcefully for channel {self.channel_id}")
- self.transcode_process.kill()
-
- try:
- self.transcode_process.wait(timeout=1.0)
- except subprocess.TimeoutExpired:
- logger.error(f"Failed to kill transcode process even with force for channel {self.channel_id}")
+ logger.error(f"Failed to kill transcode process even with force for channel {self.channel_id}")
except Exception as e:
logger.debug(f"Error terminating transcode process for channel {self.channel_id}: {e}")
diff --git a/core/api_urls.py b/core/api_urls.py
index baa4bbe5..75257db1 100644
--- a/core/api_urls.py
+++ b/core/api_urls.py
@@ -2,7 +2,16 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
-from .api_views import UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, environment, version, rehash_streams_endpoint, TimezoneListView
+from .api_views import (
+ UserAgentViewSet,
+ StreamProfileViewSet,
+ CoreSettingsViewSet,
+ environment,
+ version,
+ rehash_streams_endpoint,
+ TimezoneListView,
+ get_system_events
+)
router = DefaultRouter()
router.register(r'useragents', UserAgentViewSet, basename='useragent')
@@ -13,5 +22,6 @@ urlpatterns = [
path('version/', version, name='version'),
path('rehash-streams/', rehash_streams_endpoint, name='rehash_streams'),
path('timezones/', TimezoneListView.as_view(), name='timezones'),
+ path('system-events/', get_system_events, name='system_events'),
path('', include(router.urls)),
]
diff --git a/core/api_views.py b/core/api_views.py
index f475909a..c50d7fa6 100644
--- a/core/api_views.py
+++ b/core/api_views.py
@@ -396,3 +396,64 @@ class TimezoneListView(APIView):
'grouped': grouped,
'count': len(all_timezones)
})
+
+
+# ─────────────────────────────
+# System Events API
+# ─────────────────────────────
+@api_view(['GET'])
+@permission_classes([IsAuthenticated])
+def get_system_events(request):
+ """
+ Get recent system events (channel start/stop, buffering, client connections, etc.)
+
+ Query Parameters:
+ limit: Number of events to return per page (default: 100, max: 1000)
+ offset: Number of events to skip (for pagination, default: 0)
+ event_type: Filter by specific event type (optional)
+ """
+ from core.models import SystemEvent
+
+ try:
+ # Get pagination params
+ limit = min(int(request.GET.get('limit', 100)), 1000)
+ offset = int(request.GET.get('offset', 0))
+
+ # Start with all events
+ events = SystemEvent.objects.all()
+
+ # Filter by event_type if provided
+ event_type = request.GET.get('event_type')
+ if event_type:
+ events = events.filter(event_type=event_type)
+
+ # Get total count before applying pagination
+ total_count = events.count()
+
+ # Apply offset and limit for pagination
+ events = events[offset:offset + limit]
+
+ # Serialize the data
+ events_data = [{
+ 'id': event.id,
+ 'event_type': event.event_type,
+ 'event_type_display': event.get_event_type_display(),
+ 'timestamp': event.timestamp.isoformat(),
+ 'channel_id': str(event.channel_id) if event.channel_id else None,
+ 'channel_name': event.channel_name,
+ 'details': event.details
+ } for event in events]
+
+ return Response({
+ 'events': events_data,
+ 'count': len(events_data),
+ 'total': total_count,
+ 'offset': offset,
+ 'limit': limit
+ })
+
+ except Exception as e:
+ logger.error(f"Error fetching system events: {e}")
+ return Response({
+ 'error': 'Failed to fetch system events'
+ }, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
diff --git a/core/migrations/0017_systemevent.py b/core/migrations/0017_systemevent.py
new file mode 100644
index 00000000..9b97213c
--- /dev/null
+++ b/core/migrations/0017_systemevent.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.2.4 on 2025-11-20 20:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0016_update_dvr_template_paths'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SystemEvent',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('event_type', 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')], db_index=True, max_length=50)),
+ ('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)),
+ ('channel_id', models.UUIDField(blank=True, db_index=True, null=True)),
+ ('channel_name', models.CharField(blank=True, max_length=255, null=True)),
+ ('details', models.JSONField(blank=True, default=dict)),
+ ],
+ options={
+ 'ordering': ['-timestamp'],
+ 'indexes': [models.Index(fields=['-timestamp'], name='core_system_timesta_c6c3d1_idx'), models.Index(fields=['event_type', '-timestamp'], name='core_system_event_t_4267d9_idx')],
+ },
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 3a5895ba..2a5eb1f3 100644
--- a/core/models.py
+++ b/core/models.py
@@ -375,3 +375,43 @@ class CoreSettings(models.Model):
return rules
except Exception:
return rules
+
+
+class SystemEvent(models.Model):
+ """
+ Tracks system events like channel start/stop, buffering, failover, client connections.
+ Maintains a rolling history based on max_system_events setting.
+ """
+ EVENT_TYPES = [
+ ('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'),
+ ]
+
+ event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
+ timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
+ channel_id = models.UUIDField(null=True, blank=True, db_index=True)
+ channel_name = models.CharField(max_length=255, null=True, blank=True)
+ details = models.JSONField(default=dict, blank=True)
+
+ class Meta:
+ ordering = ['-timestamp']
+ indexes = [
+ models.Index(fields=['-timestamp']),
+ models.Index(fields=['event_type', '-timestamp']),
+ ]
+
+ def __str__(self):
+ return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}"
diff --git a/core/utils.py b/core/utils.py
index 38b31144..7b6dd9b0 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -388,3 +388,48 @@ def validate_flexible_url(value):
# If it doesn't match our flexible patterns, raise the original error
raise ValidationError("Enter a valid URL.")
+
+
+def log_system_event(event_type, channel_id=None, channel_name=None, **details):
+ """
+ Log a system event and maintain the configured max history.
+
+ Args:
+ event_type: Type of event (e.g., 'channel_start', 'client_connect')
+ channel_id: Optional UUID of the channel
+ channel_name: Optional name of the channel
+ **details: Additional details to store in the event (stored as JSON)
+
+ Example:
+ log_system_event('channel_start', channel_id=uuid, channel_name='CNN',
+ stream_url='http://...', user='admin')
+ """
+ from core.models import SystemEvent, CoreSettings
+
+ try:
+ # Create the event
+ SystemEvent.objects.create(
+ event_type=event_type,
+ channel_id=channel_id,
+ channel_name=channel_name,
+ details=details
+ )
+
+ # Get max events from settings (default 100)
+ try:
+ max_events_setting = CoreSettings.objects.filter(key='max-system-events').first()
+ max_events = int(max_events_setting.value) if max_events_setting else 100
+ except Exception:
+ max_events = 100
+
+ # Delete old events beyond the limit (keep it efficient with a single query)
+ total_count = SystemEvent.objects.count()
+ if total_count > max_events:
+ # Get the ID of the event at the cutoff point
+ cutoff_event = SystemEvent.objects.values_list('id', flat=True)[max_events]
+ # Delete all events with ID less than cutoff (older events)
+ SystemEvent.objects.filter(id__lt=cutoff_event).delete()
+
+ except Exception as e:
+ # Don't let event logging break the main application
+ logger.error(f"Failed to log system event {event_type}: {e}")
diff --git a/frontend/src/api.js b/frontend/src/api.js
index fac95b34..470373f1 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -2481,4 +2481,21 @@ export default class API {
errorNotification('Failed to update playback position', e);
}
}
+
+ static async getSystemEvents(limit = 100, offset = 0, eventType = null) {
+ try {
+ const params = new URLSearchParams();
+ params.append('limit', limit);
+ params.append('offset', offset);
+ if (eventType) {
+ params.append('event_type', eventType);
+ }
+ const response = await request(
+ `${host}/api/core/system-events/?${params.toString()}`
+ );
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve system events', e);
+ }
+ }
}
diff --git a/frontend/src/components/SystemEvents.jsx b/frontend/src/components/SystemEvents.jsx
new file mode 100644
index 00000000..4047801c
--- /dev/null
+++ b/frontend/src/components/SystemEvents.jsx
@@ -0,0 +1,304 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ ActionIcon,
+ Box,
+ Button,
+ Card,
+ Group,
+ NumberInput,
+ Pagination,
+ Select,
+ Stack,
+ Text,
+ Title,
+} from '@mantine/core';
+import {
+ ChevronDown,
+ CirclePlay,
+ Download,
+ Gauge,
+ HardDriveDownload,
+ List,
+ RefreshCw,
+ SquareX,
+ Timer,
+ Users,
+ Video,
+} from 'lucide-react';
+import dayjs from 'dayjs';
+import API from '../api';
+import useLocalStorage from '../hooks/useLocalStorage';
+
+const SystemEvents = () => {
+ const [events, setEvents] = useState([]);
+ const [totalEvents, setTotalEvents] = useState(0);
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
+ const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
+ const [eventsRefreshInterval, setEventsRefreshInterval] = useLocalStorage(
+ 'events-refresh-interval',
+ 0
+ );
+ const [eventsLimit, setEventsLimit] = useLocalStorage('events-limit', 100);
+ const [currentPage, setCurrentPage] = useState(1);
+
+ // Calculate offset based on current page and limit
+ const offset = (currentPage - 1) * eventsLimit;
+ const totalPages = Math.ceil(totalEvents / eventsLimit);
+
+ const fetchEvents = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const response = await API.getSystemEvents(eventsLimit, offset);
+ if (response && response.events) {
+ setEvents(response.events);
+ setTotalEvents(response.total || 0);
+ }
+ } catch (error) {
+ console.error('Error fetching system events:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [eventsLimit, offset]);
+
+ // Fetch events on mount and when eventsRefreshInterval changes
+ useEffect(() => {
+ fetchEvents();
+
+ // Set up polling if interval is set and events section is expanded
+ if (eventsRefreshInterval > 0 && isExpanded) {
+ const interval = setInterval(fetchEvents, eventsRefreshInterval * 1000);
+ return () => clearInterval(interval);
+ }
+ }, [fetchEvents, eventsRefreshInterval, isExpanded]);
+
+ // Reset to first page when limit changes
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [eventsLimit]);
+
+ const getEventIcon = (eventType) => {
+ switch (eventType) {
+ case 'channel_start':
+ return ;
+ case 'channel_stop':
+ return ;
+ case 'channel_reconnect':
+ return ;
+ case 'channel_buffering':
+ return ;
+ case 'channel_failover':
+ return ;
+ case 'client_connect':
+ return ;
+ case 'client_disconnect':
+ return ;
+ case 'recording_start':
+ return ;
+ case 'recording_end':
+ return ;
+ case 'stream_switch':
+ return ;
+ case 'm3u_refresh':
+ return ;
+ case 'm3u_download':
+ return ;
+ case 'epg_refresh':
+ return ;
+ case 'epg_download':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getEventColor = (eventType) => {
+ switch (eventType) {
+ case 'channel_start':
+ case 'client_connect':
+ case 'recording_start':
+ return 'green';
+ case 'channel_reconnect':
+ return 'yellow';
+ case 'channel_stop':
+ case 'client_disconnect':
+ case 'recording_end':
+ return 'gray';
+ case 'channel_buffering':
+ return 'yellow';
+ case 'channel_failover':
+ case 'channel_error':
+ return 'orange';
+ case 'stream_switch':
+ return 'blue';
+ case 'm3u_refresh':
+ case 'epg_refresh':
+ return 'cyan';
+ case 'm3u_download':
+ case 'epg_download':
+ return 'teal';
+ default:
+ return 'gray';
+ }
+ };
+
+ return (
+
+
+
+
+ System Events
+
+
+ setEventsLimit(value || 10)}
+ min={10}
+ max={1000}
+ step={10}
+ style={{ width: 130 }}
+ />
+
+
+
+ {isExpanded && (
+ <>
+ {totalEvents > eventsLimit && (
+
+
+ Showing {offset + 1}-
+ {Math.min(offset + eventsLimit, totalEvents)} of {totalEvents}
+
+
+
+ )}
+
+ {events.length === 0 ? (
+
+ No events recorded yet
+
+ ) : (
+ events.map((event) => (
+
+
+
+
+ {getEventIcon(event.event_type)}
+
+
+
+
+ {event.event_type_display || event.event_type}
+
+ {event.channel_name && (
+
+ {event.channel_name}
+
+ )}
+
+ {event.details &&
+ Object.keys(event.details).length > 0 && (
+
+ {Object.entries(event.details)
+ .filter(
+ ([key]) =>
+ !['stream_url', 'new_url'].includes(key)
+ )
+ .map(([key, value]) => `${key}: ${value}`)
+ .join(', ')}
+
+ )}
+
+
+
+ {dayjs(event.timestamp).format(`${dateFormat} HH:mm:ss`)}
+
+
+
+ ))
+ )}
+
+ >
+ )}
+
+ );
+};
+
+export default SystemEvents;
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index e6d6378c..10f6f5a2 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -285,7 +285,8 @@ const SettingsPage = () => {
acc[key] = (value) => {
const cidrs = value.split(',');
const ipv4CidrRegex = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/;
- const ipv6CidrRegex = /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
+ const ipv6CidrRegex =
+ /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/;
for (const cidr of cidrs) {
if (cidr.match(ipv4CidrRegex) || cidr.match(ipv6CidrRegex)) {
continue;
@@ -1093,6 +1094,46 @@ const SettingsPage = () => {
+
+ System Settings
+
+
+ {generalSettingsSaved && (
+
+ )}
+
+ Configure how many system events (channel start/stop,
+ buffering, etc.) to keep in the database. Events are
+ displayed on the Stats page.
+
+ {
+ form.setFieldValue('max-system-events', value);
+ }}
+ min={10}
+ max={1000}
+ step={10}
+ />
+
+
+
+
+
+
+
User-Agents
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index 52c31656..c3488072 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -8,6 +8,7 @@ import {
Container,
Flex,
Group,
+ Pagination,
Progress,
SimpleGrid,
Stack,
@@ -25,9 +26,11 @@ import useLogosStore from '../store/logos';
import logo from '../images/logo.png';
import {
ChevronDown,
+ CirclePlay,
Gauge,
HardDriveDownload,
HardDriveUpload,
+ RefreshCw,
SquareX,
Timer,
Users,
@@ -44,6 +47,7 @@ import { useLocation } from 'react-router-dom';
import { notifications } from '@mantine/notifications';
import { CustomTable, useTable } from '../components/tables/CustomTable';
import useLocalStorage from '../hooks/useLocalStorage';
+import SystemEvents from '../components/SystemEvents';
dayjs.extend(duration);
dayjs.extend(relativeTime);
@@ -1545,6 +1549,7 @@ const ChannelsPage = () => {
display: 'grid',
gap: '1rem',
padding: '10px',
+ paddingBottom: '120px',
gridTemplateColumns: 'repeat(auto-fill, minmax(500px, 1fr))',
}}
>
@@ -1583,6 +1588,23 @@ const ChannelsPage = () => {
})
)}
+
+ {/* System Events Section - Fixed at bottom */}
+
+
+
+
+
);
};