mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-28 20:41:19 +00:00
commit
cc1878bdd7
29 changed files with 1612 additions and 179 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
18
apps/epg/migrations/0012_alter_epgsource_status.py
Normal file
18
apps/epg/migrations/0012_alter_epgsource_status.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
apps/m3u/migrations/0011_alter_m3uaccount_status.py
Normal file
18
apps/m3u/migrations/0011_alter_m3uaccount_status.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
13
core/apps.py
13
core/apps.py
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
36
dispatcharr/jwt_ws_auth.py
Normal file
36
dispatcharr/jwt_ws_auth.py
Normal 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)
|
||||
|
|
@ -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'
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
715
docker/init/04-check-hwaccel.sh
Normal file
715
docker/init/04-check-hwaccel.sh
Normal 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."
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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...")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue