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

This commit is contained in:
root
2026-05-09 21:24:50 +02:00
commit f56b088643
721 changed files with 177870 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# dispatcharr/__init__.py
# For Celery:
from .celery import app as celery_app
__all__ = ("celery_app",)
+8
View File
@@ -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()
+52
View File
@@ -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
+17
View File
@@ -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)
),
})
+161
View File
@@ -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()
+72
View File
@@ -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))
+47
View File
@@ -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)
+126
View File
@@ -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.")
+6
View File
@@ -0,0 +1,6 @@
from django.urls import path
from dispatcharr.consumers import MyWebSocketConsumer
websocket_urlpatterns = [
path("ws/", MyWebSocketConsumer.as_asgi()),
]
+570
View File
@@ -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
+70
View File
@@ -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)
+69
View File
@@ -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
+8
View File
@@ -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()