Added timeout handling and force cleanup in VideoArchiver cog

Added proper cancellation and requeuing of downloads in VideoProcessor
Added cancellable logger and process cleanup in VideoDownloader
Added shutdown flag and force_stop capability in EnhancedQueueManager
Added process tracking and kill_all_processes method in FFmpegManager
The changes ensure that:

Active downloads are paused and requeued when unload is called
If cleanup takes too long, force cleanup kicks in
All resources are properly cleaned up, even in case of timeout
Downloads can be safely cancelled and resumed later
No processes are left hanging during unload
This commit is contained in:
pacnpal
2024-11-15 15:48:03 +00:00
parent 74bd55c3e9
commit e66af6e844
5 changed files with 784 additions and 528 deletions

View File

@@ -130,7 +130,7 @@ class EnhancedVideoQueueManager:
max_history_age: int = 86400, # 24 hours max_history_age: int = 86400, # 24 hours
persistence_path: Optional[str] = None, persistence_path: Optional[str] = None,
backup_interval: int = 300, # 5 minutes backup_interval: int = 300, # 5 minutes
deadlock_threshold: int = 900, # 15 minutes (reduced from 1 hour) deadlock_threshold: int = 900, # 15 minutes
): ):
self.max_retries = max_retries self.max_retries = max_retries
self.retry_delay = retry_delay self.retry_delay = retry_delay
@@ -151,6 +151,7 @@ class EnhancedVideoQueueManager:
# Track active tasks # Track active tasks
self._active_tasks: Set[asyncio.Task] = set() self._active_tasks: Set[asyncio.Task] = set()
self._processing_lock = asyncio.Lock() self._processing_lock = asyncio.Lock()
self._shutdown = False
# Status tracking # Status tracking
self._guild_queues: Dict[int, Set[str]] = {} self._guild_queues: Dict[int, Set[str]] = {}
@@ -184,73 +185,80 @@ class EnhancedVideoQueueManager:
# Load persisted queue # Load persisted queue
self._load_persisted_queue() self._load_persisted_queue()
async def add_to_queue( def force_stop(self):
self, """Force stop all queue operations immediately"""
url: str, self._shutdown = True
message_id: int,
channel_id: int,
guild_id: int,
author_id: int,
callback: Optional[
Callable[[str, bool, str], Any]
] = None, # Make callback optional
priority: int = 0,
) -> bool:
"""Add a video to the processing queue with priority support"""
try:
async with self._queue_lock:
if len(self._queue) >= self.max_queue_size:
raise QueueError("Queue is full")
# Check system resources # Cancel all active tasks
if psutil.virtual_memory().percent > 90: for task in self._active_tasks:
raise ResourceExhaustedError("System memory is critically low") if not task.done():
task.cancel()
# Create queue item # Move processing items back to queue
item = QueueItem( for url, item in self._processing.items():
url=url, if item.retry_count < self.max_retries:
message_id=message_id, item.status = "pending"
channel_id=channel_id, item.retry_count += 1
guild_id=guild_id,
author_id=author_id,
added_at=datetime.utcnow(),
priority=priority,
)
# Add to tracking collections
if guild_id not in self._guild_queues:
self._guild_queues[guild_id] = set()
self._guild_queues[guild_id].add(url)
if channel_id not in self._channel_queues:
self._channel_queues[channel_id] = set()
self._channel_queues[channel_id].add(url)
# Add to queue with priority
self._queue.append(item) self._queue.append(item)
self._queue.sort(key=lambda x: (-x.priority, x.added_at)) else:
self._failed[url] = item
# Persist queue state self._processing.clear()
# Clear task tracking
self._active_tasks.clear()
logger.info("Queue manager force stopped")
async def cleanup(self):
"""Clean up resources and stop queue processing"""
try:
# Set shutdown flag
self._shutdown = True
# Cancel all monitoring tasks
for task in self._active_tasks:
if not task.done():
task.cancel()
await asyncio.gather(*self._active_tasks, return_exceptions=True)
# Move processing items back to queue
async with self._queue_lock:
for url, item in self._processing.items():
if item.retry_count < self.max_retries:
item.status = "pending"
item.retry_count += 1
self._queue.append(item)
else:
self._failed[url] = item
self._processing.clear()
# Persist final state
if self.persistence_path: if self.persistence_path:
await self._persist_queue() await self._persist_queue()
logger.info(f"Added video to queue: {url} with priority {priority}") # Clear all collections
return True self._queue.clear()
self._completed.clear()
self._failed.clear()
self._guild_queues.clear()
self._channel_queues.clear()
self._active_tasks.clear()
logger.info("Queue manager cleanup completed")
except Exception as e: except Exception as e:
logger.error(f"Error adding video to queue: {traceback.format_exc()}") logger.error(f"Error during cleanup: {str(e)}")
raise QueueError(f"Failed to add to queue: {str(e)}") raise CleanupError(f"Failed to clean up queue manager: {str(e)}")
async def process_queue( async def process_queue(
self, processor: Callable[[QueueItem], Tuple[bool, Optional[str]]] self, processor: Callable[[QueueItem], Tuple[bool, Optional[str]]]
): ):
"""Process items in the queue with the provided processor function """Process items in the queue with the provided processor function"""
Args:
processor: A callable that takes a QueueItem and returns a tuple of (success: bool, error: Optional[str])
"""
logger.info("Queue processor started and waiting for items...") logger.info("Queue processor started and waiting for items...")
while True: while not self._shutdown:
try: try:
# Get next item from queue # Get next item from queue
item = None item = None
@@ -340,7 +348,6 @@ class EnhancedVideoQueueManager:
await self._persist_queue() await self._persist_queue()
except Exception as e: except Exception as e:
logger.error(f"Failed to persist queue state: {e}") logger.error(f"Failed to persist queue state: {e}")
# Continue processing even if persistence fails
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -353,9 +360,68 @@ class EnhancedVideoQueueManager:
# Small delay to prevent CPU overload # Small delay to prevent CPU overload
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
logger.info("Queue processor stopped due to shutdown")
async def add_to_queue(
self,
url: str,
message_id: int,
channel_id: int,
guild_id: int,
author_id: int,
priority: int = 0,
) -> bool:
"""Add a video to the processing queue with priority support"""
if self._shutdown:
raise QueueError("Queue manager is shutting down")
try:
async with self._queue_lock:
if len(self._queue) >= self.max_queue_size:
raise QueueError("Queue is full")
# Check system resources
if psutil.virtual_memory().percent > 90:
raise ResourceExhaustedError("System memory is critically low")
# Create queue item
item = QueueItem(
url=url,
message_id=message_id,
channel_id=channel_id,
guild_id=guild_id,
author_id=author_id,
added_at=datetime.utcnow(),
priority=priority,
)
# Add to tracking collections
if guild_id not in self._guild_queues:
self._guild_queues[guild_id] = set()
self._guild_queues[guild_id].add(url)
if channel_id not in self._channel_queues:
self._channel_queues[channel_id] = set()
self._channel_queues[channel_id].add(url)
# Add to queue with priority
self._queue.append(item)
self._queue.sort(key=lambda x: (-x.priority, x.added_at))
# Persist queue state
if self.persistence_path:
await self._persist_queue()
logger.info(f"Added video to queue: {url} with priority {priority}")
return True
except Exception as e:
logger.error(f"Error adding video to queue: {traceback.format_exc()}")
raise QueueError(f"Failed to add to queue: {str(e)}")
async def _periodic_backup(self): async def _periodic_backup(self):
"""Periodically backup queue state""" """Periodically backup queue state"""
while True: while not self._shutdown:
try: try:
if self.persistence_path and ( if self.persistence_path and (
not self._last_backup not self._last_backup
@@ -365,6 +431,8 @@ class EnhancedVideoQueueManager:
await self._persist_queue() await self._persist_queue()
self._last_backup = datetime.utcnow() self._last_backup = datetime.utcnow()
await asyncio.sleep(self.backup_interval) await asyncio.sleep(self.backup_interval)
except asyncio.CancelledError:
break
except Exception as e: except Exception as e:
logger.error(f"Error in periodic backup: {str(e)}") logger.error(f"Error in periodic backup: {str(e)}")
await asyncio.sleep(60) await asyncio.sleep(60)
@@ -482,7 +550,7 @@ class EnhancedVideoQueueManager:
async def _monitor_health(self): async def _monitor_health(self):
"""Monitor queue health and performance with improved metrics""" """Monitor queue health and performance with improved metrics"""
while True: while not self._shutdown:
try: try:
# Check memory usage # Check memory usage
process = psutil.Process() process = psutil.Process()
@@ -492,10 +560,9 @@ class EnhancedVideoQueueManager:
logger.warning(f"High memory usage detected: {memory_usage:.2f}MB") logger.warning(f"High memory usage detected: {memory_usage:.2f}MB")
# Force garbage collection # Force garbage collection
import gc import gc
gc.collect() gc.collect()
# Check for potential deadlocks with reduced threshold # Check for potential deadlocks
processing_times = [ processing_times = [
time.time() - item.processing_time time.time() - item.processing_time
for item in self._processing.values() for item in self._processing.values()
@@ -504,7 +571,7 @@ class EnhancedVideoQueueManager:
if processing_times: if processing_times:
max_time = max(processing_times) max_time = max(processing_times)
if max_time > self.deadlock_threshold: # Reduced from 3600s to 900s if max_time > self.deadlock_threshold:
logger.warning( logger.warning(
f"Potential deadlock detected: Item processing for {max_time:.2f}s" f"Potential deadlock detected: Item processing for {max_time:.2f}s"
) )
@@ -528,6 +595,8 @@ class EnhancedVideoQueueManager:
await asyncio.sleep(300) # Check every 5 minutes await asyncio.sleep(300) # Check every 5 minutes
except asyncio.CancelledError:
break
except Exception as e: except Exception as e:
logger.error(f"Error in health monitor: {traceback.format_exc()}") logger.error(f"Error in health monitor: {traceback.format_exc()}")
await asyncio.sleep(60) await asyncio.sleep(60)
@@ -563,43 +632,54 @@ class EnhancedVideoQueueManager:
except Exception as e: except Exception as e:
logger.error(f"Error recovering stuck items: {str(e)}") logger.error(f"Error recovering stuck items: {str(e)}")
async def cleanup(self): async def _periodic_cleanup(self):
"""Clean up resources and stop queue processing""" """Periodically clean up old completed/failed items"""
while not self._shutdown:
try: try:
# Cancel all monitoring tasks current_time = datetime.utcnow()
for task in self._active_tasks: cleanup_cutoff = current_time - timedelta(seconds=self.max_history_age)
if not task.done():
task.cancel()
await asyncio.gather(*self._active_tasks, return_exceptions=True) async with self._queue_lock:
# Clean up completed items
for url in list(self._completed.keys()):
item = self._completed[url]
if item.added_at < cleanup_cutoff:
self._completed.pop(url)
# Persist final state # Clean up failed items
if self.persistence_path: for url in list(self._failed.keys()):
await self._persist_queue() item = self._failed[url]
if item.added_at < cleanup_cutoff:
self._failed.pop(url)
# Clear all collections # Clean up guild and channel tracking
self._queue.clear() for guild_id in list(self._guild_queues.keys()):
self._processing.clear() self._guild_queues[guild_id] = {
self._completed.clear() url
self._failed.clear() for url in self._guild_queues[guild_id]
self._guild_queues.clear() if url in self._queue or url in self._processing
self._channel_queues.clear() }
logger.info("Queue manager cleanup completed") for channel_id in list(self._channel_queues.keys()):
self._channel_queues[channel_id] = {
url
for url in self._channel_queues[channel_id]
if url in self._queue or url in self._processing
}
self.metrics.last_cleanup = current_time
logger.info("Completed periodic queue cleanup")
await asyncio.sleep(self.cleanup_interval)
except asyncio.CancelledError:
break
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {str(e)}") logger.error(f"Error in periodic cleanup: {traceback.format_exc()}")
raise CleanupError(f"Failed to clean up queue manager: {str(e)}") await asyncio.sleep(60)
def get_queue_status(self, guild_id: int) -> dict: def get_queue_status(self, guild_id: int) -> dict:
"""Get current queue status and metrics for a guild """Get current queue status and metrics for a guild"""
Args:
guild_id: The ID of the guild to get status for
Returns:
dict: Queue status including counts and metrics
"""
try: try:
# Count items for this guild # Count items for this guild
pending = len([item for item in self._queue if item.guild_id == guild_id]) pending = len([item for item in self._queue if item.guild_id == guild_id])
@@ -660,14 +740,10 @@ class EnhancedVideoQueueManager:
} }
async def clear_guild_queue(self, guild_id: int) -> int: async def clear_guild_queue(self, guild_id: int) -> int:
"""Clear all queue items for a specific guild """Clear all queue items for a specific guild"""
if self._shutdown:
raise QueueError("Queue manager is shutting down")
Args:
guild_id: The ID of the guild to clear items for
Returns:
int: Number of items cleared
"""
try: try:
cleared_count = 0 cleared_count = 0
async with self._queue_lock: async with self._queue_lock:
@@ -720,47 +796,3 @@ class EnhancedVideoQueueManager:
except Exception as e: except Exception as e:
logger.error(f"Error clearing guild queue: {traceback.format_exc()}") logger.error(f"Error clearing guild queue: {traceback.format_exc()}")
raise QueueError(f"Failed to clear guild queue: {str(e)}") raise QueueError(f"Failed to clear guild queue: {str(e)}")
async def _periodic_cleanup(self):
"""Periodically clean up old completed/failed items"""
while True:
try:
current_time = datetime.utcnow()
cleanup_cutoff = current_time - timedelta(seconds=self.max_history_age)
async with self._queue_lock:
# Clean up completed items
for url in list(self._completed.keys()):
item = self._completed[url]
if item.added_at < cleanup_cutoff:
self._completed.pop(url)
# Clean up failed items
for url in list(self._failed.keys()):
item = self._failed[url]
if item.added_at < cleanup_cutoff:
self._failed.pop(url)
# Clean up guild and channel tracking
for guild_id in list(self._guild_queues.keys()):
self._guild_queues[guild_id] = {
url
for url in self._guild_queues[guild_id]
if url in self._queue or url in self._processing
}
for channel_id in list(self._channel_queues.keys()):
self._channel_queues[channel_id] = {
url
for url in self._channel_queues[channel_id]
if url in self._queue or url in self._processing
}
self.metrics.last_cleanup = current_time
logger.info("Completed periodic queue cleanup")
await asyncio.sleep(self.cleanup_interval)
except Exception as e:
logger.error(f"Error in periodic cleanup: {traceback.format_exc()}")
await asyncio.sleep(60)

View File

@@ -6,8 +6,10 @@ import multiprocessing
import logging import logging
import subprocess import subprocess
import traceback import traceback
import signal
import psutil
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional from typing import Dict, Any, Optional, Set
from videoarchiver.ffmpeg.exceptions import ( from videoarchiver.ffmpeg.exceptions import (
FFmpegError, FFmpegError,
@@ -67,10 +69,52 @@ class FFmpegManager:
# Initialize encoder params # Initialize encoder params
self.encoder_params = EncoderParams(self._cpu_cores, self._gpu_info) self.encoder_params = EncoderParams(self._cpu_cores, self._gpu_info)
# Track active FFmpeg processes
self._active_processes: Set[subprocess.Popen] = set()
# Verify FFmpeg functionality # Verify FFmpeg functionality
self._verify_ffmpeg() self._verify_ffmpeg()
logger.info("FFmpeg manager initialized successfully") logger.info("FFmpeg manager initialized successfully")
def kill_all_processes(self) -> None:
"""Kill all active FFmpeg processes"""
try:
# First try graceful termination
for process in self._active_processes:
try:
if process.poll() is None: # Process is still running
process.terminate()
except Exception as e:
logger.error(f"Error terminating FFmpeg process: {e}")
# Give processes a moment to terminate
import time
time.sleep(0.5)
# Force kill any remaining processes
for process in self._active_processes:
try:
if process.poll() is None: # Process is still running
process.kill()
except Exception as e:
logger.error(f"Error killing FFmpeg process: {e}")
# Find and kill any orphaned FFmpeg processes
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
if 'ffmpeg' in proc.info['name'].lower():
proc.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
except Exception as e:
logger.error(f"Error killing orphaned FFmpeg process: {e}")
self._active_processes.clear()
logger.info("All FFmpeg processes terminated")
except Exception as e:
logger.error(f"Error killing FFmpeg processes: {e}")
def _initialize_binaries(self) -> Dict[str, Path]: def _initialize_binaries(self) -> Dict[str, Path]:
"""Initialize FFmpeg and FFprobe binaries with proper error handling""" """Initialize FFmpeg and FFprobe binaries with proper error handling"""
try: try:

View File

@@ -7,7 +7,7 @@ import discord
from discord.ext import commands from discord.ext import commands
from discord import app_commands from discord import app_commands
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, Tuple from typing import Dict, Any, Optional, Tuple, Set
import traceback import traceback
from datetime import datetime from datetime import datetime
@@ -28,9 +28,9 @@ REACTIONS = {
'processing': '⚙️', 'processing': '⚙️',
'success': '', 'success': '',
'error': '', 'error': '',
'numbers': ['1', '2', '3', '4', '5'], # Queue position indicators 'numbers': ['1', '2', '3', '4', '5'],
'progress': ['', '🟨', '🟩'], # Progress indicators (0%, 50%, 100%) 'progress': ['', '🟨', '🟩'],
'download': ['0', '2', '4', '6', '8', '🔟'] # Download progress (0%, 20%, 40%, 60%, 80%, 100%) 'download': ['0', '2', '4', '6', '8', '🔟']
} }
# Global queue manager instance to persist across reloads # Global queue manager instance to persist across reloads
@@ -56,18 +56,21 @@ class VideoProcessor:
self.components = components self.components = components
self.ffmpeg_mgr = ffmpeg_mgr self.ffmpeg_mgr = ffmpeg_mgr
# Track active downloads and their tasks
self._active_downloads: Dict[str, asyncio.Task] = {}
self._active_downloads_lock = asyncio.Lock()
self._unloading = False
# Use global queue manager if available # Use global queue manager if available
global _global_queue_manager global _global_queue_manager
if _global_queue_manager is not None: if _global_queue_manager is not None:
self.queue_manager = _global_queue_manager self.queue_manager = _global_queue_manager
logger.info("Using existing global queue manager") logger.info("Using existing global queue manager")
# Use provided queue manager if available
elif queue_manager: elif queue_manager:
self.queue_manager = queue_manager self.queue_manager = queue_manager
_global_queue_manager = queue_manager _global_queue_manager = queue_manager
logger.info("Using provided queue manager and setting as global") logger.info("Using provided queue manager and setting as global")
else: else:
# Initialize enhanced queue manager with persistence and error recovery
data_dir = Path(os.path.dirname(__file__)) / "data" data_dir = Path(os.path.dirname(__file__)) / "data"
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
queue_path = data_dir / "queue_state.json" queue_path = data_dir / "queue_state.json"
@@ -76,30 +79,338 @@ class VideoProcessor:
max_retries=3, max_retries=3,
retry_delay=5, retry_delay=5,
max_queue_size=1000, max_queue_size=1000,
cleanup_interval=1800, # 30 minutes cleanup_interval=1800,
max_history_age=86400, # 24 hours max_history_age=86400,
persistence_path=str(queue_path) persistence_path=str(queue_path)
) )
_global_queue_manager = self.queue_manager _global_queue_manager = self.queue_manager
logger.info("Created new queue manager and set as global") logger.info("Created new queue manager and set as global")
# Track failed downloads for cleanup
self._failed_downloads = set()
self._failed_downloads_lock = asyncio.Lock()
# Start queue processing # Start queue processing
logger.info("Starting video processing queue...") logger.info("Starting video processing queue...")
self._queue_task = asyncio.create_task(self.queue_manager.process_queue(self._process_video)) self._queue_task = asyncio.create_task(self.queue_manager.process_queue(self._process_video))
logger.info("Video processing queue started successfully") logger.info("Video processing queue started successfully")
# Register commands async def _cancel_active_downloads(self) -> None:
@commands.hybrid_command(name='queuedetails') """Cancel all active downloads and requeue them"""
@commands.is_owner() async with self._active_downloads_lock:
async def queue_details(ctx): for url, task in self._active_downloads.items():
"""Show detailed queue status and progress information""" if not task.done():
await self._show_queue_details(ctx) # Cancel the task
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Error cancelling download task for {url}: {e}")
self.bot.add_command(queue_details) # Requeue the download if we're unloading
if self._unloading and url in _download_progress:
try:
# Get the original message details from progress tracking
progress = _download_progress[url]
if progress.get('message_id') and progress.get('channel_id') and progress.get('guild_id'):
await self.queue_manager.add_to_queue(
url=url,
message_id=progress['message_id'],
channel_id=progress['channel_id'],
guild_id=progress['guild_id'],
author_id=progress.get('author_id')
)
logger.info(f"Requeued download for {url}")
except Exception as e:
logger.error(f"Failed to requeue download for {url}: {e}")
self._active_downloads.clear()
async def cleanup(self) -> None:
"""Clean up resources with proper handling"""
try:
self._unloading = True
# Cancel queue processing
if hasattr(self, '_queue_task') and not self._queue_task.done():
self._queue_task.cancel()
try:
await self._queue_task
except asyncio.CancelledError:
pass
# Cancel and requeue active downloads
await self._cancel_active_downloads()
# Clean up progress tracking
_download_progress.clear()
_compression_progress.clear()
except Exception as e:
logger.error(f"Error during processor cleanup: {e}")
raise
finally:
self._unloading = False
async def force_cleanup(self) -> None:
"""Force cleanup of resources when timeout occurs"""
try:
# Cancel all tasks immediately without requeuing
async with self._active_downloads_lock:
for task in self._active_downloads.values():
if not task.done():
task.cancel()
# Cancel queue task
if hasattr(self, '_queue_task') and not self._queue_task.done():
self._queue_task.cancel()
# Clear all tracking
self._active_downloads.clear()
_download_progress.clear()
_compression_progress.clear()
except Exception as e:
logger.error(f"Error during force cleanup: {e}")
async def process_message(self, message: discord.Message) -> None:
"""Process a message for video content"""
try:
if not message.guild or not message.guild.id in self.components:
return
components = self.components[message.guild.id]
downloader = components.get("downloader")
if not downloader:
logger.error(f"No downloader found for guild {message.guild.id}")
return
content = message.content.strip()
if not content or not downloader.is_supported_url(content):
return
try:
await message.add_reaction(REACTIONS['queued'])
logger.info(f"Added queued reaction to message {message.id}")
except Exception as e:
logger.error(f"Failed to add queued reaction: {e}")
# Track message details in progress tracking
_download_progress[content] = {
'active': False,
'message_id': message.id,
'channel_id': message.channel.id,
'guild_id': message.guild.id,
'author_id': message.author.id,
'start_time': None,
'percent': 0,
'speed': 'N/A',
'eta': 'N/A',
'downloaded_bytes': 0,
'total_bytes': 0,
'retries': 0
}
await self.queue_manager.add_to_queue(
url=content,
message_id=message.id,
channel_id=message.channel.id,
guild_id=message.guild.id,
author_id=message.author.id
)
logger.info(f"Added message {message.id} to processing queue")
queue_status = self.queue_manager.get_queue_status(message.guild.id)
queue_position = queue_status['pending'] - 1
await self.update_queue_position_reaction(message, queue_position)
logger.info(f"Message {message.id} is at position {queue_position + 1} in queue")
except Exception as e:
logger.error(f"Error processing message: {traceback.format_exc()}")
raise ProcessingError(f"Failed to process message: {str(e)}")
async def _process_video(self, item) -> Tuple[bool, Optional[str]]:
"""Process a video from the queue"""
if self._unloading:
return False, "Processor is unloading"
file_path = None
original_message = None
download_task = None
try:
guild_id = item.guild_id
if guild_id not in self.components:
return False, f"No components found for guild {guild_id}"
components = self.components[guild_id]
downloader = components.get("downloader")
message_manager = components.get("message_manager")
if not downloader or not message_manager:
return False, f"Missing required components for guild {guild_id}"
try:
channel = self.bot.get_channel(item.channel_id)
if not channel:
return False, f"Channel {item.channel_id} not found"
original_message = await channel.fetch_message(item.message_id)
await original_message.remove_reaction(REACTIONS['queued'], self.bot.user)
await original_message.add_reaction(REACTIONS['processing'])
logger.info(f"Started processing message {item.message_id}")
except discord.NotFound:
original_message = None
except Exception as e:
logger.error(f"Error fetching original message: {e}")
original_message = None
# Create and track download task
download_task = asyncio.create_task(
downloader.download_video(
item.url,
progress_callback=lambda progress: self.update_download_progress_reaction(original_message, progress) if original_message else None
)
)
async with self._active_downloads_lock:
self._active_downloads[item.url] = download_task
try:
success, file_path, error = await download_task
if not success:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Download failed for message {item.message_id}: {error}")
return False, f"Failed to download video: {error}"
except asyncio.CancelledError:
logger.info(f"Download cancelled for {item.url}")
return False, "Download cancelled"
except Exception as e:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Download error for message {item.message_id}: {str(e)}")
return False, f"Download error: {str(e)}"
finally:
async with self._active_downloads_lock:
self._active_downloads.pop(item.url, None)
# Get archive channel
guild = self.bot.get_guild(guild_id)
if not guild:
return False, f"Guild {guild_id} not found"
archive_channel = await self.config.get_channel(guild, "archive")
if not archive_channel:
return False, "Archive channel not configured"
# Format message
try:
author = original_message.author if original_message else None
message = await message_manager.format_message(
author=author,
channel=channel,
url=item.url
)
except Exception as e:
return False, f"Failed to format message: {str(e)}"
# Upload to archive channel
try:
if not os.path.exists(file_path):
return False, "Processed file not found"
await archive_channel.send(
content=message,
file=discord.File(file_path)
)
if original_message:
await original_message.remove_reaction(REACTIONS['processing'], self.bot.user)
await original_message.add_reaction(REACTIONS['success'])
logger.info(f"Successfully processed message {item.message_id}")
return True, None
except discord.HTTPException as e:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Failed to upload to Discord for message {item.message_id}: {str(e)}")
return False, f"Failed to upload to Discord: {str(e)}"
except Exception as e:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Failed to archive video for message {item.message_id}: {str(e)}")
return False, f"Failed to archive video: {str(e)}"
except Exception as e:
logger.error(f"Error processing video: {traceback.format_exc()}")
return False, str(e)
finally:
# Clean up downloaded file
if file_path and os.path.exists(file_path):
try:
os.unlink(file_path)
except Exception as e:
logger.error(f"Failed to clean up file {file_path}: {e}")
async def update_queue_position_reaction(self, message, position):
"""Update queue position reaction"""
try:
for reaction in REACTIONS['numbers']:
try:
await message.remove_reaction(reaction, self.bot.user)
except:
pass
if 0 <= position < len(REACTIONS['numbers']):
await message.add_reaction(REACTIONS['numbers'][position])
logger.info(f"Updated queue position reaction to {position + 1} for message {message.id}")
except Exception as e:
logger.error(f"Failed to update queue position reaction: {e}")
async def update_progress_reaction(self, message, progress):
"""Update progress reaction based on FFmpeg progress"""
try:
for reaction in REACTIONS['progress']:
try:
await message.remove_reaction(reaction, self.bot.user)
except:
pass
if progress < 33:
await message.add_reaction(REACTIONS['progress'][0])
elif progress < 66:
await message.add_reaction(REACTIONS['progress'][1])
else:
await message.add_reaction(REACTIONS['progress'][2])
except Exception as e:
logger.error(f"Failed to update progress reaction: {e}")
async def update_download_progress_reaction(self, message, progress):
"""Update download progress reaction"""
if not message:
return
try:
for reaction in REACTIONS['download']:
try:
await message.remove_reaction(reaction, self.bot.user)
except:
pass
if progress <= 20:
await message.add_reaction(REACTIONS['download'][0])
elif progress <= 40:
await message.add_reaction(REACTIONS['download'][1])
elif progress <= 60:
await message.add_reaction(REACTIONS['download'][2])
elif progress <= 80:
await message.add_reaction(REACTIONS['download'][3])
elif progress < 100:
await message.add_reaction(REACTIONS['download'][4])
else:
await message.add_reaction(REACTIONS['download'][5])
except Exception as e:
logger.error(f"Failed to update download progress reaction: {e}")
async def _show_queue_details(self, ctx): async def _show_queue_details(self, ctx):
"""Display detailed queue status and progress information""" """Display detailed queue status and progress information"""
@@ -213,286 +524,3 @@ class VideoProcessor:
except Exception as e: except Exception as e:
logger.error(f"Error showing queue details: {traceback.format_exc()}") logger.error(f"Error showing queue details: {traceback.format_exc()}")
await ctx.send(f"Error getting queue details: {str(e)}") await ctx.send(f"Error getting queue details: {str(e)}")
async def update_queue_position_reaction(self, message, position):
"""Update queue position reaction"""
try:
# Remove any existing number reactions
for reaction in REACTIONS['numbers']:
try:
await message.remove_reaction(reaction, self.bot.user)
except:
pass
# Add new position reaction if within range
if 0 <= position < len(REACTIONS['numbers']):
await message.add_reaction(REACTIONS['numbers'][position])
logger.info(f"Updated queue position reaction to {position + 1} for message {message.id}")
except Exception as e:
logger.error(f"Failed to update queue position reaction: {e}")
async def update_progress_reaction(self, message, progress):
"""Update progress reaction based on FFmpeg progress"""
try:
# Remove existing progress reactions
for reaction in REACTIONS['progress']:
try:
await message.remove_reaction(reaction, self.bot.user)
except:
pass
# Add appropriate progress reaction
if progress < 33:
await message.add_reaction(REACTIONS['progress'][0])
logger.info(f"FFmpeg progress 0-33% for message {message.id}")
elif progress < 66:
await message.add_reaction(REACTIONS['progress'][1])
logger.info(f"FFmpeg progress 33-66% for message {message.id}")
else:
await message.add_reaction(REACTIONS['progress'][2])
logger.info(f"FFmpeg progress 66-100% for message {message.id}")
except Exception as e:
logger.error(f"Failed to update progress reaction: {e}")
async def update_download_progress_reaction(self, message, progress):
"""Update download progress reaction"""
try:
# Remove existing download progress reactions
for reaction in REACTIONS['download']:
try:
await message.remove_reaction(reaction, self.bot.user)
except:
pass
# Add appropriate download progress reaction
if progress <= 20:
await message.add_reaction(REACTIONS['download'][0])
logger.info(f"Download progress 0-20% for message {message.id}")
elif progress <= 40:
await message.add_reaction(REACTIONS['download'][1])
logger.info(f"Download progress 20-40% for message {message.id}")
elif progress <= 60:
await message.add_reaction(REACTIONS['download'][2])
logger.info(f"Download progress 40-60% for message {message.id}")
elif progress <= 80:
await message.add_reaction(REACTIONS['download'][3])
logger.info(f"Download progress 60-80% for message {message.id}")
elif progress < 100:
await message.add_reaction(REACTIONS['download'][4])
logger.info(f"Download progress 80-100% for message {message.id}")
else:
await message.add_reaction(REACTIONS['download'][5])
logger.info(f"Download completed (100%) for message {message.id}")
except Exception as e:
logger.error(f"Failed to update download progress reaction: {e}")
async def process_message(self, message):
"""Process a message for video content"""
try:
if not message.guild or not message.guild.id in self.components:
return
components = self.components[message.guild.id]
downloader = components.get("downloader")
if not downloader:
logger.error(f"No downloader found for guild {message.guild.id}")
return
# Check if message contains a video URL
content = message.content.strip()
if not content or not downloader.is_supported_url(content):
return
# Add initial queued reaction
try:
await message.add_reaction(REACTIONS['queued'])
logger.info(f"Added queued reaction to message {message.id}")
except Exception as e:
logger.error(f"Failed to add queued reaction: {e}")
# Add to processing queue
await self.queue_manager.add_to_queue(
url=content,
message_id=message.id,
channel_id=message.channel.id,
guild_id=message.guild.id,
author_id=message.author.id
)
logger.info(f"Added message {message.id} to processing queue")
# Update queue position
queue_status = self.queue_manager.get_queue_status(message.guild.id)
queue_position = queue_status['pending'] - 1 # -1 because this item was just added
await self.update_queue_position_reaction(message, queue_position)
logger.info(f"Message {message.id} is at position {queue_position + 1} in queue")
except Exception as e:
logger.error(f"Error processing message: {traceback.format_exc()}")
raise ProcessingError(f"Failed to process message: {str(e)}")
async def _process_video(self, item) -> Tuple[bool, Optional[str]]:
"""Process a video from the queue"""
file_path = None
original_message = None
try:
guild_id = item.guild_id
if guild_id not in self.components:
return False, f"No components found for guild {guild_id}"
components = self.components[guild_id]
downloader = components.get("downloader")
message_manager = components.get("message_manager")
if not downloader or not message_manager:
return False, f"Missing required components for guild {guild_id}"
# Get original message
try:
channel = self.bot.get_channel(item.channel_id)
if not channel:
return False, f"Channel {item.channel_id} not found"
original_message = await channel.fetch_message(item.message_id)
# Update reactions to show processing
await original_message.remove_reaction(REACTIONS['queued'], self.bot.user)
await original_message.add_reaction(REACTIONS['processing'])
logger.info(f"Started processing message {item.message_id}")
except discord.NotFound:
original_message = None
except Exception as e:
logger.error(f"Error fetching original message: {e}")
original_message = None
# Initialize progress tracking
_download_progress[item.url] = {
'active': True,
'start_time': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'),
'percent': 0,
'speed': 'N/A',
'eta': 'N/A',
'downloaded_bytes': 0,
'total_bytes': 0,
'retries': 0
}
# Download and process video
try:
success, file_path, error = await downloader.download_video(
item.url,
progress_callback=lambda progress: self.update_download_progress_reaction(original_message, progress) if original_message else None
)
if not success:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Download failed for message {item.message_id}: {error}")
return False, f"Failed to download video: {error}"
except Exception as e:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Download error for message {item.message_id}: {str(e)}")
return False, f"Download error: {str(e)}"
finally:
# Clean up progress tracking
if item.url in _download_progress:
_download_progress[item.url]['active'] = False
# Get archive channel
guild = self.bot.get_guild(guild_id)
if not guild:
return False, f"Guild {guild_id} not found"
archive_channel = await self.config.get_channel(guild, "archive")
if not archive_channel:
return False, "Archive channel not configured"
# Format message
try:
author = original_message.author if original_message else None
message = await message_manager.format_message(
author=author,
channel=channel,
url=item.url
)
except Exception as e:
return False, f"Failed to format message: {str(e)}"
# Upload to archive channel
try:
if not os.path.exists(file_path):
return False, "Processed file not found"
await archive_channel.send(
content=message,
file=discord.File(file_path)
)
# Update reactions for success
if original_message:
await original_message.remove_reaction(REACTIONS['processing'], self.bot.user)
await original_message.add_reaction(REACTIONS['success'])
logger.info(f"Successfully processed message {item.message_id}")
return True, None
except discord.HTTPException as e:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Failed to upload to Discord for message {item.message_id}: {str(e)}")
return False, f"Failed to upload to Discord: {str(e)}"
except Exception as e:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Failed to archive video for message {item.message_id}: {str(e)}")
return False, f"Failed to archive video: {str(e)}"
except Exception as e:
if original_message:
await original_message.add_reaction(REACTIONS['error'])
logger.error(f"Error processing video: {traceback.format_exc()}")
return False, str(e)
finally:
# Clean up downloaded file
if file_path and os.path.exists(file_path):
try:
os.unlink(file_path)
except Exception as e:
logger.error(f"Failed to clean up file {file_path}: {e}")
async def cleanup(self):
"""Clean up resources"""
try:
# Cancel queue processing
if hasattr(self, '_queue_task') and not self._queue_task.done():
self._queue_task.cancel()
try:
await self._queue_task
except asyncio.CancelledError:
pass
# Clean up queue manager
if hasattr(self, 'queue_manager'):
await self.queue_manager.cleanup()
# Clean up failed downloads
async with self._failed_downloads_lock:
for file_path in self._failed_downloads:
try:
if os.path.exists(file_path):
os.unlink(file_path)
except Exception as e:
logger.error(f"Failed to clean up file {file_path}: {e}")
self._failed_downloads.clear()
# Don't clear global queue manager during cleanup
# This ensures it persists through reloads
except Exception as e:
logger.error(f"Error during cleanup: {traceback.format_exc()}")
raise ProcessingError(f"Cleanup failed: {str(e)}")
@classmethod
def get_queue_manager(cls) -> Optional[EnhancedVideoQueueManager]:
"""Get the global queue manager instance"""
global _global_queue_manager
return _global_queue_manager

View File

@@ -9,8 +9,9 @@ import yt_dlp
import shutil import shutil
import subprocess import subprocess
import json import json
import signal
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Tuple, Callable from typing import Dict, List, Optional, Tuple, Callable, Set
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@@ -29,6 +30,25 @@ from videoarchiver.utils.path_manager import temp_path_context
logger = logging.getLogger("VideoArchiver") logger = logging.getLogger("VideoArchiver")
# Add a custom yt-dlp logger to handle cancellation
class CancellableYTDLLogger:
def __init__(self):
self.cancelled = False
def debug(self, msg):
if self.cancelled:
raise Exception("Download cancelled")
logger.debug(msg)
def warning(self, msg):
if self.cancelled:
raise Exception("Download cancelled")
logger.warning(msg)
def error(self, msg):
if self.cancelled:
raise Exception("Download cancelled")
logger.error(msg)
def is_video_url_pattern(url: str) -> bool: def is_video_url_pattern(url: str) -> bool:
"""Check if URL matches common video platform patterns""" """Check if URL matches common video platform patterns"""
@@ -53,12 +73,12 @@ def is_video_url_pattern(url: str) -> bool:
] ]
return any(re.search(pattern, url, re.IGNORECASE) for pattern in video_patterns) return any(re.search(pattern, url, re.IGNORECASE) for pattern in video_patterns)
class VideoDownloader: class VideoDownloader:
MAX_RETRIES = 5 # Increased from 3 MAX_RETRIES = 5
RETRY_DELAY = 10 # Increased from 5 RETRY_DELAY = 10
FILE_OP_RETRIES = 3 FILE_OP_RETRIES = 3
FILE_OP_RETRY_DELAY = 1 # seconds FILE_OP_RETRY_DELAY = 1
SHUTDOWN_TIMEOUT = 15 # seconds
def __init__( def __init__(
self, self,
@@ -67,35 +87,36 @@ class VideoDownloader:
max_quality: int, max_quality: int,
max_file_size: int, max_file_size: int,
enabled_sites: Optional[List[str]] = None, enabled_sites: Optional[List[str]] = None,
concurrent_downloads: int = 2, # Reduced from 3 concurrent_downloads: int = 2,
ffmpeg_mgr: Optional[FFmpegManager] = None, ffmpeg_mgr: Optional[FFmpegManager] = None,
): ):
# Ensure download path exists with proper permissions
self.download_path = Path(download_path) self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True) self.download_path.mkdir(parents=True, exist_ok=True)
os.chmod(str(self.download_path), 0o755) os.chmod(str(self.download_path), 0o755)
logger.info(f"Initialized download directory: {self.download_path}")
self.video_format = video_format self.video_format = video_format
self.max_quality = max_quality self.max_quality = max_quality
self.max_file_size = max_file_size self.max_file_size = max_file_size
self.enabled_sites = enabled_sites self.enabled_sites = enabled_sites
# Initialize FFmpeg manager
self.ffmpeg_mgr = ffmpeg_mgr or FFmpegManager() self.ffmpeg_mgr = ffmpeg_mgr or FFmpegManager()
logger.info(f"Using FFmpeg from: {self.ffmpeg_mgr.get_ffmpeg_path()}")
# Create thread pool for this instance # Create thread pool with proper naming
self.download_pool = ThreadPoolExecutor( self.download_pool = ThreadPoolExecutor(
max_workers=max(1, min(3, concurrent_downloads)), max_workers=max(1, min(3, concurrent_downloads)),
thread_name_prefix="videoarchiver_download", thread_name_prefix="videoarchiver_download",
) )
# Track active downloads for cleanup # Track active downloads and processes
self.active_downloads: Dict[str, str] = {} self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._downloads_lock = asyncio.Lock() self._downloads_lock = asyncio.Lock()
self._active_processes: Set[subprocess.Popen] = set()
self._processes_lock = asyncio.Lock()
self._shutting_down = False
# Configure yt-dlp options with improved settings # Create cancellable logger
self.ytdl_logger = CancellableYTDLLogger()
# Configure yt-dlp options
self.ydl_opts = { self.ydl_opts = {
"format": f"bv*[height<={max_quality}][ext=mp4]+ba[ext=m4a]/b[height<={max_quality}]/best", "format": f"bv*[height<={max_quality}][ext=mp4]+ba[ext=m4a]/b[height<={max_quality}]/best",
"outtmpl": "%(title)s.%(ext)s", "outtmpl": "%(title)s.%(ext)s",
@@ -103,30 +124,87 @@ class VideoDownloader:
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
"extract_flat": True, "extract_flat": True,
"concurrent_fragment_downloads": 1, # Reduced from default "concurrent_fragment_downloads": 1,
"retries": self.MAX_RETRIES, "retries": self.MAX_RETRIES,
"fragment_retries": self.MAX_RETRIES, "fragment_retries": self.MAX_RETRIES,
"file_access_retries": self.FILE_OP_RETRIES, "file_access_retries": self.FILE_OP_RETRIES,
"extractor_retries": self.MAX_RETRIES, "extractor_retries": self.MAX_RETRIES,
"postprocessor_hooks": [self._check_file_size], "postprocessor_hooks": [self._check_file_size],
"progress_hooks": [self._progress_hook, self._detailed_progress_hook], # Add detailed hook "progress_hooks": [self._progress_hook, self._detailed_progress_hook],
"ffmpeg_location": str(self.ffmpeg_mgr.get_ffmpeg_path()), "ffmpeg_location": str(self.ffmpeg_mgr.get_ffmpeg_path()),
"ffprobe_location": str(self.ffmpeg_mgr.get_ffprobe_path()), "ffprobe_location": str(self.ffmpeg_mgr.get_ffprobe_path()),
"paths": {"home": str(self.download_path)}, "paths": {"home": str(self.download_path)},
"logger": logger, "logger": self.ytdl_logger,
"ignoreerrors": True, "ignoreerrors": True,
"no_color": True, "no_color": True,
"geo_bypass": True, "geo_bypass": True,
"socket_timeout": 60, # Increased from 30 "socket_timeout": 60,
"http_chunk_size": 1048576, # Reduced to 1MB chunks for better stability "http_chunk_size": 1048576,
"external_downloader_args": { "external_downloader_args": {
"ffmpeg": ["-timeout", "60000000"] # Increased to 60 seconds "ffmpeg": ["-timeout", "60000000"]
}, },
"max_sleep_interval": 5, # Maximum time to sleep between retries "max_sleep_interval": 5,
"sleep_interval": 1, # Initial sleep interval "sleep_interval": 1,
"max_filesize": max_file_size * 1024 * 1024, # Set max file size limit "max_filesize": max_file_size * 1024 * 1024,
} }
async def cleanup(self) -> None:
"""Clean up resources with proper shutdown"""
self._shutting_down = True
try:
# Cancel active downloads
self.ytdl_logger.cancelled = True
# Kill any active FFmpeg processes
async with self._processes_lock:
for process in self._active_processes:
try:
process.terminate()
await asyncio.sleep(0.1) # Give process time to terminate
if process.poll() is None:
process.kill() # Force kill if still running
except Exception as e:
logger.error(f"Error killing process: {e}")
self._active_processes.clear()
# Clean up thread pool
self.download_pool.shutdown(wait=False, cancel_futures=True)
# Clean up active downloads
async with self._downloads_lock:
self.active_downloads.clear()
except Exception as e:
logger.error(f"Error during downloader cleanup: {e}")
finally:
self._shutting_down = False
async def force_cleanup(self) -> None:
"""Force cleanup of all resources"""
try:
# Force cancel all downloads
self.ytdl_logger.cancelled = True
# Kill all processes immediately
async with self._processes_lock:
for process in self._active_processes:
try:
process.kill()
except Exception as e:
logger.error(f"Error force killing process: {e}")
self._active_processes.clear()
# Force shutdown thread pool
self.download_pool.shutdown(wait=False, cancel_futures=True)
# Clear all tracking
async with self._downloads_lock:
self.active_downloads.clear()
except Exception as e:
logger.error(f"Error during force cleanup: {e}")
def _detailed_progress_hook(self, d): def _detailed_progress_hook(self, d):
"""Handle detailed download progress tracking""" """Handle detailed download progress tracking"""
try: try:
@@ -233,6 +311,9 @@ class VideoDownloader:
self, url: str, progress_callback: Optional[Callable[[float], None]] = None self, url: str, progress_callback: Optional[Callable[[float], None]] = None
) -> Tuple[bool, str, str]: ) -> Tuple[bool, str, str]:
"""Download and process a video with improved error handling""" """Download and process a video with improved error handling"""
if self._shutting_down:
return False, "", "Downloader is shutting down"
# Initialize progress tracking for this URL # Initialize progress tracking for this URL
from videoarchiver.processor import _download_progress from videoarchiver.processor import _download_progress
_download_progress[url] = { _download_progress[url] = {
@@ -272,7 +353,10 @@ class VideoDownloader:
original_file = file_path original_file = file_path
async with self._downloads_lock: async with self._downloads_lock:
self.active_downloads[url] = original_file self.active_downloads[url] = {
'file_path': original_file,
'start_time': datetime.utcnow()
}
# Check file size and compress if needed # Check file size and compress if needed
file_size = os.path.getsize(original_file) file_size = os.path.getsize(original_file)
@@ -386,6 +470,9 @@ class VideoDownloader:
use_hardware: bool = True, use_hardware: bool = True,
) -> bool: ) -> bool:
"""Attempt video compression with given parameters""" """Attempt video compression with given parameters"""
if self._shutting_down:
return False
try: try:
# Build FFmpeg command # Build FFmpeg command
ffmpeg_path = str(self.ffmpeg_mgr.get_ffmpeg_path()) ffmpeg_path = str(self.ffmpeg_mgr.get_ffmpeg_path())
@@ -448,9 +535,18 @@ class VideoDownloader:
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
) )
# Track the process
async with self._processes_lock:
self._active_processes.add(process)
start_time = datetime.utcnow() start_time = datetime.utcnow()
try:
while True: while True:
if self._shutting_down:
process.terminate()
return False
line = await process.stdout.readline() line = await process.stdout.readline()
if not line: if not line:
break break
@@ -493,6 +589,11 @@ class VideoDownloader:
return success return success
finally:
# Remove process from tracking
async with self._processes_lock:
self._active_processes.discard(process)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"FFmpeg compression failed: {e.stderr.decode()}") logger.error(f"FFmpeg compression failed: {e.stderr.decode()}")
return False return False
@@ -593,6 +694,9 @@ class VideoDownloader:
progress_callback: Optional[Callable[[float], None]] = None, progress_callback: Optional[Callable[[float], None]] = None,
) -> Tuple[bool, str, str]: ) -> Tuple[bool, str, str]:
"""Safely download video with retries""" """Safely download video with retries"""
if self._shutting_down:
return False, "", "Downloader is shutting down"
last_error = None last_error = None
for attempt in range(self.MAX_RETRIES): for attempt in range(self.MAX_RETRIES):
try: try:

View File

@@ -27,9 +27,11 @@ from videoarchiver.utils.exceptions import (
FileCleanupError as FileOperationError FileCleanupError as FileOperationError
) )
logger = logging.getLogger("VideoArchiver") logger = logging.getLogger("VideoArchiver")
# Constants for timeouts
UNLOAD_TIMEOUT = 30 # seconds
CLEANUP_TIMEOUT = 15 # seconds
class VideoArchiver(commands.Cog): class VideoArchiver(commands.Cog):
"""Archive videos from Discord channels""" """Archive videos from Discord channels"""
@@ -40,6 +42,7 @@ class VideoArchiver(commands.Cog):
self.ready = asyncio.Event() self.ready = asyncio.Event()
self._init_task: Optional[asyncio.Task] = None self._init_task: Optional[asyncio.Task] = None
self._cleanup_task: Optional[asyncio.Task] = None self._cleanup_task: Optional[asyncio.Task] = None
self._unloading = False
# Start initialization # Start initialization
self._init_task = asyncio.create_task(self._initialize()) self._init_task = asyncio.create_task(self._initialize())
@@ -73,7 +76,6 @@ class VideoArchiver(commands.Cog):
await self.initialize_guild_components(guild.id) await self.initialize_guild_components(guild.id)
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize guild {guild.id}: {str(e)}") logger.error(f"Failed to initialize guild {guild.id}: {str(e)}")
# Continue initialization even if one guild fails
continue continue
# Initialize queue manager after components are ready # Initialize queue manager after components are ready
@@ -97,7 +99,7 @@ class VideoArchiver(commands.Cog):
self.config_manager, self.config_manager,
self.components, self.components,
queue_manager=self.queue_manager, queue_manager=self.queue_manager,
ffmpeg_mgr=self.ffmpeg_mgr, # Pass shared FFmpeg manager ffmpeg_mgr=self.ffmpeg_mgr,
) )
# Start update checker # Start update checker
@@ -109,10 +111,7 @@ class VideoArchiver(commands.Cog):
logger.info("VideoArchiver initialization completed successfully") logger.info("VideoArchiver initialization completed successfully")
except Exception as e: except Exception as e:
logger.error( logger.error(f"Critical error during initialization: {traceback.format_exc()}")
f"Critical error during initialization: {traceback.format_exc()}"
)
# Clean up any partially initialized components
await self._cleanup() await self._cleanup()
raise raise
@@ -129,9 +128,7 @@ class VideoArchiver(commands.Cog):
async def cog_load(self) -> None: async def cog_load(self) -> None:
"""Handle cog loading""" """Handle cog loading"""
try: try:
# Wait for initialization to complete
await asyncio.wait_for(self.ready.wait(), timeout=30) await asyncio.wait_for(self.ready.wait(), timeout=30)
except asyncio.TimeoutError: except asyncio.TimeoutError:
await self._cleanup() await self._cleanup()
raise ProcessingError("Cog initialization timed out") raise ProcessingError("Cog initialization timed out")
@@ -140,31 +137,82 @@ class VideoArchiver(commands.Cog):
raise ProcessingError(f"Error during cog load: {str(e)}") raise ProcessingError(f"Error during cog load: {str(e)}")
async def cog_unload(self) -> None: async def cog_unload(self) -> None:
"""Clean up when cog is unloaded""" """Clean up when cog is unloaded with timeout"""
await self._cleanup() self._unloading = True
try:
# Create cleanup task with timeout
cleanup_task = asyncio.create_task(self._cleanup())
try:
await asyncio.wait_for(cleanup_task, timeout=UNLOAD_TIMEOUT)
except asyncio.TimeoutError:
logger.error("Cog unload timed out, forcing cleanup")
# Force cleanup of any remaining resources
await self._force_cleanup()
except Exception as e:
logger.error(f"Error during cog unload: {str(e)}")
await self._force_cleanup()
finally:
self._unloading = False
async def _force_cleanup(self) -> None:
"""Force cleanup of resources when timeout occurs"""
try:
# Cancel all tasks
if hasattr(self, "processor"):
await self.processor.force_cleanup()
# Force stop queue manager
if hasattr(self, "queue_manager"):
self.queue_manager.force_stop()
# Kill any remaining FFmpeg processes
if hasattr(self, "ffmpeg_mgr"):
self.ffmpeg_mgr.kill_all_processes()
# Clean up download directory
if hasattr(self, "download_path") and self.download_path.exists():
try:
await cleanup_downloads(str(self.download_path))
self.download_path.rmdir()
except Exception as e:
logger.error(f"Error force cleaning download directory: {str(e)}")
except Exception as e:
logger.error(f"Error during force cleanup: {str(e)}")
finally:
self.ready.clear()
async def _cleanup(self) -> None: async def _cleanup(self) -> None:
"""Clean up all resources""" """Clean up all resources with proper handling"""
try: try:
# Cancel initialization if still running # Cancel initialization if still running
if self._init_task and not self._init_task.done(): if self._init_task and not self._init_task.done():
self._init_task.cancel() self._init_task.cancel()
try: try:
await self._init_task await asyncio.wait_for(self._init_task, timeout=CLEANUP_TIMEOUT)
except asyncio.CancelledError: except (asyncio.TimeoutError, asyncio.CancelledError):
pass pass
# Stop update checker # Stop update checker
if hasattr(self, "update_checker"): if hasattr(self, "update_checker"):
await self.update_checker.stop() try:
await asyncio.wait_for(self.update_checker.stop(), timeout=CLEANUP_TIMEOUT)
except asyncio.TimeoutError:
pass
# Clean up processor # Clean up processor
if hasattr(self, "processor"): if hasattr(self, "processor"):
await self.processor.cleanup() try:
await asyncio.wait_for(self.processor.cleanup(), timeout=CLEANUP_TIMEOUT)
except asyncio.TimeoutError:
await self.processor.force_cleanup()
# Clean up queue manager # Clean up queue manager
if hasattr(self, "queue_manager"): if hasattr(self, "queue_manager"):
await self.queue_manager.cleanup() try:
await asyncio.wait_for(self.queue_manager.cleanup(), timeout=CLEANUP_TIMEOUT)
except asyncio.TimeoutError:
self.queue_manager.force_stop()
# Clean up components for each guild # Clean up components for each guild
if hasattr(self, "components"): if hasattr(self, "components"):
@@ -191,8 +239,8 @@ class VideoArchiver(commands.Cog):
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {traceback.format_exc()}") logger.error(f"Error during cleanup: {traceback.format_exc()}")
raise ProcessingError(f"Cleanup failed: {str(e)}")
finally: finally:
# Clear ready flag
self.ready.clear() self.ready.clear()
async def initialize_guild_components(self, guild_id: int) -> None: async def initialize_guild_components(self, guild_id: int) -> None: