mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Enhancement: Add system event logging and viewer with M3U/EPG endpoint caching
System Event Logging: - Add SystemEvent model with 15 event types tracking channel operations, client connections, M3U/EPG activities, and buffering events - Log detailed metrics for M3U/EPG refresh operations (streams/programs created/updated/deleted) - Track M3U/EPG downloads with client information (IP address, user agent, profile, channel count) - Record channel lifecycle events (start, stop, reconnect) with stream and client details - Monitor client connections/disconnections and buffering events with stream metadata Event Viewer UI: - Add SystemEvents component with real-time updates via WebSocket - Implement pagination, filtering by event type, and configurable auto-refresh - Display events with color-coded badges and type-specific icons - Integrate event viewer into Stats page with modal display - Add event management settings (retention period, refresh rate) M3U/EPG Endpoint Optimizations: - Implement content caching with 5-minute TTL to reduce duplicate processing - Add client-based event deduplication (2-second window) using IP and user agent hashing - Support HEAD requests for efficient preflight checks - Cache streamed EPG responses while maintaining streaming behavior for first request
This commit is contained in:
parent
204a5a0c76
commit
89a23164ff
18 changed files with 1022 additions and 67 deletions
|
|
@ -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('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
|
|
@ -1286,7 +1370,8 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
xml_lines.append(" </channel>")
|
||||
|
||||
# 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 "</tv>\n" # Return streaming response
|
||||
yield "</tv>\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"'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue