Merge pull request #98 from Dispatcharr/dev

v0.5.0
This commit is contained in:
SergeantPanda 2025-05-15 10:33:04 -05:00 committed by GitHub
commit cc1878bdd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1612 additions and 179 deletions

View file

@ -95,7 +95,7 @@ jobs:
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64 # Fast build - amd64 only
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:${{ steps.meta.outputs.branch_tag }}
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:${{ steps.version.outputs.version }}-${{ steps.timestamp.outputs.timestamp }}

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-15 01:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0011_update_epgsource_fields'),
]
operations = [
migrations.AlterField(
model_name='epgsource',
name='status',
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success'), ('disabled', 'Disabled')], default='idle', max_length=20),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-05-15 01:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0010_add_status_fields_and_remove_auto_now'),
]
operations = [
migrations.AlterField(
model_name='m3uaccount',
name='status',
field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success'), ('pending_setup', 'Pending Setup'), ('disabled', 'Disabled')], default='idle', max_length=20),
),
]

View file

@ -2,9 +2,12 @@
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import M3UAccount
from .tasks import refresh_single_m3u_account, refresh_m3u_groups
from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id
from django_celery_beat.models import PeriodicTask, IntervalSchedule
import json
import logging
logger = logging.getLogger(__name__)
@receiver(post_save, sender=M3UAccount)
def refresh_account_on_save(sender, instance, created, **kwargs):
@ -28,14 +31,17 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
period=IntervalSchedule.HOURS
)
# Task should be enabled only if refresh_interval != 0 AND account is active
should_be_enabled = (instance.refresh_interval != 0) and instance.is_active
# First check if the task already exists to avoid validation errors
try:
task = PeriodicTask.objects.get(name=task_name)
# Task exists, just update it
updated_fields = []
if task.enabled != (instance.refresh_interval != 0):
task.enabled = instance.refresh_interval != 0
if task.enabled != should_be_enabled:
task.enabled = should_be_enabled
updated_fields.append("enabled")
if task.interval != interval:
@ -56,7 +62,7 @@ def create_or_update_refresh_task(sender, instance, **kwargs):
interval=interval,
task="apps.m3u.tasks.refresh_single_m3u_account",
kwargs=json.dumps({"account_id": instance.id}),
enabled=instance.refresh_interval != 0,
enabled=should_be_enabled,
)
M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task)
@ -65,9 +71,21 @@ def delete_refresh_task(sender, instance, **kwargs):
"""
Delete the associated Celery Beat periodic task when a Channel is deleted.
"""
if instance.refresh_task:
instance.refresh_task.interval.delete()
instance.refresh_task.delete()
try:
# First try the foreign key relationship to find the task ID
task = None
if instance.refresh_task:
logger.info(f"Found task via foreign key: {instance.refresh_task.id} for M3UAccount {instance.id}")
task = instance.refresh_task
# Use the helper function to delete the task
if task:
delete_m3u_refresh_task_by_id(instance.id)
else:
# Otherwise use the helper function
delete_m3u_refresh_task_by_id(instance.id)
except Exception as e:
logger.error(f"Error in delete_refresh_task signal handler: {str(e)}", exc_info=True)
@receiver(pre_save, sender=M3UAccount)
def update_status_on_active_change(sender, instance, **kwargs):

View file

