Core Systems:

Component-based architecture with lifecycle management
Enhanced error handling and recovery mechanisms
Comprehensive state management and tracking
Event-driven architecture with monitoring
Queue Management:

Multiple processing strategies for different scenarios
Advanced state management with recovery
Comprehensive metrics and health monitoring
Sophisticated cleanup system with multiple strategies
Processing Pipeline:

Enhanced message handling with validation
Improved URL extraction and processing
Better queue management and monitoring
Advanced cleanup mechanisms
Overall Benefits:

Better code organization and maintainability
Improved error handling and recovery
Enhanced monitoring and reporting
More robust and reliable system
This commit is contained in:
pacnpal
2024-11-16 05:01:29 +00:00
parent 537a325807
commit a4ca6e8ea6
47 changed files with 11085 additions and 2110 deletions

View File

@@ -4,154 +4,216 @@ from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
from redbot.core.bot import Red
from redbot.core.commands import GroupCog
from .initialization import initialize_cog, init_callback
from .error_handler import handle_command_error
from .cleanup import cleanup_resources, force_cleanup_resources
from .settings import Settings
from .lifecycle import LifecycleManager
from .component_manager import ComponentManager, ComponentState
from .error_handler import error_manager, handle_command_error
from .response_handler import response_manager
from .commands import setup_archiver_commands, setup_database_commands, setup_settings_commands
from ..utils.exceptions import VideoArchiverError as ProcessingError
from .events import setup_events
logger = logging.getLogger("VideoArchiver")
# Constants for timeouts
UNLOAD_TIMEOUT = 30 # seconds
CLEANUP_TIMEOUT = 15 # seconds
class CogStatus:
"""Tracks cog status and health"""
class VideoArchiver(GroupCog):
def __init__(self):
self.start_time = datetime.utcnow()
self.last_error: Optional[str] = None
self.error_count = 0
self.command_count = 0
self.last_command_time: Optional[datetime] = None
self.health_checks: Dict[str, bool] = {}
def record_error(self, error: str) -> None:
"""Record an error occurrence"""
self.last_error = error
self.error_count += 1
def record_command(self) -> None:
"""Record a command execution"""
self.command_count += 1
self.last_command_time = datetime.utcnow()
def update_health_check(self, check: str, status: bool) -> None:
"""Update health check status"""
self.health_checks[check] = status
def get_status(self) -> Dict[str, Any]:
"""Get current status"""
return {
"uptime": (datetime.utcnow() - self.start_time).total_seconds(),
"last_error": self.last_error,
"error_count": self.error_count,
"command_count": self.command_count,
"last_command": self.last_command_time.isoformat() if self.last_command_time else None,
"health_checks": self.health_checks.copy()
}
class ComponentAccessor:
"""Provides safe access to components"""
def __init__(self, component_manager: ComponentManager):
self._component_manager = component_manager
def get_component(self, name: str) -> Optional[Any]:
"""Get a component with state validation"""
component = self._component_manager.get(name)
if component and component.state == ComponentState.READY:
return component
return None
def get_component_status(self, name: str) -> Dict[str, Any]:
"""Get component status"""
return self._component_manager.get_component_status().get(name, {})
class VideoArchiver(GroupCog, Settings):
"""Archive videos from Discord channels"""
default_guild_settings = {
"enabled": False,
"archive_channel": None,
"log_channel": None,
"enabled_channels": [], # Empty list means all channels
"allowed_roles": [], # Empty list means all roles
"video_format": "mp4",
"video_quality": "high",
"max_file_size": 8, # MB
"message_duration": 30, # seconds
"message_template": "{author} archived a video from {channel}",
"concurrent_downloads": 2,
"enabled_sites": None, # None means all sites
"use_database": False, # Database tracking is off by default
}
def __init__(self, bot: Red) -> None:
"""Initialize the cog with minimal setup"""
super().__init__()
self.bot = bot
self.ready = asyncio.Event()
self._init_task = None
self._cleanup_task = None
self._queue_task = None
self._unloading = False
self.db = None
self.queue_manager = None
self.processor = None
self.components = {}
self.config_manager = None
self.update_checker = None
self.ffmpeg_mgr = None
self.data_path = None
self.download_path = None
# Initialize managers
self.lifecycle_manager = LifecycleManager(self)
self.component_manager = ComponentManager(self)
self.component_accessor = ComponentAccessor(self.component_manager)
self.status = CogStatus()
# Set up commands
setup_archiver_commands(self)
setup_database_commands(self)
setup_settings_commands(self)
# Set up events - non-blocking
from .events import setup_events
# Set up events
setup_events(self)
# Register cleanup handlers
self.lifecycle_manager.register_cleanup_handler(self._cleanup_handler)
async def cog_load(self) -> None:
"""Handle cog loading without blocking"""
"""Handle cog loading"""
try:
# Start initialization as background task without waiting
self._init_task = asyncio.create_task(initialize_cog(self))
self._init_task.add_done_callback(lambda t: init_callback(self, t))
logger.info("Initialization started in background")
await self.lifecycle_manager.handle_load()
await self._start_health_monitoring()
except Exception as e:
# Ensure cleanup on any error
try:
await asyncio.wait_for(
force_cleanup_resources(self), timeout=CLEANUP_TIMEOUT
)
except asyncio.TimeoutError:
logger.error("Force cleanup during load error timed out")
raise ProcessingError(f"Error during cog load: {str(e)}")
self.status.record_error(str(e))
raise
async def cog_unload(self) -> None:
"""Clean up when cog is unloaded with proper timeout handling"""
self._unloading = True
"""Handle cog unloading"""
try:
# Cancel any pending tasks
if self._init_task and not self._init_task.done():
self._init_task.cancel()
if self._cleanup_task and not self._cleanup_task.done():
self._cleanup_task.cancel()
# Cancel queue processing task if it exists
if (
hasattr(self, "_queue_task")
and self._queue_task
and not self._queue_task.done()
):
self._queue_task.cancel()
try:
await self._queue_task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Error cancelling queue task: {e}")
# Try normal cleanup first
cleanup_task = asyncio.create_task(cleanup_resources(self))
try:
await asyncio.wait_for(cleanup_task, timeout=UNLOAD_TIMEOUT)
logger.info("Normal cleanup completed")
except (asyncio.TimeoutError, Exception) as e:
if isinstance(e, asyncio.TimeoutError):
logger.warning("Normal cleanup timed out, forcing cleanup")
else:
logger.error(f"Error during normal cleanup: {str(e)}")
# Cancel normal cleanup and force cleanup
cleanup_task.cancel()
try:
# Force cleanup with timeout
await asyncio.wait_for(
force_cleanup_resources(self), timeout=CLEANUP_TIMEOUT
)
logger.info("Force cleanup completed")
except asyncio.TimeoutError:
logger.error("Force cleanup timed out")
except Exception as e:
logger.error(f"Error during force cleanup: {str(e)}")
await self.lifecycle_manager.handle_unload()
except Exception as e:
logger.error(f"Error during cog unload: {str(e)}")
finally:
self._unloading = False
# Ensure ready flag is cleared
self.ready.clear()
# Clear all references
self.bot = None
self.processor = None
self.queue_manager = None
self.update_checker = None
self.ffmpeg_mgr = None
self.components.clear()
self.db = None
self._init_task = None
self._cleanup_task = None
if hasattr(self, "_queue_task"):
self._queue_task = None
self.status.record_error(str(e))
raise
async def cog_command_error(self, ctx, error):
"""Handle command errors"""
self.status.record_error(str(error))
await handle_command_error(ctx, error)
async def cog_before_invoke(self, ctx) -> bool:
"""Pre-command hook"""
self.status.record_command()
return True
async def _start_health_monitoring(self) -> None:
"""Start health monitoring tasks"""
asyncio.create_task(self._monitor_component_health())
asyncio.create_task(self._monitor_system_health())
async def _monitor_component_health(self) -> None:
"""Monitor component health"""
while True:
try:
component_status = self.component_manager.get_component_status()
for name, status in component_status.items():
self.status.update_health_check(
f"component_{name}",
status["state"] == ComponentState.READY.value
)
except Exception as e:
logger.error(f"Error monitoring component health: {e}")
await asyncio.sleep(60) # Check every minute
async def _monitor_system_health(self) -> None:
"""Monitor system health metrics"""
while True:
try:
# Check queue health
queue_manager = self.queue_manager
if queue_manager:
queue_status = await queue_manager.get_queue_status()
self.status.update_health_check(
"queue_health",
queue_status["active"] and not queue_status["stalled"]
)
# Check processor health
processor = self.processor
if processor:
processor_status = await processor.get_status()
self.status.update_health_check(
"processor_health",
processor_status["active"]
)
except Exception as e:
logger.error(f"Error monitoring system health: {e}")
await asyncio.sleep(30) # Check every 30 seconds
async def _cleanup_handler(self) -> None:
"""Custom cleanup handler"""
try:
# Perform any custom cleanup
pass
except Exception as e:
logger.error(f"Error in cleanup handler: {e}")
def get_status(self) -> Dict[str, Any]:
"""Get comprehensive cog status"""
return {
"cog": self.status.get_status(),
"lifecycle": self.lifecycle_manager.get_status(),
"components": self.component_manager.get_component_status(),
"errors": error_manager.tracker.get_error_stats()
}
# Component property accessors
@property
def processor(self):
"""Get the processor component"""
return self.component_accessor.get_component("processor")
@property
def queue_manager(self):
"""Get the queue manager component"""
return self.component_accessor.get_component("queue_manager")
@property
def config_manager(self):
"""Get the config manager component"""
return self.component_accessor.get_component("config_manager")
@property
def ffmpeg_mgr(self):
"""Get the FFmpeg manager component"""
return self.component_accessor.get_component("ffmpeg_mgr")
@property
def data_path(self):
"""Get the data path"""
return self.component_accessor.get_component("data_path")
@property
def download_path(self):
"""Get the download path"""
return self.component_accessor.get_component("download_path")

View File

@@ -0,0 +1,261 @@
"""Module for managing VideoArchiver components"""
import logging
import asyncio
from typing import Dict, Any, Optional, Set, List
from enum import Enum
from datetime import datetime
logger = logging.getLogger("VideoArchiver")
class ComponentState(Enum):
"""Possible states of a component"""
UNREGISTERED = "unregistered"
REGISTERED = "registered"
INITIALIZING = "initializing"
READY = "ready"
ERROR = "error"
SHUTDOWN = "shutdown"
class ComponentDependencyError(Exception):
"""Raised when component dependencies cannot be satisfied"""
pass
class ComponentLifecycleError(Exception):
"""Raised when component lifecycle operations fail"""
pass
class Component:
"""Base class for managed components"""
def __init__(self, name: str):
self.name = name
self.state = ComponentState.UNREGISTERED
self.dependencies: Set[str] = set()
self.dependents: Set[str] = set()
self.registration_time: Optional[datetime] = None
self.initialization_time: Optional[datetime] = None
self.error: Optional[str] = None
async def initialize(self) -> None:
"""Initialize the component"""
pass
async def shutdown(self) -> None:
"""Shutdown the component"""
pass
class ComponentTracker:
"""Tracks component states and relationships"""
def __init__(self):
self.states: Dict[str, ComponentState] = {}
self.history: List[Dict[str, Any]] = []
def update_state(self, name: str, state: ComponentState, error: Optional[str] = None) -> None:
"""Update component state"""
self.states[name] = state
self.history.append({
"component": name,
"state": state.value,
"timestamp": datetime.utcnow(),
"error": error
})
def get_component_history(self, name: str) -> List[Dict[str, Any]]:
"""Get state history for a component"""
return [
entry for entry in self.history
if entry["component"] == name
]
class DependencyManager:
"""Manages component dependencies"""
def __init__(self):
self.dependencies: Dict[str, Set[str]] = {}
self.dependents: Dict[str, Set[str]] = {}
def add_dependency(self, component: str, dependency: str) -> None:
"""Add a dependency relationship"""
if component not in self.dependencies:
self.dependencies[component] = set()
self.dependencies[component].add(dependency)
if dependency not in self.dependents:
self.dependents[dependency] = set()
self.dependents[dependency].add(component)
def get_dependencies(self, component: str) -> Set[str]:
"""Get dependencies for a component"""
return self.dependencies.get(component, set())
def get_dependents(self, component: str) -> Set[str]:
"""Get components that depend on this component"""
return self.dependents.get(component, set())
def get_initialization_order(self) -> List[str]:
"""Get components in dependency order"""
visited = set()
order = []
def visit(component: str) -> None:
if component in visited:
return
visited.add(component)
for dep in self.dependencies.get(component, set()):
visit(dep)
order.append(component)
for component in self.dependencies:
visit(component)
return order
class ComponentManager:
"""Manages VideoArchiver components"""
def __init__(self, cog):
self.cog = cog
self._components: Dict[str, Component] = {}
self.tracker = ComponentTracker()
self.dependency_manager = DependencyManager()
def register(
self,
name: str,
component: Any,
dependencies: Optional[Set[str]] = None
) -> None:
"""Register a component with dependencies"""
try:
# Wrap non-Component objects
if not isinstance(component, Component):
component = Component(name)
# Register dependencies
if dependencies:
for dep in dependencies:
if dep not in self._components:
raise ComponentDependencyError(
f"Dependency {dep} not registered for {name}"
)
self.dependency_manager.add_dependency(name, dep)
# Register component
self._components[name] = component
component.registration_time = datetime.utcnow()
self.tracker.update_state(name, ComponentState.REGISTERED)
logger.debug(f"Registered component: {name}")
except Exception as e:
logger.error(f"Error registering component {name}: {e}")
self.tracker.update_state(name, ComponentState.ERROR, str(e))
raise ComponentLifecycleError(f"Failed to register component: {str(e)}")
async def initialize_components(self) -> None:
"""Initialize all components in dependency order"""
try:
# Get initialization order
init_order = self.dependency_manager.get_initialization_order()
# Initialize core components first
await self._initialize_core_components()
# Initialize remaining components
for name in init_order:
if name not in self._components:
continue
component = self._components[name]
try:
self.tracker.update_state(name, ComponentState.INITIALIZING)
await component.initialize()
component.initialization_time = datetime.utcnow()
self.tracker.update_state(name, ComponentState.READY)
except Exception as e:
logger.error(f"Error initializing component {name}: {e}")
self.tracker.update_state(name, ComponentState.ERROR, str(e))
raise ComponentLifecycleError(
f"Failed to initialize component {name}: {str(e)}"
)
except Exception as e:
logger.error(f"Error during component initialization: {e}")
raise ComponentLifecycleError(f"Component initialization failed: {str(e)}")
async def _initialize_core_components(self) -> None:
"""Initialize core system components"""
from ..config_manager import ConfigManager
from ..processor.core import Processor
from ..queue.manager import QueueManager
from ..ffmpeg.ffmpeg_manager import FFmpegManager
core_components = {
"config_manager": (ConfigManager(self.cog), set()),
"processor": (Processor(self.cog), {"config_manager"}),
"queue_manager": (QueueManager(self.cog), {"config_manager"}),
"ffmpeg_mgr": (FFmpegManager(self.cog), set())
}
for name, (component, deps) in core_components.items():
self.register(name, component, deps)
# Initialize paths
await self._initialize_paths()
async def _initialize_paths(self) -> None:
"""Initialize required paths"""
from pathlib import Path
from ..utils.path_manager import ensure_directory
data_dir = Path(self.cog.bot.data_path) / "VideoArchiver"
download_dir = data_dir / "downloads"
# Ensure directories exist
await ensure_directory(data_dir)
await ensure_directory(download_dir)
# Register paths
self.register("data_path", data_dir)
self.register("download_path", download_dir)
def get(self, name: str) -> Optional[Any]:
"""Get a registered component"""
component = self._components.get(name)
return component if isinstance(component, Component) else None
async def shutdown_components(self) -> None:
"""Shutdown components in reverse dependency order"""
shutdown_order = reversed(self.dependency_manager.get_initialization_order())
for name in shutdown_order:
if name not in self._components:
continue
component = self._components[name]
try:
await component.shutdown()
self.tracker.update_state(name, ComponentState.SHUTDOWN)
except Exception as e:
logger.error(f"Error shutting down component {name}: {e}")
self.tracker.update_state(name, ComponentState.ERROR, str(e))
def clear(self) -> None:
"""Clear all registered components"""
self._components.clear()
logger.debug("Cleared all components")
def get_component_status(self) -> Dict[str, Any]:
"""Get status of all components"""
return {
name: {
"state": self.tracker.states.get(name, ComponentState.UNREGISTERED).value,
"registration_time": component.registration_time,
"initialization_time": component.initialization_time,
"dependencies": self.dependency_manager.get_dependencies(name),
"dependents": self.dependency_manager.get_dependents(name),
"error": component.error
}
for name, component in self._components.items()
}

View File

@@ -2,45 +2,201 @@
import logging
import traceback
from redbot.core.commands import Context, MissingPermissions, BotMissingPermissions, MissingRequiredArgument, BadArgument
from typing import Dict, Optional, Tuple, Type
import discord
from redbot.core.commands import (
Context,
MissingPermissions,
BotMissingPermissions,
MissingRequiredArgument,
BadArgument,
CommandError
)
from ..utils.exceptions import VideoArchiverError as ProcessingError, ConfigurationError as ConfigError
from .response_handler import handle_response
from .response_handler import response_manager
logger = logging.getLogger("VideoArchiver")
async def handle_command_error(ctx: Context, error: Exception) -> None:
"""Handle command errors"""
error_msg = None
try:
class ErrorFormatter:
"""Formats error messages for display"""
@staticmethod
def format_permission_error(error: Exception) -> str:
"""Format permission error messages"""
if isinstance(error, MissingPermissions):
error_msg = "You don't have permission to use this command."
return "You don't have permission to use this command."
elif isinstance(error, BotMissingPermissions):
error_msg = "I don't have the required permissions to do that."
elif isinstance(error, MissingRequiredArgument):
error_msg = f"❌ Missing required argument: {error.param.name}"
return "I don't have the required permissions to do that."
return str(error)
@staticmethod
def format_argument_error(error: Exception) -> str:
"""Format argument error messages"""
if isinstance(error, MissingRequiredArgument):
return f"Missing required argument: {error.param.name}"
elif isinstance(error, BadArgument):
error_msg = f"Invalid argument: {str(error)}"
elif isinstance(error, ConfigError):
error_msg = f"❌ Configuration error: {str(error)}"
elif isinstance(error, ProcessingError):
error_msg = f"❌ Processing error: {str(error)}"
else:
logger.error(
f"Command error in {ctx.command}: {traceback.format_exc()}"
)
error_msg = (
"❌ An unexpected error occurred. Check the logs for details."
)
return f"Invalid argument: {str(error)}"
return str(error)
if error_msg:
await handle_response(ctx, error_msg)
@staticmethod
def format_processing_error(error: ProcessingError) -> str:
"""Format processing error messages"""
return f"Processing error: {str(error)}"
except Exception as e:
logger.error(f"Error handling command error: {str(e)}")
@staticmethod
def format_config_error(error: ConfigError) -> str:
"""Format configuration error messages"""
return f"Configuration error: {str(error)}"
@staticmethod
def format_unexpected_error(error: Exception) -> str:
"""Format unexpected error messages"""
return "An unexpected error occurred. Check the logs for details."
class ErrorCategorizer:
"""Categorizes errors and determines handling strategy"""
ERROR_TYPES = {
MissingPermissions: ("permission", "error"),
BotMissingPermissions: ("permission", "error"),
MissingRequiredArgument: ("argument", "warning"),
BadArgument: ("argument", "warning"),
ConfigError: ("configuration", "error"),
ProcessingError: ("processing", "error"),
}
@classmethod
def categorize_error(cls, error: Exception) -> Tuple[str, str]:
"""Categorize an error and determine its severity
Returns:
Tuple[str, str]: (Error category, Severity level)
"""
for error_type, (category, severity) in cls.ERROR_TYPES.items():
if isinstance(error, error_type):
return category, severity
return "unexpected", "error"
class ErrorTracker:
"""Tracks error occurrences and patterns"""
def __init__(self):
self.error_counts: Dict[str, int] = {}
self.error_patterns: Dict[str, Dict[str, int]] = {}
def track_error(self, error: Exception, category: str) -> None:
"""Track an error occurrence"""
error_type = type(error).__name__
self.error_counts[error_type] = self.error_counts.get(error_type, 0) + 1
if category not in self.error_patterns:
self.error_patterns[category] = {}
self.error_patterns[category][error_type] = self.error_patterns[category].get(error_type, 0) + 1
def get_error_stats(self) -> Dict:
"""Get error statistics"""
return {
"counts": self.error_counts.copy(),
"patterns": self.error_patterns.copy()
}
class ErrorManager:
"""Manages error handling and reporting"""
def __init__(self):
self.formatter = ErrorFormatter()
self.categorizer = ErrorCategorizer()
self.tracker = ErrorTracker()
async def handle_error(
self,
ctx: Context,
error: Exception
) -> None:
"""Handle a command error
Args:
ctx: Command context
error: The error that occurred
"""
try:
await handle_response(
# Categorize error
category, severity = self.categorizer.categorize_error(error)
# Track error
self.tracker.track_error(error, category)
# Format error message
error_msg = await self._format_error_message(error, category)
# Log error details
self._log_error(ctx, error, category, severity)
# Send response
await response_manager.send_response(
ctx,
"❌ An error occurred while handling another error. Please check the logs.",
content=error_msg,
response_type=severity
)
except Exception:
pass
except Exception as e:
logger.error(f"Error handling command error: {str(e)}")
try:
await response_manager.send_response(
ctx,
content="An error occurred while handling another error. Please check the logs.",
response_type="error"
)
except Exception:
pass
async def _format_error_message(
self,
error: Exception,
category: str
) -> str:
"""Format error message based on category"""
try:
if category == "permission":
return self.formatter.format_permission_error(error)
elif category == "argument":
return self.formatter.format_argument_error(error)
elif category == "processing":
return self.formatter.format_processing_error(error)
elif category == "configuration":
return self.formatter.format_config_error(error)
else:
return self.formatter.format_unexpected_error(error)
except Exception as e:
logger.error(f"Error formatting error message: {e}")
return "An error occurred. Please check the logs."
def _log_error(
self,
ctx: Context,
error: Exception,
category: str,
severity: str
) -> None:
"""Log error details"""
try:
if severity == "error":
logger.error(
f"Command error in {ctx.command} (Category: {category}):\n"
f"{traceback.format_exc()}"
)
else:
logger.warning(
f"Command warning in {ctx.command} (Category: {category}):\n"
f"{str(error)}"
)
except Exception as e:
logger.error(f"Error logging error details: {e}")
# Global error manager instance
error_manager = ErrorManager()
async def handle_command_error(ctx: Context, error: Exception) -> None:
"""Helper function to handle command errors using the error manager"""
await error_manager.handle_error(ctx, error)

View File

@@ -4,97 +4,207 @@ import logging
import discord
import asyncio
import traceback
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Dict, Any, Optional
from datetime import datetime
from ..processor.reactions import REACTIONS, handle_archived_reaction
from .guild import initialize_guild_components, cleanup_guild_components
from .error_handler import error_manager
from .response_handler import response_manager
if TYPE_CHECKING:
from .base import VideoArchiver
logger = logging.getLogger("VideoArchiver")
def setup_events(cog: "VideoArchiver") -> None:
"""Set up event handlers for the cog"""
class EventTracker:
"""Tracks event occurrences and patterns"""
@cog.listener()
async def on_guild_join(guild: discord.Guild) -> None:
def __init__(self):
self.event_counts: Dict[str, int] = {}
self.last_events: Dict[str, datetime] = {}
self.error_counts: Dict[str, int] = {}
def record_event(self, event_type: str) -> None:
"""Record an event occurrence"""
self.event_counts[event_type] = self.event_counts.get(event_type, 0) + 1
self.last_events[event_type] = datetime.utcnow()
def record_error(self, event_type: str) -> None:
"""Record an event error"""
self.error_counts[event_type] = self.error_counts.get(event_type, 0) + 1
def get_stats(self) -> Dict[str, Any]:
"""Get event statistics"""
return {
"counts": self.event_counts.copy(),
"last_events": {k: v.isoformat() for k, v in self.last_events.items()},
"errors": self.error_counts.copy()
}
class GuildEventHandler:
"""Handles guild-related events"""
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
self.cog = cog
self.tracker = tracker
async def handle_guild_join(self, guild: discord.Guild) -> None:
"""Handle bot joining a new guild"""
if not cog.ready.is_set():
self.tracker.record_event("guild_join")
if not self.cog.ready.is_set():
return
try:
await initialize_guild_components(cog, guild.id)
await initialize_guild_components(self.cog, guild.id)
logger.info(f"Initialized components for new guild {guild.id}")
except Exception as e:
self.tracker.record_error("guild_join")
logger.error(f"Failed to initialize new guild {guild.id}: {str(e)}")
@cog.listener()
async def on_guild_remove(guild: discord.Guild) -> None:
async def handle_guild_remove(self, guild: discord.Guild) -> None:
"""Handle bot leaving a guild"""
self.tracker.record_event("guild_remove")
try:
await cleanup_guild_components(cog, guild.id)
await cleanup_guild_components(self.cog, guild.id)
except Exception as e:
self.tracker.record_error("guild_remove")
logger.error(f"Error cleaning up removed guild {guild.id}: {str(e)}")
@cog.listener()
async def on_message(message: discord.Message) -> None:
class MessageEventHandler:
"""Handles message-related events"""
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
self.cog = cog
self.tracker = tracker
async def handle_message(self, message: discord.Message) -> None:
"""Handle new messages for video processing"""
self.tracker.record_event("message")
# Skip if not ready or if message is from DM/bot
if not cog.ready.is_set() or message.guild is None or message.author.bot:
if not self.cog.ready.is_set() or message.guild is None or message.author.bot:
return
# Skip if message is a command
ctx = await cog.bot.get_context(message)
ctx = await self.cog.bot.get_context(message)
if ctx.valid:
return
# Process message in background task to avoid blocking
asyncio.create_task(process_message_background(cog, message))
@cog.listener()
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None:
"""Handle reactions to messages"""
if payload.user_id == cog.bot.user.id:
return
# Process message in background task
asyncio.create_task(self._process_message_background(message))
async def _process_message_background(self, message: discord.Message) -> None:
"""Process message in background to avoid blocking"""
try:
# Get the channel and message
channel = cog.bot.get_channel(payload.channel_id)
if not channel:
return
message = await channel.fetch_message(payload.message_id)
if not message:
return
# Check if it's the archived reaction
if str(payload.emoji) == REACTIONS["archived"]:
# Only process if database is enabled
if cog.db:
user = cog.bot.get_user(payload.user_id)
# Process reaction in background task
asyncio.create_task(handle_archived_reaction(message, user, cog.db))
await self.cog.processor.process_message(message)
except Exception as e:
logger.error(f"Error handling reaction: {e}")
self.tracker.record_error("message_processing")
await self._handle_processing_error(message, e)
async def process_message_background(cog: "VideoArchiver", message: discord.Message) -> None:
"""Process message in background to avoid blocking"""
try:
await cog.processor.process_message(message)
except Exception as e:
async def _handle_processing_error(
self,
message: discord.Message,
error: Exception
) -> None:
"""Handle message processing errors"""
logger.error(
f"Error processing message {message.id}: {traceback.format_exc()}"
)
try:
log_channel = await cog.config_manager.get_channel(
log_channel = await self.cog.config_manager.get_channel(
message.guild, "log"
)
if log_channel:
await log_channel.send(
f"Error processing message: {str(e)}\n"
f"Message ID: {message.id}\n"
f"Channel: {message.channel.mention}"
await response_manager.send_response(
log_channel,
content=(
f"Error processing message: {str(error)}\n"
f"Message ID: {message.id}\n"
f"Channel: {message.channel.mention}"
),
response_type="error"
)
except Exception as log_error:
logger.error(f"Failed to log error to guild: {str(log_error)}")
class ReactionEventHandler:
"""Handles reaction-related events"""
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
self.cog = cog
self.tracker = tracker
async def handle_reaction_add(
self,
payload: discord.RawReactionActionEvent
) -> None:
"""Handle reactions to messages"""
self.tracker.record_event("reaction_add")
if payload.user_id == self.cog.bot.user.id:
return
try:
await self._process_reaction(payload)
except Exception as e:
self.tracker.record_error("reaction_processing")
logger.error(f"Error handling reaction: {e}")
async def _process_reaction(
self,
payload: discord.RawReactionActionEvent
) -> None:
"""Process a reaction event"""
# Get the channel and message
channel = self.cog.bot.get_channel(payload.channel_id)
if not channel:
return
message = await channel.fetch_message(payload.message_id)
if not message:
return
# Check if it's the archived reaction
if str(payload.emoji) == REACTIONS["archived"]:
# Only process if database is enabled
if self.cog.db:
user = self.cog.bot.get_user(payload.user_id)
asyncio.create_task(
handle_archived_reaction(message, user, self.cog.db)
)
class EventManager:
"""Manages Discord event handling"""
def __init__(self, cog: "VideoArchiver"):
self.tracker = EventTracker()
self.guild_handler = GuildEventHandler(cog, self.tracker)
self.message_handler = MessageEventHandler(cog, self.tracker)
self.reaction_handler = ReactionEventHandler(cog, self.tracker)
def get_stats(self) -> Dict[str, Any]:
"""Get event statistics"""
return self.tracker.get_stats()
def setup_events(cog: "VideoArchiver") -> None:
"""Set up event handlers for the cog"""
event_manager = EventManager(cog)
@cog.listener()
async def on_guild_join(guild: discord.Guild) -> None:
await event_manager.guild_handler.handle_guild_join(guild)
@cog.listener()
async def on_guild_remove(guild: discord.Guild) -> None:
await event_manager.guild_handler.handle_guild_remove(guild)
@cog.listener()
async def on_message(message: discord.Message) -> None:
await event_manager.message_handler.handle_message(message)
@cog.listener()
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None:
await event_manager.reaction_handler.handle_reaction_add(payload)

View File

@@ -4,6 +4,7 @@ import logging
import asyncio
import traceback
from pathlib import Path
from typing import Dict, Any, Optional
from redbot.core import Config, data_manager
from ..config_manager import ConfigManager
@@ -17,83 +18,197 @@ from ..utils.exceptions import VideoArchiverError as ProcessingError
logger = logging.getLogger("VideoArchiver")
# Constants for timeouts
INIT_TIMEOUT = 60 # seconds
COMPONENT_INIT_TIMEOUT = 30 # seconds
CLEANUP_TIMEOUT = 15 # seconds
class InitializationTracker:
"""Tracks initialization progress"""
def __init__(self):
self.total_steps = 8 # Total number of initialization steps
self.current_step = 0
self.current_component = ""
self.errors: Dict[str, str] = {}
def start_step(self, component: str) -> None:
"""Start a new initialization step"""
self.current_step += 1
self.current_component = component
logger.info(f"Initializing {component} ({self.current_step}/{self.total_steps})")
def record_error(self, component: str, error: str) -> None:
"""Record an initialization error"""
self.errors[component] = error
logger.error(f"Error initializing {component}: {error}")
def get_progress(self) -> Dict[str, Any]:
"""Get current initialization progress"""
return {
"progress": (self.current_step / self.total_steps) * 100,
"current_component": self.current_component,
"errors": self.errors.copy()
}
class ComponentInitializer:
"""Handles initialization of individual components"""
def __init__(self, cog, tracker: InitializationTracker):
self.cog = cog
self.tracker = tracker
async def init_config(self) -> None:
"""Initialize configuration manager"""
self.tracker.start_step("Config Manager")
try:
config = Config.get_conf(self.cog, identifier=855847, force_registration=True)
config.register_guild(**self.cog.default_guild_settings)
self.cog.config_manager = ConfigManager(config)
logger.info("Config manager initialized")
except Exception as e:
self.tracker.record_error("Config Manager", str(e))
raise
async def init_paths(self) -> None:
"""Initialize data paths"""
self.tracker.start_step("Paths")
try:
self.cog.data_path = Path(data_manager.cog_data_path(self.cog))
self.cog.download_path = self.cog.data_path / "downloads"
self.cog.download_path.mkdir(parents=True, exist_ok=True)
logger.info("Paths initialized")
except Exception as e:
self.tracker.record_error("Paths", str(e))
raise
async def init_ffmpeg(self) -> None:
"""Initialize FFmpeg manager"""
self.tracker.start_step("FFmpeg Manager")
try:
self.cog.ffmpeg_mgr = FFmpegManager()
logger.info("FFmpeg manager initialized")
except Exception as e:
self.tracker.record_error("FFmpeg Manager", str(e))
raise
async def init_queue(self) -> None:
"""Initialize queue manager"""
self.tracker.start_step("Queue Manager")
try:
queue_path = self.cog.data_path / "queue_state.json"
queue_path.parent.mkdir(parents=True, exist_ok=True)
self.cog.queue_manager = EnhancedVideoQueueManager(
max_retries=3,
retry_delay=5,
max_queue_size=1000,
cleanup_interval=1800,
max_history_age=86400,
persistence_path=str(queue_path),
)
await self.cog.queue_manager.initialize()
logger.info("Queue manager initialized")
except Exception as e:
self.tracker.record_error("Queue Manager", str(e))
raise
async def init_processor(self) -> None:
"""Initialize video processor"""
self.tracker.start_step("Video Processor")
try:
self.cog.processor = VideoProcessor(
self.cog.bot,
self.cog.config_manager,
self.cog.components,
queue_manager=self.cog.queue_manager,
ffmpeg_mgr=self.cog.ffmpeg_mgr,
db=self.cog.db,
)
logger.info("Video processor initialized")
except Exception as e:
self.tracker.record_error("Video Processor", str(e))
raise
async def init_guilds(self) -> None:
"""Initialize guild components"""
self.tracker.start_step("Guild Components")
errors = []
for guild in self.cog.bot.guilds:
try:
await initialize_guild_components(self.cog, guild.id)
except Exception as e:
errors.append(f"Guild {guild.id}: {str(e)}")
logger.error(f"Failed to initialize guild {guild.id}: {str(e)}")
if errors:
self.tracker.record_error("Guild Components", "; ".join(errors))
async def init_update_checker(self) -> None:
"""Initialize update checker"""
self.tracker.start_step("Update Checker")
try:
self.cog.update_checker = UpdateChecker(self.cog.bot, self.cog.config_manager)
await self.cog.update_checker.start()
logger.info("Update checker initialized")
except Exception as e:
self.tracker.record_error("Update Checker", str(e))
raise
async def start_queue_processing(self) -> None:
"""Start queue processing"""
self.tracker.start_step("Queue Processing")
try:
self.cog._queue_task = asyncio.create_task(
self.cog.queue_manager.process_queue(self.cog.processor.process_video)
)
logger.info("Queue processing started")
except Exception as e:
self.tracker.record_error("Queue Processing", str(e))
raise
class InitializationManager:
"""Manages VideoArchiver initialization"""
def __init__(self, cog):
self.cog = cog
self.tracker = InitializationTracker()
self.component_initializer = ComponentInitializer(cog, self.tracker)
async def initialize(self) -> None:
"""Initialize all components"""
try:
# Initialize components in sequence
await self.component_initializer.init_config()
await self.component_initializer.init_paths()
# Clean existing downloads
try:
await cleanup_downloads(str(self.cog.download_path))
except Exception as e:
logger.warning(f"Download cleanup error: {e}")
await self.component_initializer.init_ffmpeg()
await self.component_initializer.init_queue()
await self.component_initializer.init_processor()
await self.component_initializer.init_guilds()
await self.component_initializer.init_update_checker()
await self.component_initializer.start_queue_processing()
# Set ready flag
self.cog.ready.set()
logger.info("VideoArchiver initialization completed successfully")
except Exception as e:
logger.error(f"Error during initialization: {str(e)}")
await cleanup_resources(self.cog)
raise
def get_progress(self) -> Dict[str, Any]:
"""Get initialization progress"""
return self.tracker.get_progress()
# Global initialization manager instance
init_manager: Optional[InitializationManager] = None
async def initialize_cog(cog) -> None:
"""Initialize all components with proper error handling"""
try:
# Initialize config first as other components depend on it
config = Config.get_conf(cog, identifier=855847, force_registration=True)
config.register_guild(**cog.default_guild_settings)
cog.config_manager = ConfigManager(config)
logger.info("Config manager initialized")
# Set up paths
cog.data_path = Path(data_manager.cog_data_path(cog))
cog.download_path = cog.data_path / "downloads"
cog.download_path.mkdir(parents=True, exist_ok=True)
logger.info("Paths initialized")
# Clean existing downloads
try:
await cleanup_downloads(str(cog.download_path))
except Exception as e:
logger.warning(f"Download cleanup error: {e}")
# Initialize shared FFmpeg manager
cog.ffmpeg_mgr = FFmpegManager()
# Initialize queue manager
queue_path = cog.data_path / "queue_state.json"
queue_path.parent.mkdir(parents=True, exist_ok=True)
cog.queue_manager = EnhancedVideoQueueManager(
max_retries=3,
retry_delay=5,
max_queue_size=1000,
cleanup_interval=1800,
max_history_age=86400,
persistence_path=str(queue_path),
)
await cog.queue_manager.initialize()
# Initialize processor
cog.processor = VideoProcessor(
cog.bot,
cog.config_manager,
cog.components,
queue_manager=cog.queue_manager,
ffmpeg_mgr=cog.ffmpeg_mgr,
db=cog.db,
)
# Initialize components for existing guilds
for guild in cog.bot.guilds:
try:
await initialize_guild_components(cog, guild.id)
except Exception as e:
logger.error(f"Failed to initialize guild {guild.id}: {str(e)}")
continue
# Initialize update checker
cog.update_checker = UpdateChecker(cog.bot, cog.config_manager)
await cog.update_checker.start()
# Start queue processing as a background task
cog._queue_task = asyncio.create_task(
cog.queue_manager.process_queue(cog.processor.process_video)
)
# Set ready flag
cog.ready.set()
logger.info("VideoArchiver initialization completed successfully")
except Exception as e:
logger.error(f"Error during initialization: {str(e)}")
await cleanup_resources(cog)
raise
global init_manager
init_manager = InitializationManager(cog)
await init_manager.initialize()
def init_callback(cog, task: asyncio.Task) -> None:
"""Handle initialization task completion"""

View File

@@ -0,0 +1,239 @@
"""Module for managing VideoArchiver lifecycle"""
import asyncio
import logging
from typing import Optional, Dict, Any, Set
from enum import Enum
from datetime import datetime
from .cleanup import cleanup_resources, force_cleanup_resources
from ..utils.exceptions import VideoArchiverError
from .initialization import initialize_cog, init_callback
logger = logging.getLogger("VideoArchiver")
class LifecycleState(Enum):
"""Possible states in the cog lifecycle"""
UNINITIALIZED = "uninitialized"
INITIALIZING = "initializing"
READY = "ready"
UNLOADING = "unloading"
ERROR = "error"
class TaskManager:
"""Manages asyncio tasks"""
def __init__(self):
self._tasks: Dict[str, asyncio.Task] = {}
self._task_history: Dict[str, Dict[str, Any]] = {}
async def create_task(
self,
name: str,
coro,
callback=None
) -> asyncio.Task:
"""Create and track a task"""
task = asyncio.create_task(coro)
self._tasks[name] = task
self._task_history[name] = {
"start_time": datetime.utcnow(),
"status": "running"
}
if callback:
task.add_done_callback(lambda t: self._handle_completion(name, t, callback))
else:
task.add_done_callback(lambda t: self._handle_completion(name, t))
return task
def _handle_completion(
self,
name: str,
task: asyncio.Task,
callback=None
) -> None:
"""Handle task completion"""
try:
task.result() # Raises exception if task failed
status = "completed"
except asyncio.CancelledError:
status = "cancelled"
except Exception as e:
status = "failed"
logger.error(f"Task {name} failed: {e}")
self._task_history[name].update({
"end_time": datetime.utcnow(),
"status": status
})
if callback:
try:
callback(task)
except Exception as e:
logger.error(f"Task callback error for {name}: {e}")
self._tasks.pop(name, None)
async def cancel_task(self, name: str) -> None:
"""Cancel a specific task"""
if task := self._tasks.get(name):
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Error cancelling task {name}: {e}")
async def cancel_all_tasks(self) -> None:
"""Cancel all tracked tasks"""
for name in list(self._tasks.keys()):
await self.cancel_task(name)
def get_task_status(self) -> Dict[str, Any]:
"""Get status of all tasks"""
return {
"active_tasks": list(self._tasks.keys()),
"history": self._task_history.copy()
}
class StateTracker:
"""Tracks lifecycle state and transitions"""
def __init__(self):
self.state = LifecycleState.UNINITIALIZED
self.state_history: List[Dict[str, Any]] = []
self._record_state()
def set_state(self, state: LifecycleState) -> None:
"""Set current state"""
self.state = state
self._record_state()
def _record_state(self) -> None:
"""Record state transition"""
self.state_history.append({
"state": self.state.value,
"timestamp": datetime.utcnow()
})
def get_state_history(self) -> List[Dict[str, Any]]:
"""Get state transition history"""
return self.state_history.copy()
class LifecycleManager:
"""Manages the lifecycle of the VideoArchiver cog"""
def __init__(self, cog):
self.cog = cog
self.task_manager = TaskManager()
self.state_tracker = StateTracker()
self._cleanup_handlers: Set[callable] = set()
def register_cleanup_handler(self, handler: callable) -> None:
"""Register a cleanup handler"""
self._cleanup_handlers.add(handler)
async def handle_load(self) -> None:
"""Handle cog loading without blocking"""
try:
self.state_tracker.set_state(LifecycleState.INITIALIZING)
# Start initialization as background task
await self.task_manager.create_task(
"initialization",
initialize_cog(self.cog),
lambda t: init_callback(self.cog, t)
)
logger.info("Initialization started in background")
except Exception as e:
self.state_tracker.set_state(LifecycleState.ERROR)
# Ensure cleanup on any error
try:
await asyncio.wait_for(
force_cleanup_resources(self.cog),
timeout=15 # CLEANUP_TIMEOUT
)
except asyncio.TimeoutError:
logger.error("Force cleanup during load error timed out")
raise VideoArchiverError(f"Error during cog load: {str(e)}")
async def handle_unload(self) -> None:
"""Clean up when cog is unloaded"""
self.state_tracker.set_state(LifecycleState.UNLOADING)
try:
# Cancel all tasks
await self.task_manager.cancel_all_tasks()
# Run cleanup handlers
await self._run_cleanup_handlers()
# Try normal cleanup
try:
cleanup_task = await self.task_manager.create_task(
"cleanup",
cleanup_resources(self.cog)
)
await asyncio.wait_for(cleanup_task, timeout=30) # UNLOAD_TIMEOUT
logger.info("Normal cleanup completed")
except (asyncio.TimeoutError, Exception) as e:
if isinstance(e, asyncio.TimeoutError):
logger.warning("Normal cleanup timed out, forcing cleanup")
else:
logger.error(f"Error during normal cleanup: {str(e)}")
# Force cleanup
try:
await asyncio.wait_for(
force_cleanup_resources(self.cog),
timeout=15 # CLEANUP_TIMEOUT
)
logger.info("Force cleanup completed")
except asyncio.TimeoutError:
logger.error("Force cleanup timed out")
except Exception as e:
logger.error(f"Error during force cleanup: {str(e)}")
except Exception as e:
logger.error(f"Error during cog unload: {str(e)}")
self.state_tracker.set_state(LifecycleState.ERROR)
finally:
# Clear all references
await self._cleanup_references()
async def _run_cleanup_handlers(self) -> None:
"""Run all registered cleanup handlers"""
for handler in self._cleanup_handlers:
try:
if asyncio.iscoroutinefunction(handler):
await handler()
else:
handler()
except Exception as e:
logger.error(f"Error in cleanup handler: {e}")
async def _cleanup_references(self) -> None:
"""Clean up all references"""
self.cog.ready.clear()
self.cog.bot = None
self.cog.processor = None
self.cog.queue_manager = None
self.cog.update_checker = None
self.cog.ffmpeg_mgr = None
self.cog.components.clear()
self.cog.db = None
def get_status(self) -> Dict[str, Any]:
"""Get current lifecycle status"""
return {
"state": self.state_tracker.state.value,
"state_history": self.state_tracker.get_state_history(),
"tasks": self.task_manager.get_task_status()
}

View File

@@ -2,77 +2,197 @@
import logging
import discord
from typing import Optional, Union, Dict, Any
from redbot.core.commands import Context
logger = logging.getLogger("VideoArchiver")
async def handle_response(ctx: Context, content: str = None, embed: discord.Embed = None) -> None:
"""Helper method to handle responses for both regular commands and interactions"""
try:
# Check if this is a slash command interaction
is_interaction = hasattr(ctx, "interaction") and ctx.interaction is not None
class ResponseFormatter:
"""Formats responses for consistency"""
if is_interaction:
try:
# For slash commands
if not ctx.interaction.response.is_done():
# If not responded yet, send initial response
if embed:
await ctx.interaction.response.send_message(
content=content, embed=embed
)
else:
await ctx.interaction.response.send_message(content=content)
else:
# If already responded (deferred), use followup
try:
if embed:
await ctx.interaction.followup.send(
content=content, embed=embed
)
else:
await ctx.interaction.followup.send(content=content)
except AttributeError:
# Fallback if followup is not available
if embed:
await ctx.send(content=content, embed=embed)
else:
await ctx.send(content=content)
except discord.errors.InteractionResponded:
# If interaction was already responded to, try followup
try:
if embed:
await ctx.interaction.followup.send(
content=content, embed=embed
)
else:
await ctx.interaction.followup.send(content=content)
except (AttributeError, discord.errors.HTTPException):
# Final fallback to regular message
if embed:
await ctx.send(content=content, embed=embed)
else:
await ctx.send(content=content)
except Exception as e:
logger.error(f"Error handling interaction response: {e}")
# Fallback to regular message
@staticmethod
def format_success(message: str) -> Dict[str, Any]:
"""Format a success message"""
return {
"content": f"{message}",
"color": discord.Color.green()
}
@staticmethod
def format_error(message: str) -> Dict[str, Any]:
"""Format an error message"""
return {
"content": f"{message}",
"color": discord.Color.red()
}
@staticmethod
def format_warning(message: str) -> Dict[str, Any]:
"""Format a warning message"""
return {
"content": f"⚠️ {message}",
"color": discord.Color.yellow()
}
@staticmethod
def format_info(message: str) -> Dict[str, Any]:
"""Format an info message"""
return {
"content": f" {message}",
"color": discord.Color.blue()
}
class InteractionHandler:
"""Handles slash command interactions"""
@staticmethod
async def send_initial_response(
interaction: discord.Interaction,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None
) -> bool:
"""Send initial interaction response"""
try:
if not interaction.response.is_done():
if embed:
await ctx.send(content=content, embed=embed)
await interaction.response.send_message(content=content, embed=embed)
else:
await ctx.send(content=content)
else:
# Regular command response
await interaction.response.send_message(content=content)
return True
return False
except Exception as e:
logger.error(f"Error sending initial interaction response: {e}")
return False
@staticmethod
async def send_followup(
interaction: discord.Interaction,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None
) -> bool:
"""Send interaction followup"""
try:
if embed:
await ctx.send(content=content, embed=embed)
await interaction.followup.send(content=content, embed=embed)
else:
await ctx.send(content=content)
except Exception as e:
logger.error(f"Error sending response: {e}")
# Final fallback attempt
await interaction.followup.send(content=content)
return True
except Exception as e:
logger.error(f"Error sending interaction followup: {e}")
return False
class ResponseManager:
"""Manages command responses"""
def __init__(self):
self.formatter = ResponseFormatter()
self.interaction_handler = InteractionHandler()
async def send_response(
self,
ctx: Context,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None,
response_type: str = "normal"
) -> None:
"""Send a response to a command
Args:
ctx: Command context
content: Optional message content
embed: Optional embed
response_type: Type of response (normal, success, error, warning, info)
"""
try:
# Format response if type specified
if response_type != "normal":
format_method = getattr(self.formatter, f"format_{response_type}", None)
if format_method and content:
formatted = format_method(content)
content = formatted["content"]
if not embed:
embed = discord.Embed(color=formatted["color"])
# Handle response
if self._is_interaction(ctx):
await self._handle_interaction_response(ctx, content, embed)
else:
await self._handle_regular_response(ctx, content, embed)
except Exception as e:
logger.error(f"Error sending response: {e}")
await self._send_fallback_response(ctx, content, embed)
def _is_interaction(self, ctx: Context) -> bool:
"""Check if context is from an interaction"""
return hasattr(ctx, "interaction") and ctx.interaction is not None
async def _handle_interaction_response(
self,
ctx: Context,
content: Optional[str],
embed: Optional[discord.Embed]
) -> None:
"""Handle interaction response"""
try:
# Try initial response
if await self.interaction_handler.send_initial_response(
ctx.interaction, content, embed
):
return
# Try followup
if await self.interaction_handler.send_followup(
ctx.interaction, content, embed
):
return
# Fallback to regular message
await self._handle_regular_response(ctx, content, embed)
except Exception as e:
logger.error(f"Error handling interaction response: {e}")
await self._send_fallback_response(ctx, content, embed)
async def _handle_regular_response(
self,
ctx: Context,
content: Optional[str],
embed: Optional[discord.Embed]
) -> None:
"""Handle regular command response"""
try:
if embed:
await ctx.send(content=content, embed=embed)
else:
await ctx.send(content=content)
except Exception as e2:
logger.error(f"Failed to send fallback message: {e2}")
except Exception as e:
logger.error(f"Error sending regular response: {e}")
await self._send_fallback_response(ctx, content, embed)
async def _send_fallback_response(
self,
ctx: Context,
content: Optional[str],
embed: Optional[discord.Embed]
) -> None:
"""Send fallback response when other methods fail"""
try:
if embed:
await ctx.send(content=content, embed=embed)
else:
await ctx.send(content=content)
except Exception as e:
logger.error(f"Failed to send fallback response: {e}")
# Global response manager instance
response_manager = ResponseManager()
async def handle_response(
ctx: Context,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None,
response_type: str = "normal"
) -> None:
"""Helper function to handle responses using the response manager"""
await response_manager.send_response(ctx, content, embed, response_type)

View File

@@ -0,0 +1,228 @@
"""Module for managing VideoArchiver settings"""
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from enum import Enum
class VideoFormat(Enum):
"""Supported video formats"""
MP4 = "mp4"
WEBM = "webm"
MKV = "mkv"
class VideoQuality(Enum):
"""Video quality presets"""
LOW = "low" # 480p
MEDIUM = "medium" # 720p
HIGH = "high" # 1080p
ULTRA = "ultra" # 4K
@dataclass
class SettingDefinition:
"""Defines a setting's properties"""
name: str
category: str
default_value: Any
description: str
data_type: type
required: bool = True
min_value: Optional[int] = None
max_value: Optional[int] = None
choices: Optional[List[Any]] = None
depends_on: Optional[str] = None
class SettingCategory(Enum):
"""Setting categories"""
GENERAL = "general"
CHANNELS = "channels"
PERMISSIONS = "permissions"
VIDEO = "video"
MESSAGES = "messages"
PERFORMANCE = "performance"
FEATURES = "features"
class Settings:
"""Manages VideoArchiver settings"""
# Setting definitions
SETTINGS = {
"enabled": SettingDefinition(
name="enabled",
category=SettingCategory.GENERAL.value,
default_value=False,
description="Whether the archiver is enabled for this guild",
data_type=bool
),
"archive_channel": SettingDefinition(
name="archive_channel",
category=SettingCategory.CHANNELS.value,
default_value=None,
description="Channel where archived videos are posted",
data_type=int,
required=False
),
"log_channel": SettingDefinition(
name="log_channel",
category=SettingCategory.CHANNELS.value,
default_value=None,
description="Channel for logging archiver actions",
data_type=int,
required=False
),
"enabled_channels": SettingDefinition(
name="enabled_channels",
category=SettingCategory.CHANNELS.value,
default_value=[],
description="Channels to monitor (empty means all channels)",
data_type=list
),
"allowed_roles": SettingDefinition(
name="allowed_roles",
category=SettingCategory.PERMISSIONS.value,
default_value=[],
description="Roles allowed to use archiver (empty means all roles)",
data_type=list
),
"video_format": SettingDefinition(
name="video_format",
category=SettingCategory.VIDEO.value,
default_value=VideoFormat.MP4.value,
description="Format for archived videos",
data_type=str,
choices=[format.value for format in VideoFormat]
),
"video_quality": SettingDefinition(
name="video_quality",
category=SettingCategory.VIDEO.value,
default_value=VideoQuality.HIGH.value,
description="Quality preset for archived videos",
data_type=str,
choices=[quality.value for quality in VideoQuality]
),
"max_file_size": SettingDefinition(
name="max_file_size",
category=SettingCategory.VIDEO.value,
default_value=8,
description="Maximum file size in MB",
data_type=int,
min_value=1,
max_value=100
),
"message_duration": SettingDefinition(
name="message_duration",
category=SettingCategory.MESSAGES.value,
default_value=30,
description="Duration to show status messages (seconds)",
data_type=int,
min_value=5,
max_value=300
),
"message_template": SettingDefinition(
name="message_template",
category=SettingCategory.MESSAGES.value,
default_value="{author} archived a video from {channel}",
description="Template for archive messages",
data_type=str
),
"concurrent_downloads": SettingDefinition(
name="concurrent_downloads",
category=SettingCategory.PERFORMANCE.value,
default_value=2,
description="Maximum concurrent downloads",
data_type=int,
min_value=1,
max_value=5
),
"enabled_sites": SettingDefinition(
name="enabled_sites",
category=SettingCategory.FEATURES.value,
default_value=None,
description="Sites to enable archiving for (None means all sites)",
data_type=list,
required=False
),
"use_database": SettingDefinition(
name="use_database",
category=SettingCategory.FEATURES.value,
default_value=False,
description="Enable database tracking of archived videos",
data_type=bool
),
}
@classmethod
def get_setting_definition(cls, setting: str) -> Optional[SettingDefinition]:
"""Get definition for a setting"""
return cls.SETTINGS.get(setting)
@classmethod
def get_settings_by_category(cls, category: str) -> Dict[str, SettingDefinition]:
"""Get all settings in a category"""
return {
name: definition
for name, definition in cls.SETTINGS.items()
if definition.category == category
}
@classmethod
def validate_setting(cls, setting: str, value: Any) -> bool:
"""Validate a setting value"""
definition = cls.get_setting_definition(setting)
if not definition:
return False
# Check type
if not isinstance(value, definition.data_type):
return False
# Check required
if definition.required and value is None:
return False
# Check choices
if definition.choices and value not in definition.choices:
return False
# Check numeric bounds
if isinstance(value, (int, float)):
if definition.min_value is not None and value < definition.min_value:
return False
if definition.max_value is not None and value > definition.max_value:
return False
return True
@property
def default_guild_settings(self) -> Dict[str, Any]:
"""Default settings for guild configuration"""
return {
name: definition.default_value
for name, definition in self.SETTINGS.items()
}
@classmethod
def get_setting_help(cls, setting: str) -> Optional[str]:
"""Get help text for a setting"""
definition = cls.get_setting_definition(setting)
if not definition:
return None
help_text = [
f"Setting: {definition.name}",
f"Category: {definition.category}",
f"Description: {definition.description}",
f"Type: {definition.data_type.__name__}",
f"Required: {definition.required}",
f"Default: {definition.default_value}"
]
if definition.choices:
help_text.append(f"Choices: {', '.join(map(str, definition.choices))}")
if definition.min_value is not None:
help_text.append(f"Minimum: {definition.min_value}")
if definition.max_value is not None:
help_text.append(f"Maximum: {definition.max_value}")
if definition.depends_on:
help_text.append(f"Depends on: {definition.depends_on}")
return "\n".join(help_text)