mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 09:06:06 +00:00
848 lines
32 KiB
Python
848 lines
32 KiB
Python
import redis
|
|
import logging
|
|
import time
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
import re
|
|
from django.conf import settings
|
|
from redis.exceptions import ConnectionError, TimeoutError
|
|
from django.core.cache import cache
|
|
from asgiref.sync import async_to_sync
|
|
from channels.layers import get_channel_layer
|
|
from django.core.validators import URLValidator
|
|
from django.core.exceptions import ValidationError
|
|
import gc
|
|
|
|
_REDIS_TLS_HINT = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Import the command detector
|
|
from .command_utils import is_management_command
|
|
|
|
def natural_sort_key(text):
|
|
"""
|
|
Convert a string into a list of string and number chunks for natural sorting.
|
|
"PPV 10" becomes ['PPV ', 10] so it sorts correctly with "PPV 2".
|
|
|
|
This function enables natural/alphanumeric sorting where numbers within strings
|
|
are treated as actual numbers rather than strings.
|
|
|
|
Args:
|
|
text (str): The text to convert for sorting
|
|
|
|
Returns:
|
|
list: A list of strings and integers for proper sorting
|
|
|
|
Example:
|
|
>>> sorted(['PPV 1', 'PPV 10', 'PPV 2'], key=natural_sort_key)
|
|
['PPV 1', 'PPV 2', 'PPV 10']
|
|
"""
|
|
def convert(chunk):
|
|
return int(chunk) if chunk.isdigit() else chunk.lower()
|
|
|
|
return [convert(c) for c in re.split('([0-9]+)', text)]
|
|
|
|
class RedisClient:
|
|
_client = None
|
|
_buffer = None
|
|
_pubsub_client = None
|
|
|
|
@classmethod
|
|
def _init_client(cls, decode_responses=True, max_retries=5, retry_interval=1):
|
|
retry_count = 0
|
|
while retry_count < max_retries:
|
|
try:
|
|
# Get connection parameters from settings or environment
|
|
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
|
|
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
|
|
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
|
|
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
|
|
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
|
|
|
|
# Use standardized settings
|
|
socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5)
|
|
socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5)
|
|
health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30)
|
|
socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True)
|
|
retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True)
|
|
|
|
# TLS params from settings (empty dict when TLS is disabled)
|
|
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
|
|
|
|
# Create Redis client with better defaults
|
|
client = redis.Redis(
|
|
host=redis_host,
|
|
port=redis_port,
|
|
db=redis_db,
|
|
password=redis_password if redis_password else None,
|
|
username=redis_user if redis_user else None,
|
|
socket_timeout=socket_timeout,
|
|
socket_connect_timeout=socket_connect_timeout,
|
|
socket_keepalive=socket_keepalive,
|
|
health_check_interval=health_check_interval,
|
|
retry_on_timeout=retry_on_timeout,
|
|
decode_responses=decode_responses,
|
|
**ssl_params
|
|
)
|
|
|
|
# Validate connection with ping
|
|
client.ping()
|
|
|
|
# Disable persistence on first connection - improves performance
|
|
# Only try to disable if not in a read-only environment
|
|
try:
|
|
client.config_set('save', '') # Disable RDB snapshots
|
|
client.config_set('appendonly', 'no') # Disable AOF logging
|
|
|
|
# Disable protected mode when in debug mode
|
|
if os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true':
|
|
client.config_set('protected-mode', 'no') # Disable protected mode in debug
|
|
logger.warning("Redis protected mode disabled for debug environment")
|
|
|
|
logger.trace("Redis persistence disabled for better performance")
|
|
except redis.exceptions.ResponseError as e:
|
|
# Improve error handling for Redis configuration errors
|
|
if "OOM" in str(e):
|
|
logger.error(f"Redis OOM during configuration: {e}")
|
|
# Try to increase maxmemory as an emergency measure
|
|
try:
|
|
client.config_set('maxmemory', '768mb')
|
|
logger.warning("Applied emergency Redis memory increase to 768MB")
|
|
except:
|
|
pass
|
|
else:
|
|
logger.error(f"Redis configuration error: {e}")
|
|
|
|
logger.info(f"Connected to Redis at {redis_host}:{redis_port}/{redis_db}")
|
|
|
|
return client
|
|
|
|
except (ConnectionError, TimeoutError) as e:
|
|
retry_count += 1
|
|
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
|
if retry_count >= max_retries:
|
|
logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}{_tls_hint}")
|
|
return None
|
|
else:
|
|
# Use exponential backoff for retries
|
|
wait_time = retry_interval * (2 ** (retry_count - 1))
|
|
logger.warning(f"Redis connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})")
|
|
time.sleep(wait_time)
|
|
|
|
except Exception as e:
|
|
_tls_hint = ""
|
|
try:
|
|
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
|
except NameError:
|
|
pass
|
|
logger.error(f"Unexpected error connecting to Redis: {e}{_tls_hint}")
|
|
return None
|
|
|
|
return None
|
|
|
|
@classmethod
|
|
def get_client(cls, max_retries=5, retry_interval=1):
|
|
"""Get Redis client optimized for non-binary data (decoded responses)"""
|
|
if cls._client is None:
|
|
cls._client = cls._init_client(decode_responses=True, max_retries=max_retries, retry_interval=retry_interval)
|
|
return cls._client
|
|
|
|
@classmethod
|
|
def get_buffer(cls, max_retries=5, retry_interval=1):
|
|
"""Get Redis client optimized for binary data (no decoding)"""
|
|
if cls._buffer is None:
|
|
cls._buffer = cls._init_client(decode_responses=False, max_retries=max_retries, retry_interval=retry_interval)
|
|
return cls._buffer
|
|
|
|
@classmethod
|
|
def get_pubsub_client(cls, max_retries=5, retry_interval=1):
|
|
"""Get Redis client optimized for PubSub operations"""
|
|
if cls._pubsub_client is None:
|
|
retry_count = 0
|
|
while retry_count < max_retries:
|
|
try:
|
|
# Get connection parameters from settings or environment
|
|
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
|
|
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
|
|
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
|
|
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
|
|
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
|
|
|
|
# Use standardized settings but without socket timeouts for PubSub
|
|
# Important: socket_timeout is None for PubSub operations
|
|
socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5)
|
|
socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True)
|
|
health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30)
|
|
retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True)
|
|
|
|
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
|
|
|
|
# Create Redis client with PubSub-optimized settings - no timeout
|
|
client = redis.Redis(
|
|
host=redis_host,
|
|
port=redis_port,
|
|
db=redis_db,
|
|
password=redis_password if redis_password else None,
|
|
username=redis_user if redis_user else None,
|
|
socket_timeout=None, # Critical: No timeout for PubSub operations
|
|
socket_connect_timeout=socket_connect_timeout,
|
|
socket_keepalive=socket_keepalive,
|
|
health_check_interval=health_check_interval,
|
|
retry_on_timeout=retry_on_timeout,
|
|
decode_responses=True,
|
|
**ssl_params
|
|
)
|
|
|
|
# Validate connection with ping
|
|
client.ping()
|
|
logger.info(f"Connected to Redis for PubSub at {redis_host}:{redis_port}/{redis_db}")
|
|
|
|
# We don't need the keepalive thread anymore since we're using proper PubSub handling
|
|
cls._pubsub_client = client
|
|
break
|
|
|
|
except (ConnectionError, TimeoutError) as e:
|
|
retry_count += 1
|
|
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
|
if retry_count >= max_retries:
|
|
logger.error(f"Failed to connect to Redis for PubSub after {max_retries} attempts: {e}{_tls_hint}")
|
|
return None
|
|
else:
|
|
# Use exponential backoff for retries
|
|
wait_time = retry_interval * (2 ** (retry_count - 1))
|
|
logger.warning(f"Redis PubSub connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})")
|
|
time.sleep(wait_time)
|
|
|
|
except Exception as e:
|
|
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
|
|
logger.error(f"Unexpected error connecting to Redis for PubSub: {e}{_tls_hint}")
|
|
return None
|
|
|
|
return cls._pubsub_client
|
|
|
|
def acquire_task_lock(task_name, id):
|
|
"""Acquire a lock to prevent concurrent task execution."""
|
|
redis_client = RedisClient.get_client()
|
|
lock_id = f"task_lock_{task_name}_{id}"
|
|
|
|
# Use the Redis SET command with NX (only set if not exists) and EX (set expiration)
|
|
lock_acquired = redis_client.set(lock_id, "locked", ex=300, nx=True)
|
|
|
|
if not lock_acquired:
|
|
logger.warning(f"Lock for {task_name} and id={id} already acquired. Task will not proceed.")
|
|
|
|
return lock_acquired
|
|
|
|
def release_task_lock(task_name, id):
|
|
"""Release the lock after task execution."""
|
|
redis_client = RedisClient.get_client()
|
|
lock_id = f"task_lock_{task_name}_{id}"
|
|
|
|
# Remove the lock
|
|
redis_client.delete(lock_id)
|
|
|
|
|
|
class TaskLockRenewer:
|
|
"""Periodically renews a Redis task lock to prevent expiry during long-running tasks.
|
|
|
|
Use as a context manager after acquiring a lock:
|
|
|
|
if acquire_task_lock("my_task", task_id):
|
|
with TaskLockRenewer("my_task", task_id):
|
|
# ... long-running work ...
|
|
release_task_lock("my_task", task_id)
|
|
|
|
A daemon thread extends the lock TTL at regular intervals so that
|
|
slow downloads or large parsing jobs don't lose their lock mid-operation.
|
|
"""
|
|
|
|
def __init__(self, task_name, id, ttl=300, renewal_interval=120):
|
|
self.task_name = task_name
|
|
self.id = id
|
|
self.ttl = ttl
|
|
self.renewal_interval = renewal_interval
|
|
self.lock_id = f"task_lock_{task_name}_{id}"
|
|
self._stop_event = threading.Event()
|
|
self._thread = None
|
|
|
|
def _renew_loop(self):
|
|
"""Background loop that extends the lock TTL until stopped."""
|
|
while not self._stop_event.wait(self.renewal_interval):
|
|
try:
|
|
redis_client = RedisClient.get_client()
|
|
if redis_client.exists(self.lock_id):
|
|
redis_client.expire(self.lock_id, self.ttl)
|
|
logger.debug(
|
|
f"Renewed lock {self.lock_id} TTL to {self.ttl}s"
|
|
)
|
|
else:
|
|
# Lock was deleted externally (e.g. manual release) — stop renewing
|
|
logger.warning(
|
|
f"Lock {self.lock_id} no longer exists, stopping renewal"
|
|
)
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error renewing lock {self.lock_id}: {e}")
|
|
|
|
def start(self):
|
|
"""Start the background renewal thread."""
|
|
self._stop_event.clear()
|
|
self._thread = threading.Thread(
|
|
target=self._renew_loop, daemon=True,
|
|
name=f"lock-renew-{self.task_name}-{self.id}"
|
|
)
|
|
self._thread.start()
|
|
return self
|
|
|
|
def stop(self):
|
|
"""Stop the renewal thread."""
|
|
self._stop_event.set()
|
|
if self._thread and self._thread.is_alive():
|
|
self._thread.join(timeout=5)
|
|
self._thread = None
|
|
|
|
def __enter__(self):
|
|
self.start()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.stop()
|
|
return False
|
|
|
|
|
|
def _gevent_ws_send(group_name, message):
|
|
"""
|
|
Publishes a WebSocket group message synchronously through Redis.
|
|
|
|
gevent's monkey-patching removes select.epoll, which breaks asyncio event
|
|
loop creation in threadpool threads. This function replicates channels_redis
|
|
4.x group_send directly via a sync Redis client, avoiding asyncio entirely.
|
|
|
|
Matches channels_redis 4.x defaults: prefix="asgi", expiry=60,
|
|
group_expiry=86400, msgpack serializer with 12-byte random prefix.
|
|
"""
|
|
try:
|
|
import msgpack
|
|
redis = RedisClient.get_buffer() # decode_responses=False for binary values
|
|
|
|
prefix = "asgi"
|
|
group_expiry = 86400
|
|
channel_expiry = 60
|
|
rand_len = 12
|
|
|
|
group_key = f"{prefix}:group:{group_name}"
|
|
now = time.time()
|
|
|
|
redis.zremrangebyscore(group_key, 0, now - group_expiry)
|
|
raw = redis.zrange(group_key, 0, -1)
|
|
if not raw:
|
|
return
|
|
|
|
channels = [m.decode('utf-8') if isinstance(m, bytes) else m for m in raw]
|
|
|
|
# Group channels by non-local name (prefix up to and including "!") so
|
|
# specific channels sharing a prefix share one Redis sorted-set key.
|
|
nonlocal_map = {}
|
|
for ch in channels:
|
|
pos = ch.find("!")
|
|
nl = ch[:pos + 1] if pos >= 0 else ch
|
|
nonlocal_map.setdefault(nl, []).append(ch)
|
|
|
|
pipe = redis.pipeline(transaction=False)
|
|
for nl, chs in nonlocal_map.items():
|
|
channel_key = prefix + nl
|
|
msg = dict(message)
|
|
msg["__asgi_channel__"] = chs
|
|
serialized = os.urandom(rand_len) + msgpack.packb(msg)
|
|
pipe.zadd(channel_key, {serialized: now})
|
|
pipe.expire(channel_key, channel_expiry)
|
|
pipe.execute()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send WebSocket update: {e}")
|
|
|
|
|
|
def send_websocket_update(group_name, event_type, data, collect_garbage=False):
|
|
"""
|
|
Sends a WebSocket group message.
|
|
|
|
In gevent-patched uWSGI workers, asyncio event loop creation fails because
|
|
monkey-patching removes select.epoll. For those contexts a synchronous Redis
|
|
path is used instead, matching the channels_redis 4.x wire format.
|
|
"""
|
|
channel_layer = get_channel_layer()
|
|
message = {'type': event_type, 'data': data}
|
|
|
|
def _do_send():
|
|
try:
|
|
async_to_sync(channel_layer.group_send)(group_name, message)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send WebSocket update: {e}")
|
|
|
|
try:
|
|
import gevent.monkey
|
|
if gevent.monkey.is_module_patched('threading'):
|
|
import gevent
|
|
gevent.spawn(_gevent_ws_send, group_name, message)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
# Not in a gevent-patched environment (Celery, tests) — use asyncio path
|
|
_do_send()
|
|
|
|
if collect_garbage:
|
|
gc.collect()
|
|
|
|
def send_websocket_event(event, success, data):
|
|
"""Acquire a lock to prevent concurrent task execution."""
|
|
data_payload = {"success": success, "type": event}
|
|
if data:
|
|
# Make a copy to avoid modifying the original
|
|
data_payload.update(data)
|
|
|
|
# Use the standardized function
|
|
send_websocket_update('updates', 'update', data_payload)
|
|
|
|
# Help garbage collection by clearing references
|
|
data_payload = None
|
|
|
|
# Add memory monitoring utilities
|
|
def get_memory_usage():
|
|
"""Returns current memory usage in MB"""
|
|
import psutil
|
|
process = psutil.Process(os.getpid())
|
|
return process.memory_info().rss / (1024 * 1024)
|
|
|
|
def monitor_memory_usage(func):
|
|
"""Decorator to monitor memory usage before and after function execution"""
|
|
def wrapper(*args, **kwargs):
|
|
import gc
|
|
# Force garbage collection before measuring
|
|
gc.collect()
|
|
|
|
# Get initial memory usage
|
|
start_mem = get_memory_usage()
|
|
logger.debug(f"Memory usage before {func.__name__}: {start_mem:.2f} MB")
|
|
|
|
# Call the original function
|
|
result = func(*args, **kwargs)
|
|
|
|
# Force garbage collection before measuring again
|
|
gc.collect()
|
|
|
|
# Get final memory usage
|
|
end_mem = get_memory_usage()
|
|
logger.debug(f"Memory usage after {func.__name__}: {end_mem:.2f} MB (Change: {end_mem - start_mem:.2f} MB)")
|
|
|
|
return result
|
|
return wrapper
|
|
|
|
def cleanup_memory(log_usage=False, force_collection=True):
|
|
"""
|
|
Comprehensive memory cleanup function to reduce memory footprint
|
|
|
|
Args:
|
|
log_usage: Whether to log memory usage before and after cleanup
|
|
force_collection: Whether to force garbage collection
|
|
"""
|
|
logger.trace("Starting memory cleanup django memory cleanup")
|
|
# Skip logging if log level is not set to debug or more verbose (like trace)
|
|
current_log_level = logger.getEffectiveLevel()
|
|
if not current_log_level <= logging.DEBUG:
|
|
log_usage = False
|
|
if log_usage:
|
|
try:
|
|
import psutil
|
|
process = psutil.Process()
|
|
before_mem = process.memory_info().rss / (1024 * 1024)
|
|
logger.debug(f"Memory before cleanup: {before_mem:.2f} MB")
|
|
except (ImportError, Exception) as e:
|
|
logger.debug(f"Error getting memory usage: {e}")
|
|
|
|
# Clear any object caches from Django ORM
|
|
from django.db import connection, reset_queries
|
|
reset_queries()
|
|
|
|
# Force garbage collection
|
|
if force_collection:
|
|
# Run full collection
|
|
gc.collect(generation=2)
|
|
# Clear cyclic references
|
|
gc.collect(generation=0)
|
|
|
|
if log_usage:
|
|
try:
|
|
import psutil
|
|
process = psutil.Process()
|
|
after_mem = process.memory_info().rss / (1024 * 1024)
|
|
logger.debug(f"Memory after cleanup: {after_mem:.2f} MB (change: {after_mem-before_mem:.2f} MB)")
|
|
except (ImportError, Exception):
|
|
pass
|
|
logger.trace("Memory cleanup complete for django")
|
|
|
|
def safe_upload_path(filename: str, base_dir) -> str:
|
|
"""Return a safe absolute path for an uploaded file within base_dir.
|
|
|
|
Strips all directory components from *filename* and verifies the resolved
|
|
path stays inside *base_dir*. Raises ValueError on path traversal attempts.
|
|
"""
|
|
safe_name = Path(filename).name
|
|
base = Path(base_dir).resolve()
|
|
file_path = (base / safe_name).resolve()
|
|
if not file_path.is_relative_to(base):
|
|
raise ValueError("Invalid filename.")
|
|
return str(file_path)
|
|
|
|
|
|
def is_protected_path(file_path):
|
|
"""
|
|
Determine if a file path is in a protected directory that shouldn't be deleted.
|
|
|
|
Args:
|
|
file_path (str): The file path to check
|
|
|
|
Returns:
|
|
bool: True if the path is protected, False otherwise
|
|
"""
|
|
if not file_path:
|
|
return False
|
|
|
|
# List of protected directory prefixes
|
|
protected_dirs = [
|
|
'/data/epgs', # EPG files mapped from host
|
|
'/data/uploads', # User uploaded files
|
|
'/data/m3us' # M3U files mapped from host
|
|
]
|
|
|
|
# Check if the path starts with any protected directory
|
|
for protected_dir in protected_dirs:
|
|
if file_path.startswith(protected_dir):
|
|
return True
|
|
|
|
return False
|
|
|
|
def validate_flexible_url(value):
|
|
"""
|
|
Custom URL validator that accepts URLs with hostnames that aren't FQDNs.
|
|
This allows URLs like "http://hostname/" which
|
|
Django's standard URLValidator rejects.
|
|
"""
|
|
if not value:
|
|
return # Allow empty values since the field is nullable
|
|
|
|
# Create a standard Django URL validator
|
|
url_validator = URLValidator()
|
|
|
|
try:
|
|
# First try the standard validation
|
|
url_validator(value)
|
|
except ValidationError as e:
|
|
# If standard validation fails, check if it's a non-FQDN hostname
|
|
import re
|
|
|
|
# More flexible pattern for non-FQDN hostnames with paths
|
|
# Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234
|
|
# Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value
|
|
# Also supports authentication: rtsp://user:pass@hostname/path
|
|
non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$'
|
|
non_fqdn_match = re.match(non_fqdn_pattern, value)
|
|
|
|
if non_fqdn_match:
|
|
return # Accept non-FQDN hostnames and rtsp/rtp/udp URLs with optional authentication
|
|
|
|
# If it doesn't match our flexible patterns, raise the original error
|
|
raise ValidationError("Enter a valid URL.")
|
|
|
|
def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details):
|
|
try:
|
|
from apps.connect.utils import trigger_event
|
|
from apps.channels.models import Channel, Stream
|
|
from core.models import StreamProfile
|
|
from core.utils import RedisClient
|
|
|
|
payload = dict(details)
|
|
|
|
channel_obj = None
|
|
if channel_id:
|
|
try:
|
|
channel_obj = Channel.objects.get(uuid=channel_id)
|
|
payload["channel_name"] = channel_obj.name
|
|
except Exception:
|
|
payload["channel_name"] = channel_name or None
|
|
else:
|
|
payload["channel_name"] = channel_name or None
|
|
|
|
# Resolve current stream info
|
|
stream_id = details.get("stream_id")
|
|
stream_obj = None
|
|
if not stream_id and channel_obj:
|
|
try:
|
|
redis = RedisClient.get_client()
|
|
sid = redis.get(f"channel_stream:{channel_obj.id}")
|
|
if sid:
|
|
stream_id = int(sid)
|
|
except Exception:
|
|
stream_id = None
|
|
|
|
if stream_id:
|
|
try:
|
|
stream_obj = Stream.objects.get(id=stream_id)
|
|
except Exception:
|
|
stream_obj = None
|
|
|
|
# Populate stream details
|
|
payload["stream_name"] = getattr(stream_obj, "name", None)
|
|
payload["stream_url"] = getattr(stream_obj, "url", None)
|
|
|
|
# Channel URL: use stream URL as best-effort
|
|
payload["channel_url"] = payload.get("stream_url")
|
|
|
|
# Provider name from M3U account
|
|
provider_name = None
|
|
try:
|
|
if stream_obj and stream_obj.m3u_account:
|
|
provider_name = stream_obj.m3u_account.name
|
|
except Exception:
|
|
provider_name = None
|
|
payload["provider_name"] = provider_name
|
|
|
|
# Profile used
|
|
profile_used = None
|
|
try:
|
|
if stream_id:
|
|
redis = RedisClient.get_client()
|
|
pid = redis.get(f"stream_profile:{stream_id}")
|
|
if pid:
|
|
profile = StreamProfile.objects.filter(id=int(pid)).first()
|
|
profile_used = profile.name if profile else None
|
|
except Exception:
|
|
profile_used = None
|
|
|
|
payload["profile_used"] = profile_used
|
|
|
|
# remove empty keys
|
|
for k in list(payload.keys()):
|
|
if not payload[k]:
|
|
del payload[k]
|
|
|
|
trigger_event(event_type, payload)
|
|
|
|
except Exception as e:
|
|
# Don't fail main path if connect dispatch fails
|
|
pass
|
|
|
|
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
|
|
)
|
|
|
|
# Trigger connect integrations for specific events
|
|
dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details)
|
|
|
|
# Get max events from settings (default 100)
|
|
try:
|
|
from .models import CoreSettings
|
|
system_settings = CoreSettings.objects.filter(key='system_settings').first()
|
|
if system_settings and isinstance(system_settings.value, dict):
|
|
max_events = int(system_settings.value.get('max_system_events', 100))
|
|
else:
|
|
max_events = 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}")
|
|
|
|
|
|
def _send_async(channel_layer, group, message):
|
|
"""Send a channel layer group message without blocking the gevent hub."""
|
|
def _do():
|
|
try:
|
|
async_to_sync(channel_layer.group_send)(group, message)
|
|
except Exception as e:
|
|
logger.warning(f"Failed WebSocket group_send to '{group}': {e}")
|
|
|
|
try:
|
|
import gevent.monkey
|
|
if gevent.monkey.is_module_patched("threading"):
|
|
import gevent
|
|
gevent.spawn(_gevent_ws_send, group, message)
|
|
return
|
|
except Exception:
|
|
pass
|
|
_do()
|
|
|
|
|
|
def send_websocket_notification(notification):
|
|
"""
|
|
Send a system notification to all connected WebSocket clients.
|
|
|
|
Args:
|
|
notification: A SystemNotification model instance or dict with notification data
|
|
|
|
Example:
|
|
from core.models import SystemNotification
|
|
notification = SystemNotification.create_version_notification('0.19.0', 'https://...')
|
|
send_websocket_notification(notification)
|
|
"""
|
|
try:
|
|
channel_layer = get_channel_layer()
|
|
|
|
# Convert model instance to dict if needed
|
|
if hasattr(notification, 'id'):
|
|
notification_data = {
|
|
'id': notification.id,
|
|
'notification_key': notification.notification_key,
|
|
'notification_type': notification.notification_type,
|
|
'priority': notification.priority,
|
|
'title': notification.title,
|
|
'message': notification.message,
|
|
'action_data': notification.action_data,
|
|
'is_active': notification.is_active,
|
|
'admin_only': notification.admin_only,
|
|
'created_at': notification.created_at.isoformat() if notification.created_at else None,
|
|
}
|
|
else:
|
|
notification_data = notification
|
|
|
|
_send_async(
|
|
channel_layer,
|
|
'updates',
|
|
{
|
|
'type': 'update',
|
|
'data': {
|
|
'type': 'system_notification',
|
|
'notification': notification_data,
|
|
}
|
|
}
|
|
)
|
|
logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send WebSocket notification: {e}")
|
|
|
|
|
|
def get_host_and_port(request):
|
|
"""
|
|
Returns (host, port) for building absolute URIs.
|
|
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
|
|
- Falls back to Host header.
|
|
- Returns None for port if using standard ports (80/443) to omit from URLs.
|
|
- In dev, uses 5656 as a guess if port cannot be determined.
|
|
"""
|
|
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
|
standard_port = "443" if scheme == "https" else "80"
|
|
|
|
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
|
|
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
|
|
if xfh:
|
|
if ":" in xfh:
|
|
host, port = xfh.split(":", 1)
|
|
if port == standard_port:
|
|
return host, None
|
|
return host, port
|
|
else:
|
|
host = xfh
|
|
|
|
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
|
if port:
|
|
return host, None if port == standard_port else port
|
|
if request.META.get("HTTP_X_FORWARDED_PROTO"):
|
|
return host, None
|
|
|
|
# 2. Try Host header
|
|
raw_host = request.get_host()
|
|
if ":" in raw_host:
|
|
host, port = raw_host.split(":", 1)
|
|
return host, None if port == standard_port else port
|
|
else:
|
|
host = raw_host
|
|
|
|
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
|
|
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
|
if port:
|
|
return host, None if port == standard_port else port
|
|
|
|
# 4. Behind a reverse proxy with no port info - assume standard port
|
|
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
|
|
return host, None
|
|
|
|
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
|
|
port = request.META.get("SERVER_PORT")
|
|
if port:
|
|
return host, None if port == standard_port else port
|
|
|
|
# 6. Dev fallback
|
|
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
|
|
return host, "5656"
|
|
|
|
# 7. Final fallback: assume standard port for scheme
|
|
return host, None
|
|
|
|
|
|
def build_absolute_uri_with_port(request, path):
|
|
"""
|
|
Build an absolute URI with optional port.
|
|
Port is omitted from URL if None (standard port for scheme).
|
|
"""
|
|
host, port = get_host_and_port(request)
|
|
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
|
if port:
|
|
return f"{scheme}://{host}:{port}{path}"
|
|
return f"{scheme}://{host}{path}"
|
|
|
|
|
|
def send_notification_dismissed(notification_key):
|
|
"""
|
|
Notify all connected clients that a notification was dismissed.
|
|
Useful for syncing dismissal state across multiple browser tabs/sessions.
|
|
|
|
Args:
|
|
notification_key: The unique key of the dismissed notification
|
|
"""
|
|
try:
|
|
channel_layer = get_channel_layer()
|
|
|
|
_send_async(
|
|
channel_layer,
|
|
'updates',
|
|
{
|
|
'type': 'update',
|
|
'data': {
|
|
'type': 'notification_dismissed',
|
|
'notification_key': notification_key,
|
|
}
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send notification dismissed event: {e}")
|