@ -244,7 +244,7 @@ def process_groups(account, groups):
group_objs = []
groups_to_create = []
for group_name, custom_props in groups.items():
logger.info(f"Handling group: {group_name}")
logger.debug(f"Handling group: {group_name}")
if (group_name not in existing_groups) and (group_name not in SKIP_EXTS):
groups_to_create.append(ChannelGroup(
name=group_name,
@ -253,9 +253,9 @@ def process_groups(account, groups):
group_objs.append(existing_groups[group_name])
if groups_to_create:
logger.info(f"Creating {len(groups_to_create)} groups")
logger.debug(f"Creating {len(groups_to_create)} groups")
created = ChannelGroup.bulk_create_and_fetch(groups_to_create)
logger.info(f"Created {len(created)} groups")
logger.debug(f"Created {len(created)} groups")
group_objs.extend(created)
relations = []
@ -301,14 +301,14 @@ def process_xc_category(account_id, batch, groups, hash_keys):
continue
try:
logger.info(f"Fetching streams for XC category: {group_name} (ID: {props['xc_id']})")
logger.debug(f"Fetching streams for XC category: {group_name} (ID: {props['xc_id']})")
streams = xc_client.get_live_category_streams(props['xc_id'])
if not streams:
logger.warning(f"No streams found for XC category {group_name} (ID: {props['xc_id']})")
continue
logger.info(f"Found {len(streams)} streams for category {group_name}")
logger.debug(f"Found {len(streams)} streams for category {group_name}")
for stream in streams:
name = stream["name"]
@ -550,7 +550,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
if account.account_type == M3UAccount.Types.XC:
# Log detailed information about the account
logger.info(f"Processing XC account {account_id} with URL: {account.server_url}")
logger.info(f"Username: {account.username}, Has password: {'Yes' if account.password else 'No'}")
logger.debug(f"Username: {account.username}, Has password: {'Yes' if account.password else 'No'}")
# Validate required fields
if not account.server_url:
@ -582,7 +582,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
# User agent handling - completely rewritten
try:
# Debug the user agent issue
logger.info(f"Getting user agent for account {account.id}")
logger.debug(f"Getting user agent for account {account.id}")
# Use a hardcoded user agent string to avoid any issues with object structure
user_agent_string = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
@ -593,20 +593,20 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
ua_obj = UserAgent.objects.get(id=account.user_agent_id)
if ua_obj and hasattr(ua_obj, 'user_agent') and ua_obj.user_agent:
user_agent_string = ua_obj.user_agent
logger.info(f"Using user agent from account: {user_agent_string}")
logger.debug(f"Using user agent from account: {user_agent_string}")
else:
# Get default user agent from CoreSettings
default_ua_id = CoreSettings.get_default_user_agent_id()
logger.info(f"Default user agent ID from settings: {default_ua_id}")
logger.debug(f"Default user agent ID from settings: {default_ua_id}")
if default_ua_id:
ua_obj = UserAgent.objects.get(id=default_ua_id)
if ua_obj and hasattr(ua_obj, 'user_agent') and ua_obj.user_agent:
user_agent_string = ua_obj.user_agent
logger.info(f"Using default user agent: {user_agent_string}")
logger.debug(f"Using default user agent: {user_agent_string}")
except Exception as e:
logger.warning(f"Error getting user agent, using fallback: {str(e)}")
logger.info(f"Final user agent string: {user_agent_string}")
logger.debug(f"Final user agent string: {user_agent_string}")
except Exception as e:
user_agent_string = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
logger.warning(f"Exception in user agent handling, using fallback: {str(e)}")
@ -629,9 +629,9 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
# Authenticate with detailed error handling
try:
logger.info(f"Authenticating with XC server {server_url}")
logger.debug(f"Authenticating with XC server {server_url}")
auth_result = xc_client.authenticate()
logger.info(f"Authentication response: {auth_result}")
logger.debug(f"Authentication response: {auth_result}")
except Exception as e:
error_msg = f"Failed to authenticate with XC server: {str(e)}"
logger.error(error_msg)
@ -733,6 +733,60 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
return extinf_data, groups
def delete_m3u_refresh_task_by_id(account_id):
"""
Delete the periodic task associated with an M3U account ID.
Can be called directly or from the post_delete signal.
Returns True if a task was found and deleted, False otherwise.
"""
try:
task = None
task_name = f"m3u_account-refresh-{account_id}"
# Look for task by name
try:
from django_celery_beat.models import PeriodicTask, IntervalSchedule
task = PeriodicTask.objects.get(name=task_name)
logger.debug(f"Found task by name: {task.id} for M3UAccount {account_id}")
except PeriodicTask.DoesNotExist:
logger.warning(f"No PeriodicTask found with name {task_name}")
return False
# Now delete the task and its interval
if task:
# Store interval info before deleting the task
interval_id = None
if hasattr(task, 'interval') and task.interval:
interval_id = task.interval.id
# Count how many TOTAL tasks use this interval (including this one)
tasks_with_same_interval = PeriodicTask.objects.filter(interval_id=interval_id).count()
logger.debug(f"Interval {interval_id} is used by {tasks_with_same_interval} tasks total")
# Delete the task first
task_id = task.id
task.delete()
logger.debug(f"Successfully deleted periodic task {task_id}")
# Now check if we should delete the interval
# We only delete if it was the ONLY task using this interval
if interval_id and tasks_with_same_interval == 1:
try:
interval = IntervalSchedule.objects.get(id=interval_id)
logger.debug(f"Deleting interval schedule {interval_id} (not shared with other tasks)")
interval.delete()
logger.debug(f"Successfully deleted interval {interval_id}")
except IntervalSchedule.DoesNotExist:
logger.warning(f"Interval {interval_id} no longer exists")
elif interval_id:
logger.debug(f"Not deleting interval {interval_id} as it's shared with {tasks_with_same_interval-1} other tasks")
return True
return False
except Exception as e:
logger.error(f"Error deleting periodic task for M3UAccount {account_id}: {str(e)}", exc_info=True)
return False
@shared_task
def refresh_single_m3u_account(account_id):
"""Splits M3U processing into chunks and dispatches them as parallel tasks."""
@ -748,7 +802,7 @@ def refresh_single_m3u_account(account_id):
try:
account = M3UAccount.objects.get(id=account_id, is_active=True)
if not account.is_active:
logger.info(f"Account {account_id} is not active, skipping.")
logger.debug(f"Account {account_id} is not active, skipping.")
release_task_lock('refresh_single_m3u_account', account_id)
return
@ -758,8 +812,17 @@ def refresh_single_m3u_account(account_id):
filters = list(account.filters.all())
except M3UAccount.DoesNotExist:
# The M3U account doesn't exist, so delete the periodic task if it exists
logger.warning(f"M3U account with ID {account_id} not found, but task was triggered. Cleaning up orphaned task.")
# Call the helper function to delete the task
if delete_m3u_refresh_task_by_id(account_id):
logger.info(f"Successfully cleaned up orphaned task for M3U account {account_id}")
else:
logger.debug(f"No orphaned task found for M3U account {account_id}")
release_task_lock('refresh_single_m3u_account', account_id)
return f"M3UAccount with ID={account_id} not found or inactive."
return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up"
# Fetch M3U lines and handle potential issues
extinf_data = []
@ -777,7 +840,7 @@ def refresh_single_m3u_account(account_id):
try:
logger.info(f"Calling refresh_m3u_groups for account {account_id}")
result = refresh_m3u_groups(account_id, full_refresh=True)
logger.info(f"refresh_m3u_groups result: {result}")
logger.trace(f"refresh_m3u_groups result: {result}")
# Check for completely empty result or missing groups
if not result or result[1] is None:
@ -845,7 +908,7 @@ def refresh_single_m3u_account(account_id):
task_group = group(process_m3u_batch.s(account_id, batch, existing_groups, hash_keys) for batch in batches)
else:
# For XC accounts, get the groups with their custom properties containing xc_id
logger.info(f"Processing XC account with groups: {existing_groups}")
logger.debug(f"Processing XC account with groups: {existing_groups}")
# Get the ChannelGroupM3UAccount entries with their custom_properties
channel_group_relationships = ChannelGroupM3UAccount.objects.filter(
@ -866,7 +929,7 @@ def refresh_single_m3u_account(account_id):
'xc_id': custom_props['xc_id'],
'channel_group_id': group_id
}
logger.info(f"Added group {group_name} with xc_id {custom_props['xc_id']}")
logger.debug(f"Added group {group_name} with xc_id {custom_props['xc_id']}")
else:
logger.warning(f"No xc_id found in custom properties for group {group_name}")
except (json.JSONDecodeError, KeyError) as e:
@ -946,7 +1009,7 @@ def refresh_single_m3u_account(account_id):
# Optionally remove completed task from the group to prevent processing it again
result.remove(async_result)
else:
logger.debug(f"Task is still running.")
logger.trace(f"Task is still running.")
# Ensure all database transactions are committed before cleanup
logger.info(f"All {total_batches} tasks completed, ensuring DB transactions are committed before cleanup")

View file

@ -102,6 +102,9 @@ class StreamManager:
self.last_bytes_update = time.time()
self.bytes_update_interval = 5 # Update Redis every 5 seconds
# Add stderr reader thread property
self.stderr_reader_thread = None
def _create_session(self):
"""Create and configure requests session with optimal settings"""
session = requests.Session()
@ -333,13 +336,17 @@ class StreamManager:
self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent)
logger.debug(f"Starting transcode process: {self.transcode_cmd}")
# Modified to capture stderr instead of discarding it
self.transcode_process = subprocess.Popen(
self.transcode_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, # Suppress error logs
stderr=subprocess.PIPE, # Capture stderr instead of discarding it
bufsize=188 * 64 # Buffer optimized for TS packets
)
# Start a thread to read stderr
self._start_stderr_reader()
# Set flag that transcoding process is active
self.transcode_process_active = True
@ -358,6 +365,40 @@ class StreamManager:
self._close_socket()
return False
def _start_stderr_reader(self):
"""Start a thread to read stderr from the transcode process"""
if self.transcode_process and self.transcode_process.stderr:
self.stderr_reader_thread = threading.Thread(
target=self._read_stderr,
daemon=True # Use daemon thread so it doesn't block program exit
)
self.stderr_reader_thread.start()
logger.debug(f"Started stderr reader thread for channel {self.channel_id}")
def _read_stderr(self):
"""Read and log stderr output from the transcode process"""
try:
if not self.transcode_process or not self.transcode_process.stderr:
logger.warning(f"No stderr to read for channel {self.channel_id}")
return
for line in iter(self.transcode_process.stderr.readline, b''):
if not line:
break
# Decode the line and strip whitespace
error_line = line.decode('utf-8', errors='replace').strip()
# Skip empty lines
if not error_line:
continue
# Log all stderr output as debug messages
logger.debug(f"Transcode stderr [{self.channel_id}]: {error_line}")
except Exception as e:
logger.error(f"Error reading transcode stderr: {e}")
def _establish_http_connection(self):
"""Establish a direct HTTP connection to the stream"""
try:

View file

@ -2,6 +2,19 @@ from django.apps import AppConfig
from django.conf import settings
import os, logging
# Define TRACE level (5 is below DEBUG which is 10)
TRACE = 5
logging.addLevelName(TRACE, "TRACE")
# Add trace method to the Logger class
def trace(self, message, *args, **kwargs):
"""Log a message with TRACE level (more detailed than DEBUG)"""
if self.isEnabledFor(TRACE):
self._log(TRACE, message, args, **kwargs)
# Add the trace method to the Logger class
logging.Logger.trace = trace
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'core'

View file

@ -33,6 +33,8 @@ last_known_data = {}
_last_log_times = {}
# Don't repeat similar log messages more often than this (in seconds)
LOG_THROTTLE_SECONDS = 300 # 5 minutes
# Track if this is the first scan since startup
_first_scan_completed = False
@shared_task
def beat_periodic_task():
@ -52,13 +54,9 @@ def throttled_log(logger_method, message, key=None, *args, **kwargs):
@shared_task
def scan_and_process_files():
global _first_scan_completed
redis_client = RedisClient.get_client()
now = time.time()
# Add debug logging for the auto-import setting
auto_import_value = CoreSettings.get_auto_import_mapped_files()
logger.debug(f"Auto-import mapped files setting value: '{auto_import_value}' (type: {type(auto_import_value).__name__})")
# Check if directories exist
dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR])
if not dirs_exist:
@ -84,7 +82,11 @@ def scan_and_process_files():
# Check if this file is already in the database
existing_m3u = M3UAccount.objects.filter(file_path=filepath).exists()
if existing_m3u:
logger.debug(f"Skipping {filename}: Already exists in database")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Already exists in database")
else:
logger.debug(f"Skipping {filename}: Already exists in database")
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
m3u_skipped += 1
continue
@ -100,7 +102,11 @@ def scan_and_process_files():
# Skip if we've already processed this mtime
if stored_mtime and float(stored_mtime) >= mtime:
logger.debug(f"Skipping {filename}: Already processed this version")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Already processed this version")
else:
logger.debug(f"Skipping {filename}: Already processed this version")
m3u_skipped += 1
continue
@ -111,11 +117,26 @@ def scan_and_process_files():
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
# More descriptive creation logging that includes active status
if created:
if m3u_account.is_active:
logger.info(f"Created new M3U account '{filename}' (active)")
else:
logger.info(f"Created new M3U account '{filename}' (inactive due to auto-import setting)")
if not m3u_account.is_active:
logger.debug(f"Skipping {filename}: M3U account is inactive")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: M3U account is inactive")
else:
logger.debug(f"Skipping {filename}: M3U account is inactive")
m3u_skipped += 1
continue
# Log update for existing files (we've already logged creation above)
if not created:
logger.info(f"Detected update to existing M3U file: {filename}")
logger.info(f"Queueing refresh for M3U file: {filename}")
refresh_single_m3u_account.delay(m3u_account.id)
m3u_processed += 1
@ -129,12 +150,12 @@ def scan_and_process_files():
},
)
logger.debug(f"M3U processing complete: {m3u_processed} processed, {m3u_skipped} skipped, {len(m3u_files)} total")
logger.trace(f"M3U processing complete: {m3u_processed} processed, {m3u_skipped} skipped, {len(m3u_files)} total")
# Process EPG files
try:
epg_files = os.listdir(EPG_WATCH_DIR)
logger.debug(f"Found {len(epg_files)} files in EPG directory")
logger.trace(f"Found {len(epg_files)} files in EPG directory")
except Exception as e:
logger.error(f"Error listing EPG directory: {e}")
epg_files = []
@ -147,12 +168,20 @@ def scan_and_process_files():
filepath = os.path.join(EPG_WATCH_DIR, filename)
if not os.path.isfile(filepath):
logger.debug(f"Skipping {filename}: Not a file")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Not a file")
else:
logger.debug(f"Skipping {filename}: Not a file")
epg_skipped += 1
continue
if not filename.endswith('.xml') and not filename.endswith('.gz'):
logger.debug(f"Skipping {filename}: Not an XML or GZ file")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Not an XML or GZ file")
else:
logger.debug(f"Skipping {filename}: Not an XML or GZ file")
epg_skipped += 1
continue
@ -166,7 +195,11 @@ def scan_and_process_files():
# Check if this file is already in the database
existing_epg = EPGSource.objects.filter(file_path=filepath).exists()
if existing_epg:
logger.debug(f"Skipping {filename}: Already exists in database")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Already exists in database")
else:
logger.debug(f"Skipping {filename}: Already exists in database")
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
epg_skipped += 1
continue
@ -176,13 +209,21 @@ def scan_and_process_files():
# File too new — probably still being written
if age < MIN_AGE_SECONDS:
logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
else:
logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)")
epg_skipped += 1
continue
# Skip if we've already processed this mtime
if stored_mtime and float(stored_mtime) >= mtime:
logger.debug(f"Skipping {filename}: Already processed this version")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: Already processed this version")
else:
logger.debug(f"Skipping {filename}: Already processed this version")
epg_skipped += 1
continue
@ -193,17 +234,28 @@ def scan_and_process_files():
"is_active": CoreSettings.get_auto_import_mapped_files() in [True, "true", "True"],
})
# Add debug logging for created sources
if created:
logger.info(f"Created new EPG source '{filename}'")
redis_client.set(redis_key, mtime, ex=REDIS_TTL)
# More descriptive creation logging that includes active status
if created:
if epg_source.is_active:
logger.info(f"Created new EPG source '{filename}' (active)")
else:
logger.info(f"Created new EPG source '{filename}' (inactive due to auto-import setting)")
if not epg_source.is_active:
logger.debug(f"Skipping {filename}: EPG source is marked as inactive")
# Use trace level if not first scan
if _first_scan_completed:
logger.trace(f"Skipping {filename}: EPG source is marked as inactive")
else:
logger.debug(f"Skipping {filename}: EPG source is marked as inactive")
epg_skipped += 1
continue
# Log update for existing files (we've already logged creation above)
if not created:
logger.info(f"Detected update to existing EPG file: {filename}")
logger.info(f"Queueing refresh for EPG file: {filename}")
refresh_epg_data.delay(epg_source.id) # Trigger Celery task
epg_processed += 1
@ -213,7 +265,10 @@ def scan_and_process_files():
epg_errors += 1
continue
logger.debug(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors")
logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors")
# Mark that the first scan is complete
_first_scan_completed = True
def fetch_channel_stats():
redis_client = RedisClient.get_client()

View file

@ -1,14 +1,17 @@
import django
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
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": AuthMiddlewareStack(
"websocket": JWTAuthMiddleware(
URLRouter(dispatcharr.routing.websocket_urlpatterns)
),
})

View file

@ -1,8 +1,91 @@
# dispatcharr/celery.py
import os
from celery import Celery
import logging
# 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',
)
@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)

View file

@ -6,9 +6,15 @@ 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()
self.room_name = "updates"
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({

View file

@ -0,0 +1,36 @@
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
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:
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:
validated_token = JWTAuthentication().get_validated_token(token)
scope["user"] = await get_user(validated_token)
else:
scope["user"] = AnonymousUser()
except (InvalidToken, TokenError):
scope["user"] = AnonymousUser()
return await super().__call__(scope, receive, send)

View file

@ -232,3 +232,90 @@ PROXY_SETTINGS = {
'REDIS_CHUNK_TTL': 60, # How long to keep chunks in Redis (seconds)
}
}
# 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
},
'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'
},
}

View file

