Proyecto LCX Dispatcharr multicuenta
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# dispatcharr/__init__.py
|
||||
|
||||
# For Celery:
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
ASGI config for dispatcharr project.
|
||||
"""
|
||||
import os
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Utilities for managing app initialization across multiple processes."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import psutil
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_worker_process():
|
||||
"""Check if this process is a worker spawned by uwsgi/gunicorn."""
|
||||
try:
|
||||
parent = psutil.Process(os.getppid())
|
||||
parent_name = parent.name()
|
||||
return parent_name in ['uwsgi', 'gunicorn']
|
||||
except Exception:
|
||||
# If we can't determine, assume it's not a worker (safe default)
|
||||
return False
|
||||
|
||||
|
||||
def should_skip_initialization():
|
||||
"""
|
||||
Determine if app initialization should be skipped in this process.
|
||||
|
||||
Returns True if:
|
||||
- A management command is being run (migrate, celery, shell, etc.)
|
||||
- The development server (daphne) is running
|
||||
- This is a worker process (not the master)
|
||||
|
||||
This prevents redundant initialization across multiple worker processes.
|
||||
"""
|
||||
# Skip management commands and background services
|
||||
skip_commands = [
|
||||
'celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell',
|
||||
'collectstatic', 'loaddata'
|
||||
]
|
||||
if any(cmd in sys.argv for cmd in skip_commands):
|
||||
logger.debug(f"Skipping initialization due to command: {sys.argv}")
|
||||
return True
|
||||
|
||||
# Skip daphne development server (single process, no need to guard)
|
||||
if 'daphne' in sys.argv[0] if sys.argv else False:
|
||||
logger.debug(f"Skipping initialization in daphne development server. Command: {sys.argv}")
|
||||
return True
|
||||
|
||||
# Skip if this is a worker process spawned by uwsgi/gunicorn
|
||||
if _is_worker_process():
|
||||
logger.debug(f"Skipping initialization in worker process. Command: {sys.argv}")
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,17 @@
|
||||
import django
|
||||
import os
|
||||
from django.core.asgi import get_asgi_application
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
import dispatcharr.routing
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dispatcharr.settings")
|
||||
django.setup()
|
||||
|
||||
from .jwt_ws_auth import JWTAuthMiddleware
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": JWTAuthMiddleware(
|
||||
URLRouter(dispatcharr.routing.websocket_urlpatterns)
|
||||
),
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
# dispatcharr/celery.py
|
||||
import os
|
||||
from celery import Celery
|
||||
import logging
|
||||
from celery.signals import task_postrun, worker_ready
|
||||
|
||||
# Initialize with defaults before Django settings are loaded
|
||||
DEFAULT_LOG_LEVEL = 'DEBUG'
|
||||
|
||||
# Try multiple sources for log level in order of preference
|
||||
def get_effective_log_level():
|
||||
# 1. Direct environment variable
|
||||
env_level = os.environ.get('DISPATCHARR_LOG_LEVEL', '').upper()
|
||||
if env_level and not env_level.startswith('$(') and not env_level.startswith('%('):
|
||||
return env_level
|
||||
|
||||
# 2. Check temp file that may have been created by settings.py
|
||||
try:
|
||||
if os.path.exists('/tmp/dispatcharr_log_level'):
|
||||
with open('/tmp/dispatcharr_log_level', 'r') as f:
|
||||
file_level = f.read().strip().upper()
|
||||
if file_level:
|
||||
return file_level
|
||||
except:
|
||||
pass
|
||||
|
||||
# 3. Fallback to default
|
||||
return DEFAULT_LOG_LEVEL
|
||||
|
||||
# Get effective log level before Django loads
|
||||
effective_log_level = get_effective_log_level()
|
||||
print(f"Celery using effective log level: {effective_log_level}")
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
|
||||
app = Celery("dispatcharr")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
|
||||
# Use environment variable for log level with fallback to INFO
|
||||
CELERY_LOG_LEVEL = os.environ.get('DISPATCHARR_LOG_LEVEL', 'INFO').upper()
|
||||
print(f"Celery using log level from environment: {CELERY_LOG_LEVEL}")
|
||||
|
||||
# Configure Celery logging
|
||||
app.conf.update(
|
||||
worker_log_level=effective_log_level,
|
||||
worker_log_format='%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
beat_log_level=effective_log_level,
|
||||
worker_hijack_root_logger=False,
|
||||
worker_task_log_format='%(asctime)s %(levelname)s %(task_name)s: %(message)s',
|
||||
)
|
||||
|
||||
# Add memory cleanup after task completion
|
||||
@task_postrun.connect # Use the imported signal
|
||||
def cleanup_task_memory(**kwargs):
|
||||
"""Clean up memory and database connections after each task completes"""
|
||||
from django.db import connection
|
||||
|
||||
# Get task name from kwargs
|
||||
task_name = kwargs.get('task').name if kwargs.get('task') else ''
|
||||
|
||||
# Close database connection for this Celery worker process
|
||||
try:
|
||||
connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Only run memory cleanup for memory-intensive tasks
|
||||
memory_intensive_tasks = [
|
||||
'apps.m3u.tasks.refresh_single_m3u_account',
|
||||
'apps.m3u.tasks.refresh_m3u_accounts',
|
||||
'apps.m3u.tasks.process_m3u_batch',
|
||||
'apps.m3u.tasks.process_xc_category',
|
||||
'apps.m3u.tasks.sync_auto_channels',
|
||||
'apps.epg.tasks.refresh_epg_data',
|
||||
'apps.epg.tasks.refresh_all_epg_data',
|
||||
'apps.epg.tasks.parse_programs_for_source',
|
||||
'apps.epg.tasks.parse_programs_for_tvg_id',
|
||||
'apps.channels.tasks.match_epg_channels',
|
||||
'core.tasks.rehash_streams'
|
||||
]
|
||||
|
||||
# Check if this is a memory-intensive task
|
||||
if task_name in memory_intensive_tasks:
|
||||
# Import cleanup_memory function
|
||||
from core.utils import cleanup_memory
|
||||
|
||||
# Use the comprehensive cleanup function
|
||||
cleanup_memory(log_usage=True, force_collection=True)
|
||||
|
||||
# Log memory usage if psutil is installed
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
if hasattr(process, 'memory_info'):
|
||||
mem = process.memory_info().rss / (1024 * 1024)
|
||||
print(f"Memory usage after {task_name}: {mem:.2f} MB")
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
else:
|
||||
# For non-intensive tasks, just log but don't force cleanup
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
if hasattr(process, 'memory_info'):
|
||||
mem = process.memory_info().rss / (1024 * 1024)
|
||||
if mem > 500: # Only log if using more than 500MB
|
||||
print(f"High memory usage detected in {task_name}: {mem:.2f} MB")
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
|
||||
@app.on_after_configure.connect
|
||||
def setup_celery_logging(**kwargs):
|
||||
# Use our directly determined log level
|
||||
log_level = effective_log_level
|
||||
print(f"Celery configuring loggers with level: {log_level}")
|
||||
|
||||
# Get the specific loggers that output potentially noisy messages
|
||||
for logger_name in ['celery.app.trace', 'celery.beat', 'celery.worker.strategy', 'celery.beat.Scheduler', 'celery.pool']:
|
||||
logger = logging.getLogger(logger_name)
|
||||
|
||||
# Remove any existing filters first (in case this runs multiple times)
|
||||
for filter in logger.filters[:]:
|
||||
if hasattr(filter, '__class__') and filter.__class__.__name__ == 'SuppressFilter':
|
||||
logger.removeFilter(filter)
|
||||
|
||||
# Add filtering for both INFO and DEBUG levels - only TRACE will show full logging
|
||||
if log_level not in ['TRACE']:
|
||||
# Add a custom filter to completely filter out the repetitive messages
|
||||
class SuppressFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
# Return False to completely suppress these specific patterns
|
||||
if (
|
||||
"succeeded in" in getattr(record, 'msg', '') or
|
||||
"Scheduler: Sending due task" in getattr(record, 'msg', '') or
|
||||
"received" in getattr(record, 'msg', '') or
|
||||
(logger_name == 'celery.pool' and "Apply" in getattr(record, 'msg', ''))
|
||||
):
|
||||
return False # Don't log these messages at all
|
||||
return True # Log all other messages
|
||||
|
||||
# Add the filter to each logger
|
||||
logger.addFilter(SuppressFilter())
|
||||
|
||||
# Set all Celery loggers to the configured level
|
||||
# This ensures they respect TRACE/DEBUG when set
|
||||
try:
|
||||
numeric_level = getattr(logging, log_level)
|
||||
logger.setLevel(numeric_level)
|
||||
except (AttributeError, TypeError):
|
||||
# If the log level string is invalid, default to DEBUG
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def on_worker_ready(**kwargs):
|
||||
"""Tasks to run once the worker is fully connected and ready."""
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
recover_recordings_on_startup.delay()
|
||||
|
||||
from core.tasks import check_for_version_update
|
||||
check_for_version_update.delay()
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
import regex, logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MyWebSocketConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.room_name = "updates"
|
||||
|
||||
user = self.scope["user"]
|
||||
if not user.is_authenticated:
|
||||
await self.close()
|
||||
return
|
||||
|
||||
try:
|
||||
await self.accept()
|
||||
await self.channel_layer.group_add(self.room_name, self.channel_name)
|
||||
# Send a connection confirmation to the client with consistent format
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'connection_established',
|
||||
'data': {
|
||||
'success': True,
|
||||
'message': 'WebSocket connection established successfully'
|
||||
}
|
||||
}))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in WebSocket connect: {str(e)}")
|
||||
# If an error occurs during connection, attempt to close
|
||||
try:
|
||||
await self.close(code=1011) # Internal server error
|
||||
except:
|
||||
pass
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
try:
|
||||
await self.channel_layer.group_discard(self.room_name, self.channel_name)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in WebSocket disconnect: {str(e)}")
|
||||
|
||||
async def receive(self, text_data):
|
||||
data = json.loads(text_data)
|
||||
|
||||
if data["type"] == "m3u_profile_test":
|
||||
from apps.proxy.ts_proxy.url_utils import transform_url
|
||||
|
||||
def replace_with_mark(match):
|
||||
# Wrap the match in <mark> tags
|
||||
return f"<mark>{match.group(0)}</mark>"
|
||||
|
||||
# Apply the transformation using the replace_with_mark function
|
||||
try:
|
||||
search_preview = regex.sub(data["search"], replace_with_mark, data["url"])
|
||||
except Exception as e:
|
||||
search_preview = data["url"]
|
||||
logger.error(f"Failed to generate replace preview: {e}")
|
||||
|
||||
result = transform_url(data["url"], data["search"], data["replace"])
|
||||
await self.send(text_data=json.dumps({
|
||||
"data": {
|
||||
'type': 'm3u_profile_test',
|
||||
'search_preview': search_preview,
|
||||
'result': result,
|
||||
}
|
||||
}))
|
||||
|
||||
async def update(self, event):
|
||||
await self.send(text_data=json.dumps(event))
|
||||
@@ -0,0 +1,47 @@
|
||||
from urllib.parse import parse_qs
|
||||
from channels.middleware import BaseMiddleware
|
||||
from channels.db import database_sync_to_async
|
||||
from rest_framework_simplejwt.tokens import UntypedToken
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
@database_sync_to_async
|
||||
def get_user(validated_token):
|
||||
try:
|
||||
jwt_auth = JWTAuthentication()
|
||||
user = jwt_auth.get_user(validated_token)
|
||||
return user
|
||||
except User.DoesNotExist:
|
||||
logger.warning(f"User from token does not exist. User ID: {validated_token.get('user_id', 'unknown')}")
|
||||
return AnonymousUser()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user from token: {str(e)}")
|
||||
return AnonymousUser()
|
||||
|
||||
class JWTAuthMiddleware(BaseMiddleware):
|
||||
async def __call__(self, scope, receive, send):
|
||||
try:
|
||||
# Extract the token from the query string
|
||||
query_string = parse_qs(scope["query_string"].decode())
|
||||
token = query_string.get("token", [None])[0]
|
||||
|
||||
if token is not None:
|
||||
try:
|
||||
validated_token = JWTAuthentication().get_validated_token(token)
|
||||
scope["user"] = await get_user(validated_token)
|
||||
except (InvalidToken, TokenError) as e:
|
||||
logger.warning(f"Invalid token: {str(e)}")
|
||||
scope["user"] = AnonymousUser()
|
||||
else:
|
||||
scope["user"] = AnonymousUser()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in JWT authentication: {str(e)}")
|
||||
scope["user"] = AnonymousUser()
|
||||
|
||||
return await super().__call__(scope, receive, send)
|
||||
@@ -0,0 +1,126 @@
|
||||
# dispatcharr/persistent_lock.py
|
||||
import uuid
|
||||
import redis
|
||||
|
||||
class PersistentLock:
|
||||
"""
|
||||
A persistent, auto-expiring lock that uses Redis.
|
||||
|
||||
Usage:
|
||||
1. Instantiate with a Redis client, a unique lock key (e.g. "lock:account:123"),
|
||||
and an optional timeout (in seconds).
|
||||
2. Call acquire() to try to obtain the lock.
|
||||
3. Optionally, periodically call refresh() to extend the lock's lifetime.
|
||||
4. When finished, call release() to free the lock.
|
||||
"""
|
||||
def __init__(self, redis_client: redis.Redis, lock_key: str, lock_timeout: int = 120):
|
||||
"""
|
||||
Initialize the lock.
|
||||
|
||||
:param redis_client: An instance of redis.Redis.
|
||||
:param lock_key: The unique key for the lock.
|
||||
:param lock_timeout: Time-to-live for the lock in seconds.
|
||||
"""
|
||||
self.redis_client = redis_client
|
||||
self.lock_key = lock_key
|
||||
self.lock_timeout = lock_timeout
|
||||
self.lock_token = None
|
||||
self.has_lock = False
|
||||
|
||||
def has_lock(self) -> bool:
|
||||
return self.has_lock
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""
|
||||
Attempt to acquire the lock. Returns True if successful.
|
||||
"""
|
||||
self.lock_token = str(uuid.uuid4())
|
||||
# Set the lock with NX (only if not exists) and EX (expire time)
|
||||
result = self.redis_client.set(self.lock_key, self.lock_token, nx=True, ex=self.lock_timeout)
|
||||
if result is not None:
|
||||
self.has_lock = True
|
||||
|
||||
return result is not None
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""
|
||||
Refresh the lock's expiration time if this instance owns the lock.
|
||||
Returns True if the expiration was successfully extended.
|
||||
"""
|
||||
current_value = self.redis_client.get(self.lock_key)
|
||||
if current_value and current_value == self.lock_token:
|
||||
self.redis_client.expire(self.lock_key, self.lock_timeout)
|
||||
self.has_lock = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def release(self) -> bool:
|
||||
"""
|
||||
Release the lock only if owned by this instance.
|
||||
Returns True if the lock was successfully released.
|
||||
"""
|
||||
# Use a Lua script for atomicity: only delete if the token matches.
|
||||
lua_script = """
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
release_lock = self.redis_client.register_script(lua_script)
|
||||
result = release_lock(keys=[self.lock_key], args=[self.lock_token])
|
||||
return result == 1
|
||||
|
||||
# Example usage (for testing purposes only):
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
# Connect to Redis using environment variables; adjust connection parameters as needed.
|
||||
redis_host = os.environ.get("REDIS_HOST", "localhost")
|
||||
redis_port = int(os.environ.get("REDIS_PORT", 6379))
|
||||
redis_db = int(os.environ.get("REDIS_DB", 0))
|
||||
redis_password = os.environ.get("REDIS_PASSWORD", "")
|
||||
redis_user = os.environ.get("REDIS_USER", "")
|
||||
ssl_kwargs = {}
|
||||
if os.environ.get("REDIS_SSL", "false").lower() == "true":
|
||||
import ssl as _ssl
|
||||
ssl_kwargs["ssl"] = True
|
||||
ssl_kwargs["ssl_cert_reqs"] = (
|
||||
_ssl.CERT_REQUIRED if os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true"
|
||||
else _ssl.CERT_NONE
|
||||
)
|
||||
for env_var, key in [
|
||||
("REDIS_SSL_CA_CERT", "ssl_ca_certs"),
|
||||
("REDIS_SSL_CERT", "ssl_certfile"),
|
||||
("REDIS_SSL_KEY", "ssl_keyfile"),
|
||||
]:
|
||||
path = os.environ.get(env_var, "")
|
||||
if path:
|
||||
if not os.path.isfile(path):
|
||||
print(f"Redis TLS: {env_var}={path!r} — file not found.")
|
||||
sys.exit(1)
|
||||
ssl_kwargs[key] = path
|
||||
|
||||
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,
|
||||
**ssl_kwargs
|
||||
)
|
||||
lock = PersistentLock(client, "lock:example_account", lock_timeout=120)
|
||||
|
||||
if lock.acquire():
|
||||
print("Lock acquired successfully!")
|
||||
# Do work here...
|
||||
# Optionally refresh the lock periodically:
|
||||
if lock.refresh():
|
||||
print("Lock refreshed.")
|
||||
# Finally, release the lock:
|
||||
if lock.release():
|
||||
print("Lock released.")
|
||||
else:
|
||||
print("Failed to release lock.")
|
||||
else:
|
||||
print("Failed to acquire lock.")
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
from dispatcharr.consumers import MyWebSocketConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/", MyWebSocketConsumer.as_asgi()),
|
||||
]
|
||||
@@ -0,0 +1,570 @@
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from urllib.parse import quote_plus
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
def _validate_tls_cert_paths(paths, service_name):
|
||||
"""Validate that configured TLS certificate file paths exist on disk.
|
||||
|
||||
Raises ImproperlyConfigured with a clear message identifying the
|
||||
service and missing file so operators can fix their environment.
|
||||
"""
|
||||
for env_var, file_path in paths:
|
||||
if file_path and not Path(file_path).is_file():
|
||||
raise ImproperlyConfigured(
|
||||
f"{service_name} TLS: {env_var}={file_path!r} — file not found. "
|
||||
f"Check that the certificate file exists and the volume is mounted correctly."
|
||||
)
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")
|
||||
REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379))
|
||||
REDIS_DB = os.environ.get("REDIS_DB", "0")
|
||||
REDIS_USER = os.environ.get("REDIS_USER", "")
|
||||
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
|
||||
|
||||
# Redis TLS configuration
|
||||
REDIS_SSL = os.environ.get("REDIS_SSL", "false").lower() == "true"
|
||||
REDIS_SSL_VERIFY = os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true"
|
||||
REDIS_SSL_CA_CERT = os.environ.get("REDIS_SSL_CA_CERT", "")
|
||||
REDIS_SSL_CERT = os.environ.get("REDIS_SSL_CERT", "")
|
||||
REDIS_SSL_KEY = os.environ.get("REDIS_SSL_KEY", "")
|
||||
|
||||
# Reusable dict of SSL kwargs for redis.Redis() constructors
|
||||
REDIS_SSL_PARAMS = {}
|
||||
if REDIS_SSL:
|
||||
_validate_tls_cert_paths([
|
||||
("REDIS_SSL_CA_CERT", REDIS_SSL_CA_CERT),
|
||||
("REDIS_SSL_CERT", REDIS_SSL_CERT),
|
||||
("REDIS_SSL_KEY", REDIS_SSL_KEY),
|
||||
], "Redis")
|
||||
|
||||
REDIS_SSL_PARAMS["ssl"] = True
|
||||
REDIS_SSL_PARAMS["ssl_cert_reqs"] = ssl.CERT_REQUIRED if REDIS_SSL_VERIFY else ssl.CERT_NONE
|
||||
if REDIS_SSL_CA_CERT:
|
||||
REDIS_SSL_PARAMS["ssl_ca_certs"] = REDIS_SSL_CA_CERT
|
||||
if REDIS_SSL_CERT:
|
||||
REDIS_SSL_PARAMS["ssl_certfile"] = REDIS_SSL_CERT
|
||||
if REDIS_SSL_KEY:
|
||||
REDIS_SSL_PARAMS["ssl_keyfile"] = REDIS_SSL_KEY
|
||||
|
||||
_mtls = "enabled" if REDIS_SSL_CERT and REDIS_SSL_KEY else "disabled"
|
||||
_verify = "on" if REDIS_SSL_VERIFY else "off"
|
||||
print(f"Redis TLS: enabled (verify={_verify}, mTLS={_mtls})")
|
||||
else:
|
||||
print("Redis TLS: disabled")
|
||||
|
||||
# Set DEBUG to True for development, False for production
|
||||
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
|
||||
DEBUG = True
|
||||
else:
|
||||
DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"apps.api",
|
||||
"apps.accounts",
|
||||
"apps.backups.apps.BackupsConfig",
|
||||
"apps.channels.apps.ChannelsConfig",
|
||||
"apps.dashboard",
|
||||
"apps.epg",
|
||||
"apps.hdhr",
|
||||
"apps.m3u",
|
||||
"apps.output",
|
||||
"apps.proxy.apps.ProxyConfig",
|
||||
"apps.proxy.ts_proxy",
|
||||
"apps.vod.apps.VODConfig",
|
||||
"apps.connect.apps.ConnectConfig",
|
||||
"core",
|
||||
"daphne",
|
||||
"drf_spectacular",
|
||||
"channels",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"django_filters",
|
||||
"django_celery_beat",
|
||||
"apps.plugins",
|
||||
]
|
||||
|
||||
# EPG Processing optimization settings
|
||||
EPG_BATCH_SIZE = 1000 # Number of records to process in a batch
|
||||
EPG_MEMORY_LIMIT = 512 # Memory limit in MB before forcing garbage collection
|
||||
EPG_ENABLE_MEMORY_MONITORING = True # Whether to monitor memory usage during processing
|
||||
|
||||
# XtreamCodes Rate Limiting Settings
|
||||
# Delay between profile authentications when refreshing multiple profiles
|
||||
# This prevents providers from temporarily banning users with many profiles
|
||||
XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5')) # seconds between profile refreshes
|
||||
|
||||
# Database optimization settings
|
||||
DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries
|
||||
DATABASE_CONN_MAX_AGE = (
|
||||
60 # Connection max age in seconds, helps with frequent reconnects
|
||||
)
|
||||
|
||||
# Disable atomic requests for performance-sensitive views
|
||||
ATOMIC_REQUESTS = False
|
||||
|
||||
# Cache settings - add caching for EPG operations
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "dispatcharr-epg-cache",
|
||||
"TIMEOUT": 3600, # 1 hour cache timeout
|
||||
"OPTIONS": {
|
||||
"MAX_ENTRIES": 10000,
|
||||
"CULL_FREQUENCY": 3, # Purge 1/3 of entries when max is reached
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Timeouts for external connections
|
||||
REQUESTS_TIMEOUT = 30 # Seconds for external API requests
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
]
|
||||
|
||||
|
||||
ROOT_URLCONF = "dispatcharr.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "frontend/dist"), BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "dispatcharr.wsgi.application"
|
||||
ASGI_APPLICATION = "dispatcharr.asgi.application"
|
||||
|
||||
_redis_scheme = "rediss" if REDIS_SSL else "redis"
|
||||
|
||||
# URL-encoded auth string shared by CHANNEL_LAYERS and Celery broker URLs
|
||||
if REDIS_PASSWORD:
|
||||
_encoded_password = quote_plus(REDIS_PASSWORD)
|
||||
if REDIS_USER:
|
||||
_redis_auth = f"{quote_plus(REDIS_USER)}:{_encoded_password}@"
|
||||
else:
|
||||
_redis_auth = f":{_encoded_password}@"
|
||||
else:
|
||||
_redis_auth = ""
|
||||
|
||||
_channels_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
|
||||
# channels_redis accepts either a URL string or a dict with "address" + kwargs.
|
||||
# When TLS is enabled, pass SSL params alongside the URL so the connection pool
|
||||
# uses the correct CA cert and verification settings.
|
||||
if REDIS_SSL:
|
||||
# Filter out "ssl" key — the rediss:// scheme already enables SSL.
|
||||
# Passing ssl=True as a kwarg to aioredis from_url causes an error.
|
||||
_channels_ssl = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"}
|
||||
_channels_host = {"address": _channels_redis_url, **_channels_ssl}
|
||||
else:
|
||||
_channels_host = _channels_redis_url
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [_channels_host],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# PostgreSQL TLS configuration (defined before DATABASES for module-level access)
|
||||
POSTGRES_SSL = os.environ.get("POSTGRES_SSL", "false").lower() == "true"
|
||||
POSTGRES_SSL_MODE = os.environ.get("POSTGRES_SSL_MODE", "verify-full")
|
||||
POSTGRES_SSL_CA_CERT = os.environ.get("POSTGRES_SSL_CA_CERT", "")
|
||||
POSTGRES_SSL_CERT = os.environ.get("POSTGRES_SSL_CERT", "")
|
||||
POSTGRES_SSL_KEY = os.environ.get("POSTGRES_SSL_KEY", "")
|
||||
|
||||
if os.getenv("DB_ENGINE", None) == "sqlite":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": "/data/dispatcharr.db",
|
||||
}
|
||||
}
|
||||
else:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("POSTGRES_DB", "dispatcharr"),
|
||||
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||
"PORT": int(os.environ.get("POSTGRES_PORT", 5432)),
|
||||
"CONN_MAX_AGE": DATABASE_CONN_MAX_AGE,
|
||||
}
|
||||
}
|
||||
|
||||
if POSTGRES_SSL:
|
||||
_validate_tls_cert_paths([
|
||||
("POSTGRES_SSL_CA_CERT", POSTGRES_SSL_CA_CERT),
|
||||
("POSTGRES_SSL_CERT", POSTGRES_SSL_CERT),
|
||||
("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY),
|
||||
], "PostgreSQL")
|
||||
|
||||
DATABASES["default"]["OPTIONS"] = {
|
||||
"sslmode": POSTGRES_SSL_MODE,
|
||||
}
|
||||
if POSTGRES_SSL_CA_CERT:
|
||||
DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT
|
||||
if POSTGRES_SSL_CERT:
|
||||
DATABASES["default"]["OPTIONS"]["sslcert"] = POSTGRES_SSL_CERT
|
||||
if POSTGRES_SSL_KEY:
|
||||
DATABASES["default"]["OPTIONS"]["sslkey"] = POSTGRES_SSL_KEY
|
||||
|
||||
_mtls = "enabled" if POSTGRES_SSL_CERT and POSTGRES_SSL_KEY else "disabled"
|
||||
print(f"PostgreSQL TLS: enabled (sslmode={POSTGRES_SSL_MODE}, mTLS={_mtls})")
|
||||
else:
|
||||
print("PostgreSQL TLS: disabled")
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
"rest_framework.renderers.BrowsableAPIRenderer",
|
||||
],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||
"apps.accounts.authentication.ApiKeyAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"apps.accounts.permissions.IsAdmin",
|
||||
],
|
||||
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
|
||||
"DEFAULT_THROTTLE_CLASSES": [],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"login": "1000/minute",
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Dispatcharr API",
|
||||
"DESCRIPTION": "API documentation for Dispatcharr",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
}
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "UTC"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = BASE_DIR / "static" # Directory where static files will be collected
|
||||
|
||||
# Adjust STATICFILES_DIRS to include the paths to the directories that contain your static files.
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "frontend/dist"), # React build static files
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
_default_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
|
||||
# Celery/Kombu require SSL parameters in the URL query string because
|
||||
# internal URL parsing can overwrite the CELERY_BROKER_USE_SSL dict.
|
||||
if REDIS_SSL:
|
||||
_celery_ssl_params = [
|
||||
f"ssl_cert_reqs={'CERT_REQUIRED' if REDIS_SSL_VERIFY else 'CERT_NONE'}",
|
||||
]
|
||||
if REDIS_SSL_CA_CERT:
|
||||
_celery_ssl_params.append(f"ssl_ca_certs={REDIS_SSL_CA_CERT}")
|
||||
if REDIS_SSL_CERT:
|
||||
_celery_ssl_params.append(f"ssl_certfile={REDIS_SSL_CERT}")
|
||||
if REDIS_SSL_KEY:
|
||||
_celery_ssl_params.append(f"ssl_keyfile={REDIS_SSL_KEY}")
|
||||
_default_celery_url = f"{_default_redis_url}?{'&'.join(_celery_ssl_params)}"
|
||||
else:
|
||||
_default_celery_url = _default_redis_url
|
||||
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_celery_url)
|
||||
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
|
||||
|
||||
# Validate that URL overrides don't conflict with TLS settings
|
||||
for _url_var, _url_val in [
|
||||
("CELERY_BROKER_URL", CELERY_BROKER_URL),
|
||||
("CELERY_RESULT_BACKEND", CELERY_RESULT_BACKEND),
|
||||
]:
|
||||
_is_override = os.environ.get(_url_var) is not None
|
||||
if not _is_override:
|
||||
continue
|
||||
_url_is_ssl = _url_val.startswith("rediss://")
|
||||
if REDIS_SSL and not _url_is_ssl:
|
||||
raise ImproperlyConfigured(
|
||||
f"REDIS_SSL is enabled but {_url_var} uses redis:// (plaintext). "
|
||||
f"Change the URL scheme to rediss:// or remove the {_url_var} override."
|
||||
)
|
||||
if not REDIS_SSL and _url_is_ssl:
|
||||
raise ImproperlyConfigured(
|
||||
f"{_url_var} uses rediss:// (TLS) but REDIS_SSL is not enabled. "
|
||||
f"Set REDIS_SSL=true and configure the TLS certificate settings."
|
||||
)
|
||||
|
||||
# Celery TLS configuration — required in addition to the rediss:// URL scheme.
|
||||
# Uses the same cert params as REDIS_SSL_PARAMS, minus the "ssl" key that
|
||||
# redis-py needs but Celery/Kombu does not.
|
||||
if REDIS_SSL:
|
||||
CELERY_BROKER_USE_SSL = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"}
|
||||
CELERY_RESULT_BACKEND_USE_SSL = CELERY_BROKER_USE_SSL
|
||||
|
||||
# Configure Redis key prefix
|
||||
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {
|
||||
"global_keyprefix": "celery-tasks:", # Set the Redis key prefix for Celery
|
||||
}
|
||||
|
||||
# Set TTL (Time-to-Live) for task results (in seconds)
|
||||
CELERY_RESULT_EXPIRES = 3600 # 1 hour TTL for task results
|
||||
|
||||
# Optionally, set visibility timeout for task retries (if using Redis)
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = {
|
||||
"visibility_timeout": 3600, # Time in seconds that a task remains invisible during retries
|
||||
}
|
||||
|
||||
CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
|
||||
# Worker memory safety net: recycle prefork workers exceeding 512MB RSS.
|
||||
# Prevents unbounded growth from memory fragmentation or unexpected leaks.
|
||||
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 524_288 # 512 MB in KB
|
||||
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler"
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
# Explicitly disable the old fetch-channel-statuses task
|
||||
# This ensures it gets disabled when DatabaseScheduler syncs
|
||||
"fetch-channel-statuses": {
|
||||
"task": "apps.proxy.tasks.fetch_channel_stats",
|
||||
"schedule": 2.0, # Original schedule (doesn't matter since disabled)
|
||||
"enabled": False, # Explicitly disabled
|
||||
},
|
||||
# Keep the file scanning task
|
||||
"scan-files": {
|
||||
"task": "core.tasks.scan_and_process_files", # Direct task call
|
||||
"schedule": 20.0, # Every 20 seconds
|
||||
},
|
||||
"maintain-recurring-recordings": {
|
||||
"task": "apps.channels.tasks.maintain_recurring_recordings",
|
||||
"schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead
|
||||
},
|
||||
# Check for version updates daily
|
||||
"check-version-updates": {
|
||||
"task": "core.tasks.check_for_version_update",
|
||||
"schedule": 86400.0, # Once every 24 hours
|
||||
},
|
||||
# Check for account expirations daily
|
||||
"check-account-expirations": {
|
||||
"task": "apps.m3u.tasks.check_account_expirations",
|
||||
"schedule": 86400.0, # Once every 24 hours
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
MEDIA_URL = "/media/"
|
||||
|
||||
# Backup settings
|
||||
BACKUP_ROOT = os.environ.get("BACKUP_ROOT", "/data/backups")
|
||||
BACKUP_DATA_DIRS = [
|
||||
os.environ.get("LOGOS_DIR", "/data/logos"),
|
||||
os.environ.get("UPLOADS_DIR", "/data/uploads"),
|
||||
os.environ.get("PLUGINS_DIR", "/data/plugins"),
|
||||
]
|
||||
|
||||
SERVER_IP = "127.0.0.1"
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"]
|
||||
APPEND_SLASH = True
|
||||
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
|
||||
"ROTATE_REFRESH_TOKENS": False, # Optional: Whether to rotate refresh tokens
|
||||
"BLACKLIST_AFTER_ROTATION": True, # Optional: Whether to blacklist refresh tokens
|
||||
}
|
||||
|
||||
# Redis connection settings — _default_redis_url uses rediss:// when REDIS_SSL is enabled
|
||||
REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url)
|
||||
if os.environ.get("REDIS_URL") is not None:
|
||||
if REDIS_SSL and not REDIS_URL.startswith("rediss://"):
|
||||
raise ImproperlyConfigured(
|
||||
"REDIS_SSL is enabled but REDIS_URL uses redis:// (plaintext). "
|
||||
"Change the URL scheme to rediss:// or remove the REDIS_URL override."
|
||||
)
|
||||
if not REDIS_SSL and REDIS_URL.startswith("rediss://"):
|
||||
raise ImproperlyConfigured(
|
||||
"REDIS_URL uses rediss:// (TLS) but REDIS_SSL is not enabled. "
|
||||
"Set REDIS_SSL=true and configure the TLS certificate settings."
|
||||
)
|
||||
REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds
|
||||
REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds
|
||||
REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds
|
||||
REDIS_SOCKET_KEEPALIVE = True # Enable socket keepalive
|
||||
REDIS_RETRY_ON_TIMEOUT = True # Retry on timeout
|
||||
REDIS_MAX_RETRIES = 10 # Maximum number of retries
|
||||
REDIS_RETRY_INTERVAL = 1 # Initial retry interval in seconds
|
||||
|
||||
# Proxy Settings
|
||||
PROXY_SETTINGS = {
|
||||
"HLS": {
|
||||
"DEFAULT_URL": "", # Default HLS stream URL if needed
|
||||
"BUFFER_SIZE": 1000,
|
||||
"USER_AGENT": "VLC/3.0.20 LibVLC/3.0.20",
|
||||
"CHUNK_SIZE": 8192,
|
||||
"CLIENT_POLL_INTERVAL": 0.1,
|
||||
"MAX_RETRIES": 3,
|
||||
"MIN_SEGMENTS": 12,
|
||||
"MAX_SEGMENTS": 16,
|
||||
"WINDOW_SIZE": 12,
|
||||
"INITIAL_SEGMENTS": 3,
|
||||
},
|
||||
"TS": {
|
||||
"DEFAULT_URL": "", # Default TS stream URL if needed
|
||||
"BUFFER_SIZE": 1000,
|
||||
"RECONNECT_DELAY": 5,
|
||||
"USER_AGENT": "VLC/3.0.20 LibVLC/3.0.20",
|
||||
},
|
||||
}
|
||||
|
||||
# Map log level names to their numeric values
|
||||
LOG_LEVEL_MAP = {
|
||||
"TRACE": 5,
|
||||
"DEBUG": 10,
|
||||
"INFO": 20,
|
||||
"WARNING": 30,
|
||||
"ERROR": 40,
|
||||
"CRITICAL": 50,
|
||||
}
|
||||
|
||||
# Get log level from environment variable, default to INFO if not set
|
||||
# Add debugging output to see exactly what's being detected
|
||||
env_log_level = os.environ.get("DISPATCHARR_LOG_LEVEL", "")
|
||||
print(f"Environment DISPATCHARR_LOG_LEVEL detected as: '{env_log_level}'")
|
||||
|
||||
if not env_log_level:
|
||||
print("No DISPATCHARR_LOG_LEVEL found in environment, using default INFO")
|
||||
LOG_LEVEL_NAME = "INFO"
|
||||
else:
|
||||
LOG_LEVEL_NAME = env_log_level.upper()
|
||||
print(f"Setting log level to: {LOG_LEVEL_NAME}")
|
||||
|
||||
LOG_LEVEL = LOG_LEVEL_MAP.get(LOG_LEVEL_NAME, 20) # Default to INFO (20) if invalid
|
||||
|
||||
# Add this to your existing LOGGING configuration or create one if it doesn't exist
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{asctime} {levelname} {name} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
"level": 5, # Always allow TRACE level messages through the handler
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"core.tasks": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL, # Use environment-configured level
|
||||
"propagate": False, # Don't propagate to root logger to avoid duplicate logs
|
||||
},
|
||||
"core.utils": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
"apps.proxy": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL, # Use environment-configured level
|
||||
"propagate": False, # Don't propagate to root logger
|
||||
},
|
||||
# Add parent logger for all app modules
|
||||
"apps": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL,
|
||||
"propagate": False,
|
||||
},
|
||||
# Celery loggers to capture task execution messages
|
||||
"celery": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL, # Use configured log level for Celery logs
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.task": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL, # Use configured log level for task-specific logs
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL, # Use configured log level for worker logs
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.beat": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL, # Use configured log level for scheduler logs
|
||||
"propagate": False,
|
||||
},
|
||||
# Add any other loggers you need to capture TRACE logs from
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO'
|
||||
},
|
||||
}
|
||||
|
||||
# Connect script execution safety settings
|
||||
# Allowed base directories for custom scripts; real paths must be inside
|
||||
_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/scripts")
|
||||
CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p]
|
||||
|
||||
# Max execution time (seconds) for scripts
|
||||
CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10"))
|
||||
|
||||
# Truncate stdout/stderr to this many characters to avoid large outputs
|
||||
CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536"))
|
||||
|
||||
# Require executable bit and disallow world-writable files
|
||||
CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True
|
||||
CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True
|
||||
@@ -0,0 +1,70 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include, re_path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.generic import TemplateView, RedirectView
|
||||
from .routing import websocket_urlpatterns
|
||||
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
|
||||
from apps.proxy.ts_proxy.views import stream_xc
|
||||
from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode
|
||||
|
||||
urlpatterns = [
|
||||
# API Routes
|
||||
path("api/", include(("apps.api.urls", "api"), namespace="api")),
|
||||
path("api", RedirectView.as_view(url="/api/", permanent=True)),
|
||||
# Swagger redirects (Swagger UI is served at /api/swagger/)
|
||||
path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)),
|
||||
path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)),
|
||||
path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)),
|
||||
path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)),
|
||||
# Outputs
|
||||
path("output", RedirectView.as_view(url="/output/", permanent=True)),
|
||||
path("output/", include(("apps.output.urls", "output"), namespace="output")),
|
||||
# HDHR
|
||||
path("hdhr", RedirectView.as_view(url="/hdhr/", permanent=True)),
|
||||
path("hdhr/", include(("apps.hdhr.urls", "hdhr"), namespace="hdhr")),
|
||||
# Add proxy apps - Move these before the catch-all
|
||||
path("proxy/", include(("apps.proxy.urls", "proxy"), namespace="proxy")),
|
||||
path("proxy", RedirectView.as_view(url="/proxy/", permanent=True)),
|
||||
# xc
|
||||
re_path("player_api.php", xc_player_api, name="xc_player_api"),
|
||||
re_path("panel_api.php", xc_panel_api, name="xc_panel_api"),
|
||||
re_path("get.php", xc_get, name="xc_get"),
|
||||
re_path("xmltv.php", xc_xmltv, name="xc_xmltv"),
|
||||
path(
|
||||
"live/<str:username>/<str:password>/<str:channel_id>",
|
||||
stream_xc,
|
||||
name="xc_live_stream_endpoint",
|
||||
),
|
||||
path(
|
||||
"<str:username>/<str:password>/<str:channel_id>",
|
||||
stream_xc,
|
||||
name="xc_stream_endpoint",
|
||||
),
|
||||
# XC VOD endpoints
|
||||
path(
|
||||
"movie/<str:username>/<str:password>/<str:stream_id>.<str:extension>",
|
||||
stream_xc_movie,
|
||||
name="stream_xc_movie",
|
||||
),
|
||||
path(
|
||||
"series/<str:username>/<str:password>/<str:stream_id>.<str:extension>",
|
||||
stream_xc_episode,
|
||||
name="stream_xc_episode",
|
||||
),
|
||||
# Admin
|
||||
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
|
||||
path("admin/", admin.site.urls),
|
||||
|
||||
# VOD proxy is now handled by the main proxy URLs above
|
||||
# Catch-all routes should always be last
|
||||
path("", TemplateView.as_view(template_name="index.html")), # React entry point
|
||||
path("<path:unused_path>", TemplateView.as_view(template_name="index.html")),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
urlpatterns += websocket_urlpatterns
|
||||
|
||||
# Serve static files for development (React's JS, CSS, etc.)
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
@@ -0,0 +1,69 @@
|
||||
# dispatcharr/utils.py
|
||||
import json
|
||||
import ipaddress
|
||||
from django.http import JsonResponse
|
||||
from django.core.exceptions import ValidationError
|
||||
from core.models import CoreSettings, NETWORK_ACCESS_KEY
|
||||
|
||||
|
||||
def json_error_response(message, status=400):
|
||||
"""Return a standardized error JSON response."""
|
||||
return JsonResponse({"success": False, "error": message}, status=status)
|
||||
|
||||
|
||||
def json_success_response(data=None, status=200):
|
||||
"""Return a standardized success JSON response."""
|
||||
response = {"success": True}
|
||||
if data is not None:
|
||||
response.update(data)
|
||||
return JsonResponse(response, status=status)
|
||||
|
||||
|
||||
def validate_logo_file(file):
|
||||
"""Validate uploaded logo file size and MIME type."""
|
||||
valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]
|
||||
if file.content_type not in valid_mime_types:
|
||||
raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, SVG.")
|
||||
if file.size > 5 * 1024 * 1024: # 5MB
|
||||
raise ValidationError("File too large. Max 5MB.")
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
x_forwarded_for = request.META.get("HTTP_X_REAL_IP")
|
||||
if x_forwarded_for:
|
||||
# X-Forwarded-For can be a comma-separated list of IPs
|
||||
ip = x_forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.META.get("REMOTE_ADDR")
|
||||
return ip
|
||||
|
||||
|
||||
def network_access_allowed(request, settings_key):
|
||||
try:
|
||||
network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value
|
||||
except CoreSettings.DoesNotExist:
|
||||
network_access = {}
|
||||
local_cidrs = ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"]
|
||||
# Set defaults based on endpoint type
|
||||
if settings_key == "M3U_EPG":
|
||||
# M3U/EPG endpoints: local IPv4 and IPv6 only by default
|
||||
default_cidrs = local_cidrs
|
||||
else:
|
||||
# Other endpoints: allow all by default
|
||||
default_cidrs = ["0.0.0.0/0", "::/0"]
|
||||
|
||||
cidrs = (
|
||||
network_access[settings_key].split(",")
|
||||
if settings_key in network_access
|
||||
else default_cidrs
|
||||
)
|
||||
|
||||
network_allowed = False
|
||||
client_ip = ipaddress.ip_address(get_client_ip(request))
|
||||
for cidr in cidrs:
|
||||
network = ipaddress.ip_network(cidr)
|
||||
if client_ip in network:
|
||||
network_allowed = True
|
||||
break
|
||||
|
||||
return network_allowed
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
WSGI config for dispatcharr project.
|
||||
"""
|
||||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
|
||||
application = get_wsgi_application()
|
||||
Reference in New Issue
Block a user