mirror of
https://github.com/pacnpal/Pac-cogs.git
synced 2025-12-21 11:21:07 -05:00
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:
@@ -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")
|
||||
|
||||
261
videoarchiver/core/component_manager.py
Normal file
261
videoarchiver/core/component_manager.py
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
239
videoarchiver/core/lifecycle.py
Normal file
239
videoarchiver/core/lifecycle.py
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
228
videoarchiver/core/settings.py
Normal file
228
videoarchiver/core/settings.py
Normal 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)
|
||||
Reference in New Issue
Block a user