@ -14,7 +14,7 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
python3.13 python3.13-dev python3.13-venv \
python-is-python3 python3-pip \
libpcre3 libpcre3-dev libpq-dev procps \
build-essential gcc \
build-essential gcc pciutils \
nginx streamlink \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
@ -37,4 +37,9 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo
echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | \
tee /etc/apt/sources.list.d/pgdg.list && \
apt-get update && apt-get install -y postgresql-14 postgresql-contrib-14 && \
apt-get clean && rm -rf /var/lib/apt/lists/*
apt-get clean && rm -rf /var/lib/apt/lists/*
# Create render group for hardware acceleration support with GID 109
RUN groupadd -r -g 109 render || true
ENTRYPOINT ["/app/docker/entrypoint.sh"]

View file

@ -13,6 +13,20 @@ services:
- DISPATCHARR_ENV=aio
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
- DISPATCHARR_LOG_LEVEL=info
# Optional for hardware acceleration
#devices:
# - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API)
# Uncomment the following lines for NVIDIA GPU support
# NVidia GPU support (requires NVIDIA Container Toolkit)
#deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes:
dispatcharr_data:

View file

@ -3,7 +3,7 @@ services:
# build:
# context: ..
# dockerfile: docker/Dockerfile.dev
image: dispatcharr/dispatcharr
image: ghcr.io/dispatcharr/dispatcharr:base
container_name: dispatcharr_debug
ports:
- 5656:5656 # API port
@ -17,3 +17,4 @@ services:
- DISPATCHARR_DEBUG=true
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
- DISPATCHARR_LOG_LEVEL=trace

View file

@ -3,7 +3,7 @@ services:
# build:
# context: ..
# dockerfile: docker/Dockerfile.dev
image: ghcr.io/dispatcharr/dispatcharr:dev
image: ghcr.io/dispatcharr/dispatcharr:base
container_name: dispatcharr_dev
ports:
- 5656:5656
@ -16,6 +16,7 @@ services:
- DISPATCHARR_ENV=dev
- REDIS_HOST=localhost
- CELERY_BROKER_URL=redis://localhost:6379/0
- DISPATCHARR_LOG_LEVEL=debug
pgadmin:
image: dpage/pgadmin4

View file

@ -14,6 +14,22 @@ services:
- POSTGRES_PASSWORD=secret
- REDIS_HOST=redis
- CELERY_BROKER_URL=redis://redis:6379/0
- DISPATCHARR_LOG_LEVEL=info
# Optional for hardware acceleration
#group_add:
# - video
# #- render # Uncomment if your GPU requires it
#devices:
# - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API)
# Uncomment the following lines for NVIDIA GPU support
# NVidia GPU support (requires NVIDIA Container Toolkit)
#deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
celery:
image: dispatcharr/dispatcharr:alpha-v1

View file

@ -37,7 +37,12 @@ export POSTGRES_PORT=${POSTGRES_PORT:-5432}
export REDIS_HOST=${REDIS_HOST:-localhost}
export REDIS_DB=${REDIS_DB:-0}
export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191}
export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri'
export LD_LIBRARY_PATH='/usr/local/lib'
# Set LIBVA_DRIVER_NAME if user has specified it
if [ -v LIBVA_DRIVER_NAME ]; then
export LIBVA_DRIVER_NAME
fi
# Extract version information from version.py
export DISPATCHARR_VERSION=$(python -c "import sys; sys.path.append('/app'); import version; print(version.__version__)")
export DISPATCHARR_TIMESTAMP=$(python -c "import sys; sys.path.append('/app'); import version; print(version.__timestamp__ or '')")
@ -48,29 +53,44 @@ if [ -n "$DISPATCHARR_TIMESTAMP" ]; then
else
echo "📦 Dispatcharr version: ${DISPATCHARR_VERSION}"
fi
export DISPATCHARR_LOG_LEVEL
# Set log level with default if not provided
DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL:-INFO}
# Convert to uppercase
DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL^^}
echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'"
# Also make the log level available in /etc/environment for all login shells
#grep -q "DISPATCHARR_LOG_LEVEL" /etc/environment || echo "DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL}" >> /etc/environment
# READ-ONLY - don't let users change these
export POSTGRES_DIR=/data/db
# Global variables, stored so other users inherit them
if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then
echo "export PATH=$PATH" >> /etc/profile.d/dispatcharr.sh
echo "export VIRTUAL_ENV=$VIRTUAL_ENV" >> /etc/profile.d/dispatcharr.sh
echo "export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE" >> /etc/profile.d/dispatcharr.sh
echo "export PYTHONUNBUFFERED=$PYTHONUNBUFFERED" >> /etc/profile.d/dispatcharr.sh
echo "export POSTGRES_DB=$POSTGRES_DB" >> /etc/profile.d/dispatcharr.sh
echo "export POSTGRES_USER=$POSTGRES_USER" >> /etc/profile.d/dispatcharr.sh
echo "export POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> /etc/profile.d/dispatcharr.sh
echo "export POSTGRES_HOST=$POSTGRES_HOST" >> /etc/profile.d/dispatcharr.sh
echo "export POSTGRES_PORT=$POSTGRES_PORT" >> /etc/profile.d/dispatcharr.sh
echo "export DISPATCHARR_ENV=$DISPATCHARR_ENV" >> /etc/profile.d/dispatcharr.sh
echo "export DISPATCHARR_DEBUG=$DISPATCHARR_DEBUG" >> /etc/profile.d/dispatcharr.sh
echo "export REDIS_HOST=$REDIS_HOST" >> /etc/profile.d/dispatcharr.sh
echo "export REDIS_DB=$REDIS_DB" >> /etc/profile.d/dispatcharr.sh
echo "export POSTGRES_DIR=$POSTGRES_DIR" >> /etc/profile.d/dispatcharr.sh
echo "export DISPATCHARR_PORT=$DISPATCHARR_PORT" >> /etc/profile.d/dispatcharr.sh
echo "export DISPATCHARR_VERSION=$DISPATCHARR_VERSION" >> /etc/profile.d/dispatcharr.sh
echo "export DISPATCHARR_TIMESTAMP=$DISPATCHARR_TIMESTAMP" >> /etc/profile.d/dispatcharr.sh
# Define all variables to process
variables=(
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
)
# Process each variable for both profile.d and environment
for var in "${variables[@]}"; do
# Check if the variable is set in the environment
if [ -n "${!var+x}" ]; then
# Add to profile.d
echo "export ${var}=${!var}" >> /etc/profile.d/dispatcharr.sh
# Add to /etc/environment if not already there
grep -q "^${var}=" /etc/environment || echo "${var}=${!var}" >> /etc/environment
else
echo "Warning: Environment variable $var is not set"
fi
done
fi
chmod +x /etc/profile.d/dispatcharr.sh
@ -126,8 +146,17 @@ else
uwsgi_file="/app/docker/uwsgi.ini"
fi
su - $POSTGRES_USER -c "cd /app && uwsgi --ini $uwsgi_file &"
uwsgi_pid=$(pgrep uwsgi | sort | head -n1)
# Set base uwsgi args
uwsgi_args="--ini $uwsgi_file"
# Conditionally disable logging if not in debug mode
if [ "$DISPATCHARR_DEBUG" != "true" ]; then
uwsgi_args+=" --disable-logging"
fi
# Launch uwsgi -p passes environment variables to the process
su -p - $POSTGRES_USER -c "cd /app && uwsgi $uwsgi_args &"
uwsgi_pid=$(pgrep uwsgi | sort | head -n1)
echo "✅ uwsgi started with PID $uwsgi_pid"
pids+=("$uwsgi_pid")
@ -162,6 +191,15 @@ pids+=("$uwsgi_pid")
# echo "✅ celery beat started with PID $beat_pid"
# pids+=("$beat_pid")
# Wait for services to fully initialize before checking hardware
echo "⏳ Waiting for services to fully initialize before hardware check..."
sleep 5
# Run hardware check
echo "🔍 Running hardware acceleration check..."
. /app/docker/init/04-check-hwaccel.sh
# Wait for at least one process to exit and log the process that exited first
if [ ${#pids[@]} -gt 0 ]; then
echo "⏳ Waiting for processes to exit..."

View file

@ -29,5 +29,62 @@ else
fi
fi
# Get the GID of /dev/dri/renderD128 on the host (must be mounted into container)
if [ -e "/dev/dri/renderD128" ]; then
HOST_RENDER_GID=$(stat -c '%g' /dev/dri/renderD128)
# Check if this GID belongs to the video group
VIDEO_GID=$(getent group video 2>/dev/null | cut -d: -f3)
if [ "$HOST_RENDER_GID" = "$VIDEO_GID" ]; then
echo "RenderD128 GID ($HOST_RENDER_GID) matches video group GID. Using video group for GPU access."
# Make sure POSTGRES_USER is in video group
if ! id -nG "$POSTGRES_USER" | grep -qw "video"; then
usermod -a -G video "$POSTGRES_USER"
echo "Added user $POSTGRES_USER to video group for GPU access"
fi
else
# We need to ensure render group exists with correct GID
if getent group render >/dev/null; then
CURRENT_RENDER_GID=$(getent group render | cut -d: -f3)
if [ "$CURRENT_RENDER_GID" != "$HOST_RENDER_GID" ]; then
# Check if another group already has the target GID
if getent group "$HOST_RENDER_GID" >/dev/null 2>&1; then
EXISTING_GROUP=$(getent group "$HOST_RENDER_GID" | cut -d: -f1)
echo "Warning: Cannot change render group GID to $HOST_RENDER_GID as it's already used by group '$EXISTING_GROUP'"
# Add user to the existing group with the target GID to ensure device access
if ! id -nG "$POSTGRES_USER" | grep -qw "$EXISTING_GROUP"; then
usermod -a -G "$EXISTING_GROUP" "$POSTGRES_USER" || echo "Warning: Failed to add user to $EXISTING_GROUP group"
echo "Added user $POSTGRES_USER to $EXISTING_GROUP group for GPU access"
fi
else
echo "Changing render group GID from $CURRENT_RENDER_GID to $HOST_RENDER_GID"
groupmod -g "$HOST_RENDER_GID" render || echo "Warning: Failed to change render group GID. Continuing anyway..."
fi
fi
else
echo "Creating render group with GID $HOST_RENDER_GID"
groupadd -g "$HOST_RENDER_GID" render
fi
# Make sure POSTGRES_USER is in render group
if ! id -nG "$POSTGRES_USER" | grep -qw "render"; then
usermod -a -G render "$POSTGRES_USER"
echo "Added user $POSTGRES_USER to render group for GPU access"
fi
fi
else
echo "Warning: /dev/dri/renderD128 not found. GPU acceleration may not be available."
fi
# Always add user to video group for hardware acceleration if it exists
# (some systems use video group for general GPU access)
if getent group video >/dev/null 2>&1; then
if ! id -nG "$POSTGRES_USER" | grep -qw "video"; then
usermod -a -G video "$POSTGRES_USER"
echo "Added user $POSTGRES_USER to video group for hardware acceleration access"
fi
fi
# Run nginx as specified user
sed -i 's/user www-data;/user dispatch;/g' /etc/nginx/nginx.conf
sed -i "s/user www-data;/user $POSTGRES_USER;/g" /etc/nginx/nginx.conf

View file

@ -0,0 +1,715 @@
#!/bin/bash
echo "🔍 Checking for GPU acceleration devices..."
# Helper function for device access checks
check_dev() {
local dev=$1
if [ -e "$dev" ]; then
if [ -r "$dev" ] && [ -w "$dev" ]; then
echo "✅ Device $dev is accessible."
else
echo "⚠️ Device $dev exists but is not accessible. Check permissions or container runtime options."
fi
else
echo " Device $dev does not exist."
fi
}
# Initialize device detection flags
ANY_GPU_DEVICES_FOUND=false
DRI_DEVICES_FOUND=false
NVIDIA_FOUND=false
NVIDIA_GPU_IN_LSPCI=false
INTEL_GPU_IN_LSPCI=false
AMD_GPU_IN_LSPCI=false
# Check for all GPU types in hardware via lspci
if command -v lspci >/dev/null 2>&1; then
# Check for NVIDIA GPUs
if lspci | grep -i "NVIDIA" | grep -i "VGA\|3D\|Display" >/dev/null; then
NVIDIA_GPU_IN_LSPCI=true
NVIDIA_MODEL=$(lspci | grep -i "NVIDIA" | grep -i "VGA\|3D\|Display" | head -1 | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //')
fi
# Check for Intel GPUs - making sure it's not already detected as NVIDIA
if lspci | grep -i "Intel" | grep -v "NVIDIA" | grep -i "VGA\|3D\|Display" >/dev/null; then
INTEL_GPU_IN_LSPCI=true
INTEL_MODEL=$(lspci | grep -i "Intel" | grep -v "NVIDIA" | grep -i "VGA\|3D\|Display" | head -1 | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //')
fi
# Check for AMD GPUs - making sure it's not already detected as NVIDIA or Intel
if lspci | grep -i "AMD\|ATI\|Advanced Micro Devices" | grep -v "NVIDIA\|Intel" | grep -i "VGA\|3D\|Display" >/dev/null; then
AMD_GPU_IN_LSPCI=true
AMD_MODEL=$(lspci | grep -i "AMD\|ATI\|Advanced Micro Devices" | grep -v "NVIDIA\|Intel" | grep -i "VGA\|3D\|Display" | head -1 | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //' | sed 's/Technologies //')
fi
# Display detected GPU hardware
if [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then
echo "🔍 Hardware detection: NVIDIA GPU ($NVIDIA_MODEL)"
fi
if [ "$INTEL_GPU_IN_LSPCI" = true ]; then
echo "🔍 Hardware detection: Intel GPU ($INTEL_MODEL)"
fi
if [ "$AMD_GPU_IN_LSPCI" = true ]; then
echo "🔍 Hardware detection: AMD GPU ($AMD_MODEL)"
fi
fi
# Silently check for any GPU devices first
for dev in /dev/dri/renderD* /dev/dri/card* /dev/nvidia*; do
if [ -e "$dev" ]; then
ANY_GPU_DEVICES_FOUND=true
break
fi
done
# Only if devices might exist, show detailed checks
if [ "$ANY_GPU_DEVICES_FOUND" = true ]; then
# Check Intel/AMD VAAPI devices
echo "🔍 Checking for VAAPI device nodes (Intel/AMD)..."
for dev in /dev/dri/renderD* /dev/dri/card*; do
if [ -e "$dev" ]; then
DRI_DEVICES_FOUND=true
check_dev "$dev"
fi
done
# Check NVIDIA device nodes
echo "🔍 Checking for NVIDIA device nodes..."
for dev in /dev/nvidia*; do
if [ -e "$dev" ]; then
NVIDIA_FOUND=true
check_dev "$dev"
fi
done
# Show GPU device availability messages
if [ "$NVIDIA_FOUND" = false ] && [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then
echo "⚠️ No NVIDIA device nodes available despite hardware detection."
echo " You may be able to use VAAPI for hardware acceleration, but NVENC/CUDA won't be available."
echo " For optimal performance, configure proper NVIDIA container runtime."
elif [ "$NVIDIA_FOUND" = false ]; then
echo " No NVIDIA device nodes found under /dev."
fi
# Check for Intel/AMD GPUs that might not be fully accessible
if [ "$DRI_DEVICES_FOUND" = false ] && [ "$INTEL_GPU_IN_LSPCI" = true ]; then
echo "⚠️ Intel GPU detected in hardware but no DRI devices found."
echo " Hardware acceleration will not be available."
echo " Make sure /dev/dri/ devices are properly mapped to the container."
elif [ "$DRI_DEVICES_FOUND" = false ] && [ "$AMD_GPU_IN_LSPCI" = true ]; then
echo "⚠️ AMD GPU detected in hardware but no DRI devices found."
echo " Hardware acceleration will not be available."
echo " Make sure /dev/dri/ devices are properly mapped to the container."
fi
else
# No GPU devices found, skip the detailed checks
echo "❌ No GPU acceleration devices detected in this container."
echo " Checking for potential configuration issues..."
# Check if the host might have GPUs that aren't passed to the container
if command -v lspci >/dev/null 2>&1; then
if lspci | grep -i "VGA\|3D\|Display" | grep -i "NVIDIA\|Intel\|AMD" >/dev/null; then
echo "⚠️ Host system appears to have GPU hardware, but no devices are accessible to the container."
echo " - For NVIDIA GPUs: Ensure NVIDIA Container Runtime is configured properly"
echo " - For Intel/AMD GPUs: Verify that /dev/dri/ devices are passed to the container"
echo " - Check your Docker run command or docker-compose.yml for proper device mapping"
else
echo " No GPU hardware detected on the host system. CPU-only transcoding will be used."
fi
else
echo " Unable to check host GPU hardware (lspci not available). CPU-only transcoding will be used."
fi
echo "📋 =================================================="
echo "✅ GPU detection script complete. No GPUs available for hardware acceleration."
# Don't exit the container - just return from this script
return 0 2>/dev/null || true
fi
# Check group membership for GPU access - context-aware based on hardware
echo "🔍 Checking user group memberships and device access..."
VIDEO_GID=$(getent group video | cut -d: -f3)
RENDER_GID=$(getent group render | cut -d: -f3)
NVIDIA_CONTAINER_TOOLKIT_FOUND=false
NVIDIA_ENV_MISMATCH=false
# Improved device access check function
check_user_device_access() {
local device=$1
local user=$2
if [ -e "$device" ];then
if su -c "test -r $device && test -w $device" - $user 2>/dev/null; then
echo "✅ User $user has full access to $device"
return 0
else
echo "⚠️ User $user cannot access $device (permission denied)"
return 1
fi
else
# Device doesn't exist, no need to report here
return 2
fi
}
# Direct device access verification for DRI (Intel/AMD)
echo "🔍 Verifying if $POSTGRES_USER has direct access to GPU devices..."
HAS_DRI_ACCESS=false
DRI_ACCESS_COUNT=0
DRI_DEVICE_COUNT=0
for dev in /dev/dri/renderD* /dev/dri/card*; do
if [ -e "$dev" ]; then
DRI_DEVICE_COUNT=$((DRI_DEVICE_COUNT + 1))
if check_user_device_access "$dev" "$POSTGRES_USER"; then
DRI_ACCESS_COUNT=$((DRI_ACCESS_COUNT + 1))
HAS_DRI_ACCESS=true
fi
fi
done
# Direct device access verification for NVIDIA
HAS_NVIDIA_ACCESS=false
NVIDIA_ACCESS_COUNT=0
NVIDIA_DEVICE_COUNT=0
for dev in /dev/nvidia*; do
if [ -e "$dev" ]; then
NVIDIA_DEVICE_COUNT=$((NVIDIA_DEVICE_COUNT + 1))
if check_user_device_access "$dev" "$POSTGRES_USER"; then
NVIDIA_ACCESS_COUNT=$((NVIDIA_ACCESS_COUNT + 1))
HAS_NVIDIA_ACCESS=true
fi
fi
done
# Summary of device access
if [ $DRI_DEVICE_COUNT -gt 0 ]; then
if [ $DRI_ACCESS_COUNT -eq $DRI_DEVICE_COUNT ]; then
echo "✅ User $POSTGRES_USER has access to all DRI devices ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)"
echo " VAAPI hardware acceleration should work properly."
else
echo "⚠️ User $POSTGRES_USER has limited access to DRI devices ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)"
echo " VAAPI hardware acceleration may not work properly."
echo " Consider adding $POSTGRES_USER to the 'video' and/or 'render' groups."
fi
fi
if [ $NVIDIA_DEVICE_COUNT -gt 0 ]; then
if [ $NVIDIA_ACCESS_COUNT -eq $NVIDIA_DEVICE_COUNT ]; then
echo "✅ User $POSTGRES_USER has access to all NVIDIA devices ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)"
echo " NVIDIA hardware acceleration should work properly."
else
echo "⚠️ User $POSTGRES_USER has limited access to NVIDIA devices ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)"
echo " NVIDIA hardware acceleration may not work properly."
if [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = false ]; then
echo " Consider adding $POSTGRES_USER to the 'video' group or use NVIDIA Container Toolkit."
fi
fi
fi
# Check for traditional group memberships (as additional information)
USER_IN_VIDEO_GROUP=false
USER_IN_RENDER_GROUP=false
if [ -n "$VIDEO_GID" ]; then
if id -nG "$POSTGRES_USER" 2>/dev/null | grep -qw "video"; then
USER_IN_VIDEO_GROUP=true
echo " User $POSTGRES_USER is in the 'video' group (GID $VIDEO_GID)."
fi
fi
if [ -n "$RENDER_GID" ]; then
if id -nG "$POSTGRES_USER" 2>/dev/null | grep -qw "render"; then
USER_IN_RENDER_GROUP=true
echo " User $POSTGRES_USER is in the 'render' group (GID $RENDER_GID)."
fi
fi
# Check if NVIDIA Container Toolkit is present through environment or CLI tool
# IMPORTANT: Only mark as found if both env vars AND actual NVIDIA devices exist
if [ "$NVIDIA_FOUND" = true ] && command -v nvidia-container-cli >/dev/null 2>&1; then
NVIDIA_CONTAINER_TOOLKIT_FOUND=true
# Check for environment variables set by NVIDIA Container Runtime, but only if NVIDIA hardware exists
elif [ "$NVIDIA_FOUND" = true ] && [ -n "$NVIDIA_VISIBLE_DEVICES" ] && [ -n "$NVIDIA_DRIVER_CAPABILITIES" ]; then
NVIDIA_CONTAINER_TOOLKIT_FOUND=true
echo "✅ NVIDIA Container Toolkit detected (via environment variables)."
echo " The container is properly configured with Docker Compose's 'driver: nvidia' syntax."
elif [ -n "$NVIDIA_VISIBLE_DEVICES" ] && [ -n "$NVIDIA_DRIVER_CAPABILITIES" ] && [ "$NVIDIA_FOUND" = false ]; then
NVIDIA_ENV_MISMATCH=true
fi
# Removed duplicate video group checks here - consolidated into the earlier checks that include GID
# Check NVIDIA Container Toolkit support
echo "🔍 Checking NVIDIA container runtime support..."
# More reliable detection of NVIDIA Container Runtime
NVIDIA_RUNTIME_ACTIVE=false
# Method 1: Check for nvidia-container-cli tool
if command -v nvidia-container-cli >/dev/null 2>&1; then
NVIDIA_RUNTIME_ACTIVE=true
echo "✅ NVIDIA Container Runtime detected (nvidia-container-cli found)."
if nvidia-container-cli info >/dev/null 2>&1; then
echo "✅ NVIDIA container runtime is functional."
else
echo "⚠️ nvidia-container-cli found, but 'info' command failed. Runtime may be misconfigured."
fi
fi
# Method 2: Check for NVIDIA Container Runtime specific files
if [ -e "/dev/.nv" ] || [ -e "/.nv" ] || [ -e "/.nvidia-container-runtime" ]; then
NVIDIA_RUNTIME_ACTIVE=true
echo "✅ NVIDIA Container Runtime files detected."
fi
# Method 3: Check cgroup information for NVIDIA
if grep -q "nvidia" /proc/self/cgroup 2>/dev/null; then
NVIDIA_RUNTIME_ACTIVE=true
echo "✅ NVIDIA Container Runtime cgroups detected."
fi
# Final verdict based on hardware AND runtime with improved messaging
if [ "$NVIDIA_FOUND" = true ] && ([ "$NVIDIA_RUNTIME_ACTIVE" = true ] || [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = true ]); then
echo "✅ NVIDIA Container Runtime is properly configured with hardware access."
elif [ "$NVIDIA_FOUND" = true ] && [ "$NVIDIA_RUNTIME_ACTIVE" = false ] && [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = false ]; then
echo " NVIDIA devices accessible via direct passthrough instead of Container Runtime."
echo " This works but consider using the 'deploy: resources: reservations: devices:' method in docker-compose."
elif [ "$NVIDIA_FOUND" = false ] && [ "$NVIDIA_RUNTIME_ACTIVE" = true ]; then
echo "⚠️ NVIDIA Container Runtime appears to be configured, but no NVIDIA devices found."
echo " Check that your host has NVIDIA drivers installed and GPUs are properly passed to the container."
elif [ "$DRI_DEVICES_FOUND" = true ] && [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then
echo " Limited GPU access: Only DRI devices available for NVIDIA hardware."
echo " VAAPI acceleration may work but NVENC/CUDA won't be available."
echo " For full NVIDIA capabilities, configure the NVIDIA Container Runtime."
elif [ "$DRI_DEVICES_FOUND" = true ]; then
echo " Using Intel/AMD GPU hardware for acceleration via VAAPI."
else
echo "⚠️ No GPU acceleration devices detected. CPU-only transcoding will be used."
fi
# Run nvidia-smi if available
if command -v nvidia-smi >/dev/null 2>&1; then
echo "🔍 Running nvidia-smi to verify GPU visibility..."
if nvidia-smi >/dev/null 2>&1; then
echo "✅ nvidia-smi successful - GPU is accessible to container!"
echo " This confirms hardware acceleration should be available to FFmpeg."
else
echo "⚠️ nvidia-smi command failed. GPU may not be properly mapped into container."
fi
else
echo " nvidia-smi not installed or not in PATH."
fi
# Show relevant environment variables with contextual suggestions
echo "🔍 Checking GPU-related environment variables..."
# Set flags based on device detection
DRI_DEVICES_FOUND=false
for dev in /dev/dri/renderD* /dev/dri/card*; do
if [ -e "$dev" ];then
DRI_DEVICES_FOUND=true
break
fi
done
# Give contextual suggestions based on detected hardware
if [ "$DRI_DEVICES_FOUND" = true ]; then
# Detect Intel/AMD GPU model - skip this if we already detected GPUs earlier
if [ "$NVIDIA_GPU_IN_LSPCI" = false ] && [ "$INTEL_GPU_IN_LSPCI" = false ] && [ "$AMD_GPU_IN_LSPCI" = false ] && command -v lspci >/dev/null 2>&1; then
GPU_INFO=$(lspci -nn | grep -i "VGA\|Display" | head -1)
if [ -n "$GPU_INFO" ]; then
echo "🔍 Detected GPU: $GPU_INFO"
# Extract model for cleaner display in summary
GPU_MODEL=$(echo "$GPU_INFO" | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //' | sed 's/Technologies //')
fi
else
# Use already detected GPU model info
if [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then
GPU_MODEL=$NVIDIA_MODEL
elif [ "$INTEL_GPU_IN_LSPCI" = true ]; then
GPU_MODEL=$INTEL_MODEL
elif [ "$AMD_GPU_IN_LSPCI" = true ]; then
GPU_MODEL=$AMD_MODEL
fi
fi
if [ -n "$GPU_MODEL" ]; then
echo "🔍 GPU model: $GPU_MODEL"
fi
# Check for LIBVA_DRIVER_NAME environment variable
if [ -n "$LIBVA_DRIVER_NAME" ]; then
echo " LIBVA_DRIVER_NAME is set to '$LIBVA_DRIVER_NAME'"
echo " Note: If you experience issues with hardware acceleration, try removing this"
echo " environment variable to let the system auto-detect the appropriate driver."
else
# Check if we can detect the GPU type
if command -v lspci >/dev/null 2>&1; then
echo " VAAPI driver auto-detection is usually reliable. Settings below only needed if you experience issues."
# Create variables to store recommended driver and supported methods
INTEL_RECOMMENDED_DRIVER=""
INTEL_SUPPORTS_QSV=false
# Use the Intel model information we already captured
if [ "$INTEL_GPU_IN_LSPCI" = true ] && [ -n "$INTEL_MODEL" ]; then
# Check for newer Intel generations that use iHD
if echo "$INTEL_MODEL" | grep -q -E "Arc|Xe|Alchemist|Tiger|Alder|Raptor|Meteor|Gen1[2-9]"; then
echo "💡 Detected Intel GPU that supports iHD (e.g. Gen12+/Arc/Xe)"
echo " Recommended: LIBVA_DRIVER_NAME=iHD"
echo " Note: Only set this environment variable if hardware acceleration doesn't work by default"
INTEL_RECOMMENDED_DRIVER="iHD"
INTEL_SUPPORTS_QSV=true
elif echo "$INTEL_MODEL" | grep -q -E "Coffee|Whiskey|Comet|Gen11"; then
echo "💡 Detected Intel GPU that supports both i965 and iHD (e.g. Gen9.5/Gen11)"
echo " Preferred: LIBVA_DRIVER_NAME=iHD"
echo " Recommended: Try i965 only if iHD has compatibility issues"
echo " Note: Only set this environment variable if hardware acceleration doesn't work by default"
INTEL_RECOMMENDED_DRIVER="iHD"
INTEL_SUPPORTS_QSV=true
elif echo "$INTEL_MODEL" | grep -q -E "Haswell|Broadwell|Skylake|Kaby"; then
echo "💡 Detected Intel GPU that supports i965 (e.g. Gen9 and below)"
echo " Recommended: Set LIBVA_DRIVER_NAME=i965"
echo " Note: Only set this environment variable if hardware acceleration doesn't work by default"
INTEL_RECOMMENDED_DRIVER="i965"
# Older Intel GPUs support QSV through i965 driver but with more limitations
INTEL_SUPPORTS_QSV=false
else
# Generic Intel case - we're not fully confident in our recommendation
echo "💡 Unable to definitively identify Intel GPU generation"
echo " Try auto-detection first (no environment variable)"
echo " If issues occur: Try LIBVA_DRIVER_NAME=iHD first (newer GPUs)"
echo " If that fails: Try LIBVA_DRIVER_NAME=i965 (older GPUs)"
INTEL_RECOMMENDED_DRIVER="unknown" # Mark as unknown rather than assuming
INTEL_SUPPORTS_QSV="maybe" # Mark as maybe instead of assuming true
fi
elif [ "$AMD_GPU_IN_LSPCI" = true ]; then
echo "💡 If auto-detection fails: Set LIBVA_DRIVER_NAME=radeonsi for AMD GPUs"
echo " Note: Only set this environment variable if hardware acceleration doesn't work by default"
else
echo " Common VAAPI driver options if auto-detection fails:"
echo " - For modern Intel GPUs (Gen12+/Arc/Xe): LIBVA_DRIVER_NAME=iHD"
echo " - For older Intel GPUs: LIBVA_DRIVER_NAME=i965"
echo " - For AMD GPUs: LIBVA_DRIVER_NAME=radeonsi"
echo " Note: Only set these environment variables if hardware acceleration doesn't work by default"
fi
else
echo " Intel/AMD GPU detected. Auto-detection should work in most cases."
echo " If VAAPI doesn't work, you may need to set LIBVA_DRIVER_NAME manually."
fi
fi
fi
# Check FFmpeg hardware acceleration support
echo "🔍 Checking FFmpeg hardware acceleration capabilities..."
if command -v ffmpeg >/dev/null 2>&1; then
HWACCEL=$(ffmpeg -hide_banner -hwaccels 2>/dev/null | grep -v "Hardware acceleration methods:" || echo "None found")
# Initialize variables to store compatible and missing methods
COMPATIBLE_METHODS=""
MISSING_METHODS=""
# Format the list of hardware acceleration methods in a more readable way
echo "🔍 Available FFmpeg hardware acceleration methods:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Process the list into a more readable format with relevance indicators
if [ -n "$HWACCEL" ] && [ "$HWACCEL" != "None found" ]; then
# First, show methods compatible with detected hardware
echo " 📌 Compatible with your hardware:"
COMPATIBLE_FOUND=false
for method in $HWACCEL; do
# Skip if it's just the header line or empty
if [ "$method" = "Hardware" ] || [ -z "$method" ]; then
continue
fi
# Check if this method is relevant to detected hardware
IS_COMPATIBLE=false
DESCRIPTION=""
if [ "$NVIDIA_FOUND" = true ] && [[ "$method" =~ ^(cuda|cuvid|nvenc|nvdec)$ ]]; then
IS_COMPATIBLE=true
DESCRIPTION="NVIDIA GPU acceleration"
elif [ "$INTEL_GPU_IN_LSPCI" = true ] && [ "$method" = "qsv" ] && [ "$INTEL_SUPPORTS_QSV" = true ]; then
IS_COMPATIBLE=true
DESCRIPTION="Intel QuickSync acceleration"
elif [ "$method" = "vaapi" ] && (([ "$INTEL_GPU_IN_LSPCI" = true ] || [ "$AMD_GPU_IN_LSPCI" = true ]) && [ "$DRI_DEVICES_FOUND" = true ]); then
IS_COMPATIBLE=true
if [ "$INTEL_GPU_IN_LSPCI" = true ]; then
DESCRIPTION="Intel VAAPI acceleration"
else
DESCRIPTION="AMD VAAPI acceleration"
fi
fi
# Display compatible methods and store for summary
if [ "$IS_COMPATIBLE" = true ]; then
COMPATIBLE_FOUND=true
COMPATIBLE_METHODS="$COMPATIBLE_METHODS $method"
echo "$method - $DESCRIPTION"
fi
done
if [ "$COMPATIBLE_FOUND" = false ]; then
echo " ❌ No compatible acceleration methods found for your hardware"
fi
# Then show all other available methods
echo " 📌 Other available methods (not compatible with detected hardware):"
OTHER_FOUND=false
for method in $HWACCEL; do
# Skip if it's just the header line or empty
if [ "$method" = "Hardware" ] || [ -z "$method" ]; then
continue
fi
# Check if this method is relevant to detected hardware
IS_COMPATIBLE=false
if [ "$NVIDIA_FOUND" = true ] && [[ "$method" =~ ^(cuda|cuvid|nvenc|nvdec)$ ]]; then
IS_COMPATIBLE=true
elif [ "$INTEL_GPU_IN_LSPCI" = true ] && [ "$method" = "qsv" ] && [ "$INTEL_SUPPORTS_QSV" = true ]; then
IS_COMPATIBLE=true
elif [ "$method" = "vaapi" ] && (([ "$INTEL_GPU_IN_LSPCI" = true ] || [ "$AMD_GPU_IN_LSPCI" = true ]) && [ "$DRI_DEVICES_FOUND" = true ]); then
IS_COMPATIBLE=true
fi
# Display other methods that aren't compatible
if [ "$IS_COMPATIBLE" = false ]; then
OTHER_FOUND=true
echo " $method"
fi
done
if [ "$OTHER_FOUND" = false ]; then
echo " None"
fi
# Show expected methods that are missing
echo " 📌 Missing methods that should be available for your hardware:"
MISSING_FOUND=false
# Check for NVIDIA methods if NVIDIA GPU is detected
if [ "$NVIDIA_FOUND" = true ]; then
EXPECTED_NVIDIA="cuda" # cuvid nvenc nvdec" keeping these in case future support is added
for method in $EXPECTED_NVIDIA; do
if ! echo "$HWACCEL" | grep -q "$method"; then
MISSING_FOUND=true
MISSING_METHODS="$MISSING_METHODS $method"
echo " ⚠️ $method - NVIDIA acceleration (missing but should be available)"
fi
done
fi
# Check for Intel methods if Intel GPU is detected
if [ "$INTEL_GPU_IN_LSPCI" = true ] && [ "$DRI_DEVICES_FOUND" = true ]; then
if [ "$INTEL_SUPPORTS_QSV" = true ] && ! echo "$HWACCEL" | grep -q "qsv"; then
MISSING_FOUND=true
MISSING_METHODS="$MISSING_METHODS qsv"
echo " ⚠️ qsv - Intel QuickSync acceleration (missing but should be available)"
fi
if ! echo "$HWACCEL" | grep -q "vaapi"; then
MISSING_FOUND=true
MISSING_METHODS="$MISSING_METHODS vaapi"
echo " ⚠️ vaapi - Intel VAAPI acceleration (missing but should be available)"
fi
fi
# Check for AMD methods if AMD GPU is detected
if [ "$AMD_GPU_IN_LSPCI" = true ] && [ "$DRI_DEVICES_FOUND" = true ]; then
if ! echo "$HWACCEL" | grep -q "vaapi"; then
MISSING_FOUND=true
MISSING_METHODS="$MISSING_METHODS vaapi"
echo " ⚠️ vaapi - AMD VAAPI acceleration (missing but should be available)"
fi
fi
if [ "$MISSING_FOUND" = false ]; then
echo " None - All expected methods are available"
fi
else
echo " ❌ No hardware acceleration methods found"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Show hardware-appropriate method summary using the already gathered information
if [ -n "$COMPATIBLE_METHODS" ]; then
echo "✅ Hardware-appropriate acceleration methods available:$COMPATIBLE_METHODS"
fi
# Show missing expected methods
if [ -n "$MISSING_METHODS" ]; then
echo "⚠️ Expected acceleration methods not found:$MISSING_METHODS"
echo " This might indicate missing libraries or improper driver configuration."
fi
# Display specific cases of interest (simplify using previously captured information)
if [ "$NVIDIA_FOUND" = true ] && ! echo "$COMPATIBLE_METHODS" | grep -q "cuda\|nvenc\|cuvid"; then
echo "⚠️ NVIDIA GPU detected but no NVIDIA acceleration methods available."
echo " Ensure ffmpeg is built with NVIDIA support and required libraries are installed."
fi
if (([ "$INTEL_GPU_IN_LSPCI" = true ] || [ "$AMD_GPU_IN_LSPCI" = true ]) &&
[ "$DRI_DEVICES_FOUND" = true ] && ! echo "$COMPATIBLE_METHODS" | grep -q "vaapi"); then
echo "⚠️ Intel/AMD GPU detected but VAAPI acceleration not available."
echo " Ensure ffmpeg is built with VAAPI support and proper drivers are installed."
fi
else
echo "⚠️ FFmpeg not found in PATH."
fi
# Provide a final summary of the hardware acceleration setup
echo "📋 ===================== SUMMARY ====================="
# Identify which GPU type is active and working
if [ "$NVIDIA_FOUND" = true ] && (nvidia-smi >/dev/null 2>&1 || [ -n "$NVIDIA_VISIBLE_DEVICES" ]); then
if [ -n "$NVIDIA_MODEL" ]; then
echo "🔰 NVIDIA GPU: $NVIDIA_MODEL"
else
echo "🔰 NVIDIA GPU: ACTIVE (model detection unavailable)"
echo " Note: GPU model information couldn't be retrieved, but devices are present."
echo " This may be due to missing nvidia-smi tool or container limitations."
fi
if [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = true ]; then
echo "✅ NVIDIA Container Toolkit: CONFIGURED CORRECTLY"
elif [ -n "$NVIDIA_VISIBLE_DEVICES" ] && [ -n "$NVIDIA_DRIVER_CAPABILITIES" ]; then
echo "✅ NVIDIA Docker configuration: USING MODERN DEPLOYMENT"
else
echo "⚠️ NVIDIA setup method: DIRECT DEVICE MAPPING (functional but not optimal)"
fi
# Add device accessibility status
if [ $NVIDIA_DEVICE_COUNT -gt 0 ]; then
if [ $NVIDIA_ACCESS_COUNT -eq $NVIDIA_DEVICE_COUNT ]; then
echo "✅ Device access: ALL NVIDIA DEVICES ACCESSIBLE ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)"
else
echo "⚠️ Device access: LIMITED NVIDIA DEVICE ACCESS ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)"
echo " Some hardware acceleration features may not work properly."
fi
fi
# Display FFmpeg NVIDIA acceleration methods in more detail
if echo "$COMPATIBLE_METHODS" | grep -q "cuda\|nvenc\|cuvid"; then
echo "✅ FFmpeg NVIDIA acceleration: AVAILABLE"
# Show detailed breakdown of available NVIDIA methods
NVIDIA_METHODS=$(echo "$COMPATIBLE_METHODS" | grep -o '\(cuda\|cuvid\|nvenc\|nvdec\)')
echo " Available NVIDIA methods: $NVIDIA_METHODS"
echo " Recommended for: Video transcoding with NVIDIA GPUs"
else
echo "⚠️ FFmpeg NVIDIA acceleration: NOT DETECTED"
if [ -n "$MISSING_METHODS" ]; then
echo " Missing methods that should be available: $MISSING_METHODS"
fi
fi
elif [ "$NVIDIA_GPU_IN_LSPCI" = true ] && [ "$DRI_DEVICES_FOUND" = true ]; then
# NVIDIA through DRI only (suboptimal but possible)
if [ -n "$NVIDIA_MODEL" ]; then
echo "🔰 NVIDIA GPU: $NVIDIA_MODEL (SUBOPTIMALLY CONFIGURED)"
else
echo "🔰 NVIDIA GPU: DETECTED BUT SUBOPTIMALLY CONFIGURED"
fi
echo "⚠️ Your NVIDIA GPU is only accessible through DRI devices"
echo " - VAAPI acceleration may work for some tasks"
echo " - NVENC/CUDA acceleration is NOT available"
# Add device accessibility status
if [ $DRI_DEVICE_COUNT -gt 0 ]; then
if [ $DRI_ACCESS_COUNT -eq $DRI_DEVICE_COUNT ]; then
echo "✅ Device access: ALL DRI DEVICES ACCESSIBLE ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)"
echo " VAAPI acceleration should work properly."
else
echo "⚠️ Device access: LIMITED DRI DEVICE ACCESS ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)"
echo " VAAPI acceleration may not work properly."
fi
fi
echo "💡 RECOMMENDATION: Use the proper NVIDIA container configuration:"
echo " deploy:"
echo " resources:"
echo " reservations:"
echo " devices:"
echo " - driver: nvidia"
echo " count: all"
echo " capabilities: [gpu]"
if echo "$COMPATIBLE_METHODS" | grep -q "vaapi"; then
echo "✅ FFmpeg VAAPI acceleration: AVAILABLE (limited without NVENC)"
echo " VAAPI can be used for transcoding, but NVENC/CUDA would be more efficient"
else
echo "⚠️ FFmpeg VAAPI acceleration: NOT DETECTED"
fi
elif [ "$DRI_DEVICES_FOUND" = true ]; then
# Intel/AMD detection with model if available
if [ -n "$GPU_MODEL" ]; then
echo "🔰 GPU: $GPU_MODEL"
elif [ -n "$LIBVA_DRIVER_NAME" ]; then
echo "🔰 ${LIBVA_DRIVER_NAME^^} GPU: ACTIVE"
else
echo "🔰 INTEL/AMD GPU: ACTIVE (model detection unavailable)"
echo " Note: Basic GPU drivers appear to be loaded (device nodes exist), but"
echo " couldn't identify specific model. This doesn't necessarily indicate a problem."
fi
# Add device accessibility status
if [ $DRI_DEVICE_COUNT -gt 0 ]; then
if [ $DRI_ACCESS_COUNT -eq $DRI_DEVICE_COUNT ]; then
echo "✅ Device access: ALL DRI DEVICES ACCESSIBLE ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)"
echo " VAAPI hardware acceleration should work properly."
else
echo "⚠️ Device access: LIMITED DRI DEVICE ACCESS ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)"
echo " VAAPI hardware acceleration may not work properly."
fi
fi
# Display FFmpeg VAAPI acceleration method with more details
if echo "$COMPATIBLE_METHODS" | grep -q "vaapi"; then
echo "✅ FFmpeg VAAPI acceleration: AVAILABLE"
# Add recommended usage information
echo " Recommended for: General video transcoding with Intel/AMD GPUs"
# Add recommended driver information for Intel GPUs
if [ "$INTEL_GPU_IN_LSPCI" = true ] && [ -n "$INTEL_RECOMMENDED_DRIVER" ]; then
if [ "$INTEL_RECOMMENDED_DRIVER" = "unknown" ]; then
echo " Uncertain about recommended VAAPI driver for this Intel GPU"
echo " Auto-detection should work, but if issues occur try iHD or i965"
else
echo " Recommended VAAPI driver for this Intel GPU: $INTEL_RECOMMENDED_DRIVER"
fi
if [ "$INTEL_SUPPORTS_QSV" = true ] && echo "$COMPATIBLE_METHODS" | grep -q "qsv"; then
echo "✅ QSV acceleration: AVAILABLE"
echo " Recommended for: Intel-specific optimized transcoding"
echo " Works best with: $INTEL_RECOMMENDED_DRIVER driver"
elif [ "$INTEL_SUPPORTS_QSV" = true ]; then
echo " QSV acceleration: NOT DETECTED (may be available with proper configuration)"
echo " Your Intel GPU supports QSV but it's not available in FFmpeg"
echo " Check if FFmpeg is built with QSV support"
elif [ "$INTEL_SUPPORTS_QSV" = "maybe" ]; then
echo " QSV acceleration: MAY BE AVAILABLE (depends on exact GPU model)"
fi
elif [ "$AMD_GPU_IN_LSPCI" = true ]; then
echo " Recommended VAAPI driver for AMD GPUs: radeonsi"
fi
else
echo "⚠️ FFmpeg VAAPI acceleration: NOT DETECTED"
if [ -n "$MISSING_METHODS" ]; then
echo " Missing methods that should be available: $MISSING_METHODS"
fi
fi
else
echo "❌ NO GPU ACCELERATION DETECTED"
echo "⚠️ Hardware acceleration is unavailable or misconfigured"
fi
echo "📋 =================================================="
echo "✅ GPU detection script complete."

View file

@ -8,8 +8,8 @@ exec-before = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services
attach-daemon = celery -A dispatcharr worker -l debug
attach-daemon = celery -A dispatcharr beat -l info
attach-daemon = celery -A dispatcharr worker
attach-daemon = celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
attach-daemon = cd /app/frontend && npm run dev
@ -19,6 +19,7 @@ module = scripts.debug_wrapper:application
virtualenv = /dispatcharrpy
master = true
env = DJANGO_SETTINGS_MODULE=dispatcharr.settings
socket = /app/uwsgi.sock
chmod-socket = 777
vacuum = true
@ -58,9 +59,6 @@ ignore-sigpipe = true
ignore-write-errors = true
disable-write-exception = true
# Explicitly disable for-server option that confuses debugpy
for-server = false
# Debugging settings
py-autoreload = 1
honour-stdin = true
@ -78,4 +76,10 @@ env = DEBUGPY_LOG_DIR=/app/debugpy_logs
env = WAIT_FOR_DEBUGGER=false
env = DEBUG_TIMEOUT=30
# Enable console logging (stdout)
log-master = true
# Enable strftime formatting for timestamps
logformat-strftime = true
log-date = %%Y-%%m-%%d %%H:%%M:%%S,000
# Use the environment variable in log format - ensure consistent formatting with other files
log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms

View file

@ -1,6 +1,8 @@
[uwsgi]
; exec-before = python manage.py collectstatic --noinput
; exec-before = python manage.py migrate --noinput
; Remove file creation commands since we're not logging to files anymore
; exec-pre = mkdir -p /data/logs
; exec-pre = touch /data/logs/uwsgi-dev.log
; exec-pre = chmod 666 /data/logs/uwsgi-dev.log
; First run Redis availability check script once
exec-pre = python /app/scripts/wait_for_redis.py
@ -8,8 +10,8 @@ exec-pre = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services
attach-daemon = celery -A dispatcharr worker -l debug --concurrency=4
attach-daemon = celery -A dispatcharr beat -l debug
attach-daemon = celery -A dispatcharr worker --concurrency=4
attach-daemon = celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
attach-daemon = cd /app/frontend && npm run dev
@ -47,3 +49,12 @@ thunder-lock = true
log-4xx = true
log-5xx = true
disable-logging = false
# Logging configuration - development mode
# Enable console logging (stdout)
log-master = true
# Enable strftime formatting for timestamps
logformat-strftime = true
log-date = %%Y-%%m-%%d %%H:%%M:%%S,000
# Use formatted time with environment variable for log level
log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms

View file

@ -1,6 +1,8 @@
[uwsgi]
; exec-before = python manage.py collectstatic --noinput
; exec-before = python manage.py migrate --noinput
; Remove file creation commands since we're not logging to files anymore
; exec-pre = mkdir -p /data/logs
; exec-pre = touch /data/logs/uwsgi.log
; exec-pre = chmod 666 /data/logs/uwsgi.log
; First run Redis availability check script once
exec-pre = python /app/scripts/wait_for_redis.py
@ -8,8 +10,8 @@ exec-pre = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
; Then start other services
attach-daemon = celery -A dispatcharr worker -l info --concurrency=4
attach-daemon = celery -A dispatcharr beat -l error
attach-daemon = celery -A dispatcharr worker --concurrency=4
attach-daemon = celery -A dispatcharr beat
attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application
# Core settings
@ -45,3 +47,12 @@ thunder-lock = true
log-4xx = true
log-5xx = true
disable-logging = false
# Logging configuration
# Enable console logging (stdout)
log-master = true
# Enable strftime formatting for timestamps
logformat-strftime = true
log-date = %%Y-%%m-%%d %%H:%%M:%%S,000
# Use formatted time with environment variable for log level
log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms

View file

@ -7,7 +7,6 @@ import React, {
useMemo,
useCallback,
} from 'react';
import useStreamsStore from './store/streams';
import { notifications } from '@mantine/notifications';
import useChannelsStore from './store/channels';
import usePlaylistsStore from './store/playlists';
@ -15,6 +14,7 @@ import useEPGsStore from './store/epgs';
import { Box, Button, Stack, Alert, Group } from '@mantine/core';
import API from './api';
import useSettingsStore from './store/settings';
import useAuthStore from './store/auth';
export const WebsocketContext = createContext([false, () => { }, null]);
@ -28,10 +28,22 @@ export const WebsocketProvider = ({ children }) => {
const maxReconnectAttempts = 5;
const initialBackoffDelay = 1000; // 1 second initial delay
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const epgs = useEPGsStore((s) => s.epgs);
const updateEPG = useEPGsStore((s) => s.updateEPG);
const updateEPGProgress = useEPGsStore((s) => s.updateEPGProgress);
const playlists = usePlaylistsStore((s) => s.playlists);
const updatePlaylist = usePlaylistsStore((s) => s.updatePlaylist);
// Calculate reconnection delay with exponential backoff
const getReconnectDelay = useCallback(() => {
return Math.min(initialBackoffDelay * Math.pow(1.5, reconnectAttempts), 30000); // max 30 seconds
return Math.min(
initialBackoffDelay * Math.pow(1.5, reconnectAttempts),
30000
); // max 30 seconds
}, [reconnectAttempts]);
// Clear any existing reconnect timers
@ -50,15 +62,15 @@ export const WebsocketProvider = ({ children }) => {
// In development mode, connect directly to the WebSocket server on port 8001
if (env_mode === 'dev') {
return `${protocol}//${host}:8001/ws/`;
return `${protocol}//${host}:8001/ws/?token=${accessToken}`;
} else {
// In production mode, use the same port as the main application
// This allows nginx to handle the WebSocket forwarding
return appPort
? `${protocol}//${host}:${appPort}/ws/`
: `${protocol}//${host}/ws/`;
? `${protocol}//${host}:${appPort}/ws/?token=${accessToken}`
: `${protocol}//${host}/ws/?token=${accessToken}`;
}
}, [env_mode]);
}, [env_mode, accessToken]);
// Function to handle websocket connection
const connectWebSocket = useCallback(() => {
@ -76,12 +88,14 @@ export const WebsocketProvider = ({ children }) => {
try {
ws.current.close();
} catch (e) {
console.warn("Error closing existing WebSocket:", e);
console.warn('Error closing existing WebSocket:', e);
}
}
try {
console.log(`Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`);
console.log(
`Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`
);
// Use the function to get the correct WebSocket URL
const wsUrl = getWebSocketUrl();
@ -91,42 +105,50 @@ export const WebsocketProvider = ({ children }) => {
const socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log("WebSocket connected successfully");
console.log('WebSocket connected successfully');
setIsReady(true);
setConnectionError(null);
setReconnectAttempts(0);
};
socket.onerror = (error) => {
console.error("WebSocket connection error:", error);
console.error('WebSocket connection error:', error);
// Don't show error notification on initial page load,
// only show it after a connection was established then lost
if (reconnectAttempts > 0 || isReady) {
setConnectionError("Failed to connect to WebSocket server.");
setConnectionError('Failed to connect to WebSocket server.');
} else {
console.log("Initial connection attempt failed, will retry...");
console.log('Initial connection attempt failed, will retry...');
}
};
socket.onclose = (event) => {
console.warn("WebSocket connection closed", event);
console.warn('WebSocket connection closed', event);
setIsReady(false);
// Only attempt reconnect if we haven't reached max attempts
if (reconnectAttempts < maxReconnectAttempts) {
const delay = getReconnectDelay();
setConnectionError(`Connection lost. Reconnecting in ${Math.ceil(delay / 1000)} seconds...`);
console.log(`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`);
setConnectionError(
`Connection lost. Reconnecting in ${Math.ceil(delay / 1000)} seconds...`
);
console.log(
`Scheduling reconnect in ${delay}ms (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})...`
);
// Store timer reference so we can cancel it if needed
reconnectTimerRef.current = setTimeout(() => {
setReconnectAttempts(prev => prev + 1);
setReconnectAttempts((prev) => prev + 1);
connectWebSocket();
}, delay);
} else {
setConnectionError("Maximum reconnection attempts reached. Please reload the page.");
console.error("Maximum reconnection attempts reached. WebSocket connection failed.");
setConnectionError(
'Maximum reconnection attempts reached. Please reload the page.'
);
console.error(
'Maximum reconnection attempts reached. WebSocket connection failed.'
);
}
};
@ -137,7 +159,10 @@ export const WebsocketProvider = ({ children }) => {
// Handle connection_established event
if (parsedEvent.type === 'connection_established') {
console.log('WebSocket connection established:', parsedEvent.data?.message);
console.log(
'WebSocket connection established:',
parsedEvent.data?.message
);
// Don't need to do anything else for this event type
return;
}
@ -167,8 +192,9 @@ export const WebsocketProvider = ({ children }) => {
// Update the playlist status whenever we receive a status update
// Not just when progress is 100% or status is pending_setup
if (parsedEvent.data.status && parsedEvent.data.account) {
const playlistsState = usePlaylistsStore.getState();
const playlist = playlistsState.playlists.find(p => p.id === parsedEvent.data.account);
const playlist = playlists.find(
(p) => p.id === parsedEvent.data.account
);
if (playlist) {
// When we receive a "success" status with 100% progress, this is a completed refresh
@ -176,15 +202,19 @@ export const WebsocketProvider = ({ children }) => {
const updateData = {
...playlist,
status: parsedEvent.data.status,
last_message: parsedEvent.data.message || playlist.last_message
last_message:
parsedEvent.data.message || playlist.last_message,
};
// Update the timestamp when we complete a successful refresh
if (parsedEvent.data.status === 'success' && parsedEvent.data.progress === 100) {
if (
parsedEvent.data.status === 'success' &&
parsedEvent.data.progress === 100
) {
updateData.updated_at = new Date().toISOString();
}
playlistsState.updatePlaylist(updateData);
updatePlaylist(updateData);
}
}
break;
@ -201,12 +231,11 @@ export const WebsocketProvider = ({ children }) => {
// If source_id is provided, update that specific EPG's status
if (parsedEvent.data.source_id) {
const epgsState = useEPGsStore.getState();
const epg = epgsState.epgs[parsedEvent.data.source_id];
const epg = epgs[parsedEvent.data.source_id];
if (epg) {
epgsState.updateEPG({
updateEPG({
...epg,
status: 'success'
status: 'success',
});
}
}
@ -221,13 +250,19 @@ export const WebsocketProvider = ({ children }) => {
});
// Check if we have associations data and use the more efficient batch API
if (parsedEvent.data.associations && parsedEvent.data.associations.length > 0) {
if (
parsedEvent.data.associations &&
parsedEvent.data.associations.length > 0
) {
API.batchSetEPG(parsedEvent.data.associations);
}
break;
case 'm3u_profile_test':
setProfilePreview(parsedEvent.data.search_preview, parsedEvent.data.result);
setProfilePreview(
parsedEvent.data.search_preview,
parsedEvent.data.result
);
break;
case 'recording_started':
@ -254,13 +289,12 @@ export const WebsocketProvider = ({ children }) => {
// Update EPG status in store
if (parsedEvent.data.source_id) {
const epgsState = useEPGsStore.getState();
const epg = epgsState.epgs[parsedEvent.data.source_id];
const epg = epgs[parsedEvent.data.source_id];
if (epg) {
epgsState.updateEPG({
updateEPG({
...epg,
status: 'error',
last_message: parsedEvent.data.message
last_message: parsedEvent.data.message,
});
}
}
@ -268,28 +302,33 @@ export const WebsocketProvider = ({ children }) => {
case 'epg_refresh':
// Update the store with progress information
const epgsState = useEPGsStore.getState();
epgsState.updateEPGProgress(parsedEvent.data);
updateEPGProgress(parsedEvent.data);
// If we have source_id/account info, update the EPG source status
if (parsedEvent.data.source_id || parsedEvent.data.account) {
const sourceId = parsedEvent.data.source_id || parsedEvent.data.account;
const epg = epgsState.epgs[sourceId];
const sourceId =
parsedEvent.data.source_id || parsedEvent.data.account;
const epg = epgs[sourceId];
if (epg) {
// Check for any indication of an error (either via status or error field)
const hasError = parsedEvent.data.status === "error" ||
const hasError =
parsedEvent.data.status === 'error' ||
!!parsedEvent.data.error ||
(parsedEvent.data.message && parsedEvent.data.message.toLowerCase().includes("error"));
(parsedEvent.data.message &&
parsedEvent.data.message.toLowerCase().includes('error'));
if (hasError) {
// Handle error state
const errorMessage = parsedEvent.data.error || parsedEvent.data.message || "Unknown error occurred";
const errorMessage =
parsedEvent.data.error ||
parsedEvent.data.message ||
'Unknown error occurred';
epgsState.updateEPG({
updateEPG({
...epg,
status: 'error',
last_message: errorMessage
last_message: errorMessage,
});
// Show notification for the error
@ -301,14 +340,15 @@ export const WebsocketProvider = ({ children }) => {
}
// Update status on completion only if no errors
else if (parsedEvent.data.progress === 100) {
epgsState.updateEPG({
updateEPG({
...epg,
status: parsedEvent.data.status || 'success',
last_message: parsedEvent.data.message || epg.last_message
last_message:
parsedEvent.data.message || epg.last_message,
});
// Only show success notification if we've finished parsing programs and had no errors
if (parsedEvent.data.action === "parsing_programs") {
if (parsedEvent.data.action === 'parsing_programs') {
notifications.show({
title: 'EPG Processing Complete',
message: 'EPG data has been updated successfully',
@ -323,48 +363,70 @@ export const WebsocketProvider = ({ children }) => {
break;
default:
console.error(`Unknown websocket event type: ${parsedEvent.data?.type}`);
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`
);
break;
}
} catch (error) {
console.error('Error processing WebSocket message:', error, event.data);
console.error(
'Error processing WebSocket message:',
error,
event.data
);
}
};
ws.current = socket;
} catch (error) {
console.error("Error creating WebSocket connection:", error);
console.error('Error creating WebSocket connection:', error);
setConnectionError(`WebSocket error: ${error.message}`);
// Schedule a reconnect if we haven't reached max attempts
if (reconnectAttempts < maxReconnectAttempts) {
const delay = getReconnectDelay();
reconnectTimerRef.current = setTimeout(() => {
setReconnectAttempts(prev => prev + 1);
setReconnectAttempts((prev) => prev + 1);
connectWebSocket();
}, delay);
}
}
}, [reconnectAttempts, clearReconnectTimer, getReconnectDelay, getWebSocketUrl, isReady]);
}, [
reconnectAttempts,
clearReconnectTimer,
getReconnectDelay,
getWebSocketUrl,
isReady,
]);
// Initial connection and cleanup
useEffect(() => {
connectWebSocket();
// Only attempt to connect if the user is authenticated
if (isAuthenticated && accessToken) {
connectWebSocket();
} else if (ws.current) {
// Close the connection if the user logs out
clearReconnectTimer();
console.log('Closing WebSocket connection due to logout');
ws.current.onclose = null;
ws.current.close();
ws.current = null;
setIsReady(false);
}
return () => {
clearReconnectTimer(); // Clear any pending reconnect timers
if (ws.current) {
console.log("Closing WebSocket connection due to component unmount");
console.log('Closing WebSocket connection due to component unmount');
ws.current.onclose = null; // Remove handlers to avoid reconnection
ws.current.close();
ws.current = null;
}
};
}, [connectWebSocket, clearReconnectTimer]);
}, [connectWebSocket, clearReconnectTimer, isAuthenticated, accessToken]);
const setChannelStats = useChannelsStore((s) => s.setChannelStats);
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const setRefreshProgress = usePlaylistsStore((s) => s.setRefreshProgress);
const setProfilePreview = usePlaylistsStore((s) => s.setProfilePreview);
@ -377,22 +439,51 @@ export const WebsocketProvider = ({ children }) => {
return (
<WebsocketContext.Provider value={ret}>
{connectionError && !isReady && reconnectAttempts >= maxReconnectAttempts && (
<Alert color="red" title="WebSocket Connection Failed" style={{ position: 'fixed', bottom: 10, right: 10, zIndex: 1000, maxWidth: 350 }}>
{connectionError}
<Button size="xs" mt={10} onClick={() => {
setReconnectAttempts(0);
connectWebSocket();
}}>
Try Again
</Button>
</Alert>
)}
{connectionError && !isReady && reconnectAttempts < maxReconnectAttempts && reconnectAttempts > 0 && (
<Alert color="orange" title="WebSocket Reconnecting" style={{ position: 'fixed', bottom: 10, right: 10, zIndex: 1000, maxWidth: 350 }}>
{connectionError}
</Alert>
)}
{connectionError &&
!isReady &&
reconnectAttempts >= maxReconnectAttempts && (
<Alert
color="red"
title="WebSocket Connection Failed"
style={{
position: 'fixed',
bottom: 10,
right: 10,
zIndex: 1000,
maxWidth: 350,
}}
>
{connectionError}
<Button
size="xs"
mt={10}
onClick={() => {
setReconnectAttempts(0);
connectWebSocket();
}}
>
Try Again
</Button>
</Alert>
)}
{connectionError &&
!isReady &&
reconnectAttempts < maxReconnectAttempts &&
reconnectAttempts > 0 && (
<Alert
color="orange"
title="WebSocket Reconnecting"
style={{
position: 'fixed',
bottom: 10,
right: 10,
zIndex: 1000,
maxWidth: 350,
}}
>
{connectionError}
</Alert>
)}
{children}
</WebsocketContext.Provider>
);

View file

@ -203,7 +203,7 @@ const ChannelRowActions = React.memo(
}
);
const ChannelsTable = ({}) => {
const ChannelsTable = ({ }) => {
const theme = useMantineTheme();
/**
@ -790,8 +790,8 @@ const ChannelsTable = ({}) => {
return hasStreams
? {} // Default style for channels with streams
: {
className: 'no-streams-row', // Add a class instead of background color
};
className: 'no-streams-row', // Add a class instead of background color
};
},
});

View file

@ -84,7 +84,7 @@ const StreamRowActions = ({
const onEdit = useCallback(() => {
editStream(row.original);
}, [row.original.id, editStream]);
}, [row.original, editStream]);
const onDelete = useCallback(() => {
deleteStream(row.original.id);
@ -169,7 +169,7 @@ const StreamRowActions = ({
);
};
const StreamsTable = ({ }) => {
const StreamsTable = ({}) => {
const theme = useMantineTheme();
/**
@ -399,7 +399,14 @@ const StreamsTable = ({ }) => {
}
setIsLoading(false);
}, [pagination, sorting, debouncedFilters, groupsLoaded, channelGroups, fetchChannelGroups]);
}, [
pagination,
sorting,
debouncedFilters,
groupsLoaded,
channelGroups,
fetchChannelGroups,
]);
// Bulk creation: create channels from selected streams in one API call
const createChannelsFromStreams = async () => {
@ -592,7 +599,14 @@ const StreamsTable = ({ }) => {
);
}
},
[selectedChannelIds, channelSelectionStreams, theme, editStream, deleteStream, handleWatchStream]
[
selectedChannelIds,
channelSelectionStreams,
theme,
editStream,
deleteStream,
handleWatchStream,
]
);
const table = useTable({
@ -675,10 +689,10 @@ const StreamsTable = ({ }) => {
style={
selectedStreamIds.length > 0 && selectedChannelIds.length === 1
? {
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}
borderWidth: '1px',
borderColor: theme.tailwind.green[5],
color: 'white',
}
: undefined
}
disabled={

View file

@ -39,6 +39,7 @@ const useAuthStore = create((set, get) => ({
await Promise.all([
useChannelsStore.getState().fetchChannels(),
useChannelsStore.getState().fetchChannelGroups(),
useChannelsStore.getState().fetchChannelProfiles(),
usePlaylistsStore.getState().fetchPlaylists(),
useEPGsStore.getState().fetchEPGs(),
useEPGsStore.getState().fetchEPGData(),

View file

@ -27,7 +27,7 @@ logger.info(f"Files in current directory: {os.listdir()}")
logger.info(f"Python path: {sys.path}")
# Default timeout in seconds
DEBUG_TIMEOUT = int(os.environ.get('DEBUG_TIMEOUT', '30'))
DEBUG_TIMEOUT = int(os.environ.get('DEBUG_TIMEOUT', '60')) # Increased default timeout
# Whether to wait for debugger to attach
WAIT_FOR_DEBUGGER = os.environ.get('WAIT_FOR_DEBUGGER', 'false').lower() == 'true'
@ -40,7 +40,7 @@ try:
logger.info("Successfully imported debugpy")
# Critical: Configure debugpy to use regular Python for the adapter, not uwsgi
python_path = '/usr/local/bin/python3'
python_path = '/dispatcharrpy/bin/python'
if os.path.exists(python_path):
logger.info(f"Setting debugpy adapter to use Python interpreter: {python_path}")
debugpy.configure(python=python_path)
@ -50,21 +50,34 @@ try:
# Don't wait for connection, just set up the debugging session
logger.info("Initializing debugpy on 0.0.0.0:5678...")
try:
# Use connect instead of listen to avoid the adapter process
# Configure debugpy to listen without socket timeout initially
debugpy.listen(("0.0.0.0", 5678))
logger.info("debugpy now listening on 0.0.0.0:5678")
if WAIT_FOR_DEBUGGER:
logger.info(f"Waiting for debugger to attach (timeout: {DEBUG_TIMEOUT}s)...")
start_time = time.time()
# Use a more reliable approach for checking connection
while not debugpy.is_client_connected() and (time.time() - start_time < DEBUG_TIMEOUT):
time.sleep(1)
logger.info("Waiting for debugger connection...")
if (time.time() - start_time) % 5 == 0: # Log only every 5 seconds to reduce spam
logger.info(f"Still waiting for debugger connection... ({int(time.time() - start_time)}s)")
if debugpy.is_client_connected():
logger.info("Debugger attached!")
logger.info("Debugger attached successfully!")
else:
logger.info(f"Debugger not attached after {DEBUG_TIMEOUT}s, continuing anyway...")
logger.warning(f"Debugger not attached after {DEBUG_TIMEOUT}s, continuing anyway...")
except RuntimeError as re:
if "already in use" in str(re):
logger.warning(f"Port 5678 already in use. This might indicate another debugging session is active.")
logger.info("Continuing without debugging...")
elif "timed out waiting for adapter to connect" in str(re):
logger.warning(f"debugpy.listen timed out after {DEBUG_TIMEOUT}s. This is normal in some environments.")
logger.info("Continuing without debugging...")
else:
logger.error(f"RuntimeError with debugpy.listen: {re}", exc_info=True)
logger.info("Continuing without debugging...")
except Exception as e:
logger.error(f"Error with debugpy.listen: {e}", exc_info=True)
logger.info("Continuing without debugging...")