diff --git a/videoarchiver/commands.py b/videoarchiver/commands.py deleted file mode 100644 index ee1aea3..0000000 --- a/videoarchiver/commands.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Discord commands for VideoArchiver""" -import discord -from redbot.core import commands, app_commands, checks -from typing import Optional, Literal -import yt_dlp -from datetime import datetime - - -class VideoArchiverCommands(commands.Cog): - """Command handler for VideoArchiver""" - - def __init__(self, bot, config_manager=None, update_checker=None, processor=None): - self.bot = bot - self.config = config_manager - self.update_checker = update_checker - self.processor = processor - super().__init__() - - async def cog_before_invoke(self, ctx: commands.Context) -> None: - """This hook is called before every command.""" - # For hybrid commands, we'll handle the interaction response here - if isinstance(ctx.command, commands.HybridCommand) and ctx.interaction: - await ctx.defer() - - # Core Video Archiver Commands - @commands.hybrid_command(name="va_settings") - @app_commands.guild_only() - @commands.admin_or_permissions(administrator=True) - async def va_settings(self, ctx: commands.Context): - """Show current video archiver settings""" - embed = await self.config.format_settings_embed(ctx.guild) - await ctx.send(embed=embed) - - @commands.hybrid_command(name="va_update") - @app_commands.guild_only() - @checks.is_owner() - async def va_update(self, ctx: commands.Context): - """Update yt-dlp to the latest version""" - success, message = await self.update_checker.update_yt_dlp() - await ctx.send("✅ " + message if success else "❌ " + message) - - @commands.hybrid_command(name="va_toggleupdates") - @app_commands.guild_only() - @commands.admin_or_permissions(administrator=True) - async def va_toggleupdates(self, ctx: commands.Context): - """Toggle yt-dlp update notifications""" - state = await self.config.toggle_setting(ctx.guild.id, "disable_update_check") - status = "disabled" if state else "enabled" - await ctx.send(f"Update notifications {status}") - - # Role Management Commands - @commands.hybrid_command(name="var_add") - @app_commands.guild_only() - @app_commands.describe(role="The role to allow (leave empty for @everyone)") - async def var_add(self, ctx: commands.Context, role: Optional[discord.Role] = None): - """Add a role that's allowed to trigger archiving""" - if not role: - # If no role is specified, clear the list to allow everyone - await self.config.update_setting(ctx.guild.id, "allowed_roles", []) - await ctx.send("Allowed role set to @everyone (all users can trigger archiving)") - return - - await self.config.add_to_list(ctx.guild.id, "allowed_roles", role.id) - await ctx.send(f"Added {role.name} to allowed roles") - - @commands.hybrid_command(name="var_remove") - @app_commands.guild_only() - @app_commands.describe(role="The role to remove") - async def var_remove(self, ctx: commands.Context, role: discord.Role): - """Remove a role from allowed roles""" - await self.config.remove_from_list(ctx.guild.id, "allowed_roles", role.id) - await ctx.send(f"Removed {role.name} from allowed roles") - - @commands.hybrid_command(name="var_list") - @app_commands.guild_only() - async def var_list(self, ctx: commands.Context): - """List all roles allowed to trigger archiving""" - roles = await self.config.get_setting(ctx.guild.id, "allowed_roles") - if not roles: - await ctx.send("No roles are currently set (all users can trigger archiving)") - return - role_names = [ - r.name if r else "@everyone" - for r in [ctx.guild.get_role(role_id) for role_id in roles] - ] - await ctx.send(f"Allowed roles: {', '.join(role_names)}") - - @commands.hybrid_command(name="va_concurrent") - @app_commands.guild_only() - @app_commands.describe(count="Number of concurrent downloads (1-5)") - async def va_concurrent(self, ctx: commands.Context, count: app_commands.Range[int, 1, 5]): - """Set the number of concurrent downloads""" - await self.config.update_setting(ctx.guild.id, "concurrent_downloads", count) - await ctx.send(f"Concurrent downloads set to {count}") - - # Channel Configuration Commands - @commands.hybrid_command(name="vac_archive") - @app_commands.guild_only() - @app_commands.describe(channel="The archive channel") - async def vac_archive(self, ctx: commands.Context, channel: discord.TextChannel): - """Set the archive channel""" - await self.config.update_setting(ctx.guild.id, "archive_channel", channel.id) - await ctx.send(f"Archive channel set to {channel.mention}") - - @commands.hybrid_command(name="vac_notify") - @app_commands.guild_only() - @app_commands.describe(channel="The notification channel") - async def vac_notify(self, ctx: commands.Context, channel: discord.TextChannel): - """Set the notification channel (where archive messages appear)""" - await self.config.update_setting(ctx.guild.id, "notification_channel", channel.id) - await ctx.send(f"Notification channel set to {channel.mention}") - - @commands.hybrid_command(name="vac_log") - @app_commands.guild_only() - @app_commands.describe(channel="The log channel") - async def vac_log(self, ctx: commands.Context, channel: discord.TextChannel): - """Set the log channel for error messages and notifications""" - await self.config.update_setting(ctx.guild.id, "log_channel", channel.id) - await ctx.send(f"Log channel set to {channel.mention}") - - @commands.hybrid_command(name="vac_monitor") - @app_commands.guild_only() - @app_commands.describe(channel="The channel to monitor (leave empty to monitor all channels)") - async def vac_monitor(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None): - """Add a channel to monitor for videos""" - if not channel: - # If no channel is specified, clear the list to monitor all channels - await self.config.update_setting(ctx.guild.id, "monitored_channels", []) - await ctx.send("Now monitoring all channels for videos") - return - - await self.config.add_to_list(ctx.guild.id, "monitored_channels", channel.id) - await ctx.send(f"Now monitoring {channel.mention} for videos") - - @commands.hybrid_command(name="vac_unmonitor") - @app_commands.guild_only() - @app_commands.describe(channel="The channel to stop monitoring") - async def vac_unmonitor(self, ctx: commands.Context, channel: discord.TextChannel): - """Remove a channel from monitoring""" - await self.config.remove_from_list(ctx.guild.id, "monitored_channels", channel.id) - await ctx.send(f"Stopped monitoring {channel.mention}") - - # Video Format Commands - @commands.hybrid_command(name="va_format") - @app_commands.guild_only() - @app_commands.describe(format="The video format (e.g., mp4, webm)") - async def va_format(self, ctx: commands.Context, format: Literal["mp4", "webm"]): - """Set the video format""" - await self.config.update_setting(ctx.guild.id, "video_format", format.lower()) - await ctx.send(f"Video format set to {format.lower()}") - - @commands.hybrid_command(name="va_quality") - @app_commands.guild_only() - @app_commands.describe(quality="Maximum video quality in pixels (e.g., 1080)") - async def va_quality(self, ctx: commands.Context, quality: app_commands.Range[int, 144, 4320]): - """Set the maximum video quality""" - await self.config.update_setting(ctx.guild.id, "video_quality", quality) - await ctx.send(f"Maximum video quality set to {quality}p") - - @commands.hybrid_command(name="va_maxsize") - @app_commands.guild_only() - @app_commands.describe(size="Maximum file size in MB") - async def va_maxsize(self, ctx: commands.Context, size: app_commands.Range[int, 1, 100]): - """Set the maximum file size""" - await self.config.update_setting(ctx.guild.id, "max_file_size", size) - await ctx.send(f"Maximum file size set to {size}MB") - - @commands.hybrid_command(name="va_toggledelete") - @app_commands.guild_only() - async def va_toggledelete(self, ctx: commands.Context): - """Toggle whether to delete local files after reposting""" - state = await self.config.toggle_setting(ctx.guild.id, "delete_after_repost") - await ctx.send(f"Delete after repost: {state}") - - @commands.hybrid_command(name="va_duration") - @app_commands.guild_only() - @app_commands.describe(hours="Duration in hours (0 for permanent)") - async def va_duration(self, ctx: commands.Context, hours: app_commands.Range[int, 0, 720]): - """Set how long to keep archive messages""" - await self.config.update_setting(ctx.guild.id, "message_duration", hours) - await ctx.send(f"Archive message duration set to {hours} hours") - - @commands.hybrid_command(name="va_template") - @app_commands.guild_only() - @app_commands.describe(template="Message template using {author}, {url}, and {original_message}") - async def va_template(self, ctx: commands.Context, template: str): - """Set the archive message template""" - await self.config.update_setting(ctx.guild.id, "message_template", template) - await ctx.send(f"Archive message template set to:\n{template}") - - # Site Management Commands - @commands.hybrid_command(name="vas_enable") - @app_commands.guild_only() - @app_commands.describe(sites="Sites to enable (leave empty for all sites)") - async def vas_enable(self, ctx: commands.Context, *, sites: Optional[str] = None): - """Enable specific sites""" - if sites is None: - await self.config.update_setting(ctx.guild.id, "enabled_sites", []) - await ctx.send("All sites enabled") - return - - site_list = [s.strip().lower() for s in sites.split()] - - # Verify sites are valid - with yt_dlp.YoutubeDL() as ydl: - valid_sites = set(ie.IE_NAME.lower() for ie in ydl._ies if hasattr(ie, 'IE_NAME')) - invalid_sites = [s for s in site_list if s not in valid_sites] - if invalid_sites: - await ctx.send( - f"Invalid sites: {', '.join(invalid_sites)}\nValid sites: {', '.join(valid_sites)}" - ) - return - - await self.config.update_setting(ctx.guild.id, "enabled_sites", site_list) - await ctx.send(f"Enabled sites: {', '.join(site_list)}") - - @commands.hybrid_command(name="vas_list") - @app_commands.guild_only() - async def vas_list(self, ctx: commands.Context): - """List all available sites and currently enabled sites""" - enabled_sites = await self.config.get_setting(ctx.guild.id, "enabled_sites") - - embed = discord.Embed(title="Video Sites Configuration", color=discord.Color.blue()) - - with yt_dlp.YoutubeDL() as ydl: - # Filter out any extractors that don't have IE_NAME attribute - all_sites = sorted(ie.IE_NAME for ie in ydl._ies if hasattr(ie, 'IE_NAME') and ie.IE_NAME is not None) - - # Split sites into chunks for Discord's field value limit - chunk_size = 20 - site_chunks = [ - all_sites[i : i + chunk_size] for i in range(0, len(all_sites), chunk_size) - ] - - for i, chunk in enumerate(site_chunks, 1): - embed.add_field( - name=f"Available Sites ({i}/{len(site_chunks)})", - value=", ".join(chunk), - inline=False, - ) - - embed.add_field( - name="Currently Enabled", - value=", ".join(enabled_sites) if enabled_sites else "All sites", - inline=False, - ) - - await ctx.send(embed=embed) - - # Queue Management Commands - @commands.hybrid_command(name="vaq_status") - @app_commands.guild_only() - @commands.admin_or_permissions(administrator=True) - async def vaq_status(self, ctx: commands.Context): - """Show current queue status with basic metrics""" - status = self.processor.queue_manager.get_queue_status(ctx.guild.id) - - embed = discord.Embed( - title="Video Processing Queue Status", - color=discord.Color.blue(), - timestamp=datetime.utcnow(), - ) - - # Queue Status - embed.add_field( - name="Queue Status", - value=( - f"📥 Pending: {status['pending']}\n" - f"⚙️ Processing: {status['processing']}\n" - f"✅ Completed: {status['completed']}\n" - f"❌ Failed: {status['failed']}" - ), - inline=False, - ) - - # Basic Metrics - metrics = status["metrics"] - embed.add_field( - name="Basic Metrics", - value=( - f"Success Rate: {metrics['success_rate']:.1%}\n" - f"Avg Processing Time: {metrics['avg_processing_time']:.1f}s" - ), - inline=False, - ) - - embed.set_footer(text="Use /vaq_metrics for detailed performance metrics") - await ctx.send(embed=embed) - - @commands.hybrid_command(name="vaq_metrics") - @app_commands.guild_only() - @commands.admin_or_permissions(administrator=True) - async def vaq_metrics(self, ctx: commands.Context): - """Show detailed queue performance metrics""" - status = self.processor.queue_manager.get_queue_status(ctx.guild.id) - metrics = status["metrics"] - - embed = discord.Embed( - title="Queue Performance Metrics", - color=discord.Color.blue(), - timestamp=datetime.utcnow(), - ) - - # Processing Statistics - embed.add_field( - name="Processing Statistics", - value=( - f"Total Processed: {metrics['total_processed']}\n" - f"Total Failed: {metrics['total_failed']}\n" - f"Success Rate: {metrics['success_rate']:.1%}\n" - f"Avg Processing Time: {metrics['avg_processing_time']:.1f}s" - ), - inline=False, - ) - - # Resource Usage - embed.add_field( - name="Resource Usage", - value=( - f"Peak Memory Usage: {metrics['peak_memory_usage']:.1f}MB\n" - f"Last Cleanup: {metrics['last_cleanup']}" - ), - inline=False, - ) - - # Current Queue State - embed.add_field( - name="Current Queue State", - value=( - f"📥 Pending: {status['pending']}\n" - f"⚙️ Processing: {status['processing']}\n" - f"✅ Completed: {status['completed']}\n" - f"❌ Failed: {status['failed']}" - ), - inline=False, - ) - - embed.set_footer(text="Metrics are updated in real-time as videos are processed") - await ctx.send(embed=embed) - - @commands.hybrid_command(name="vaq_clear") - @app_commands.guild_only() - @commands.admin_or_permissions(administrator=True) - async def vaq_clear(self, ctx: commands.Context): - """Clear the video processing queue for this guild""" - cleared = await self.processor.queue_manager.clear_guild_queue(ctx.guild.id) - await ctx.send(f"Cleared {cleared} items from the queue") diff --git a/videoarchiver/core/__init__.py b/videoarchiver/core/__init__.py new file mode 100644 index 0000000..7cd0506 --- /dev/null +++ b/videoarchiver/core/__init__.py @@ -0,0 +1,5 @@ +"""Core module for VideoArchiver cog""" + +from .base import VideoArchiver + +__all__ = ["VideoArchiver"] diff --git a/videoarchiver/core/base.py b/videoarchiver/core/base.py new file mode 100644 index 0000000..4eab1fb --- /dev/null +++ b/videoarchiver/core/base.py @@ -0,0 +1,179 @@ +"""Base module containing core VideoArchiver class""" + +from __future__ import annotations + +import discord +from redbot.core import commands, Config, data_manager +import logging +import asyncio +from pathlib import Path +from typing import Dict, Any, Optional + +from ..config_manager import ConfigManager +from ..update_checker import UpdateChecker +from ..processor import VideoProcessor +from ..utils.video_downloader import VideoDownloader +from ..utils.message_manager import MessageManager +from ..utils.file_ops import cleanup_downloads +from ..queue import EnhancedVideoQueueManager +from ..ffmpeg.ffmpeg_manager import FFmpegManager +from ..database.video_archive_db import VideoArchiveDB +from ..utils.exceptions import VideoArchiverError as ProcessingError + +from .guild import initialize_guild_components +from .cleanup import cleanup_resources, force_cleanup_resources +from .commands import setup_commands +from .events import setup_events + +logger = logging.getLogger("VideoArchiver") + +# Constants for timeouts +UNLOAD_TIMEOUT = 30 # seconds +CLEANUP_TIMEOUT = 15 # seconds + +class VideoArchiver(commands.Cog): + """Archive videos from Discord channels""" + + default_guild_settings = { + "enabled": False, + "archive_channel": None, + "log_channel": None, + "enabled_channels": [], + "video_format": "mp4", + "video_quality": "high", + "max_file_size": 25, # 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: commands.Bot) -> None: + """Initialize the cog with proper error handling""" + self.bot = bot + self.ready = asyncio.Event() + self._init_task: Optional[asyncio.Task] = None + self._cleanup_task: Optional[asyncio.Task] = None + self._unloading = False + self.db = None + + # Start initialization + self._init_task = asyncio.create_task(self._initialize()) + self._init_task.add_done_callback(self._init_callback) + + # Set up commands and events + setup_commands(self) + setup_events(self) + + def _init_callback(self, task: asyncio.Task) -> None: + """Handle initialization task completion""" + try: + task.result() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Initialization failed: {str(e)}") + asyncio.create_task(self._cleanup()) + + async def _initialize(self) -> None: + """Initialize all components with proper error handling""" + try: + # Initialize config first as other components depend on it + config = Config.get_conf(self, identifier=855847, force_registration=True) + config.register_guild(**self.default_guild_settings) + self.config_manager = ConfigManager(config) + + # Set up paths + self.data_path = Path(data_manager.cog_data_path(self)) + self.download_path = self.data_path / "downloads" + self.download_path.mkdir(parents=True, exist_ok=True) + + # Clean existing downloads + await cleanup_downloads(str(self.download_path)) + + # Initialize shared FFmpeg manager + self.ffmpeg_mgr = FFmpegManager() + logger.info("Initialized shared FFmpeg manager") + + # Initialize components dict first + self.components: Dict[int, Dict[str, Any]] = {} + + # Initialize components for existing guilds + for guild in self.bot.guilds: + try: + await initialize_guild_components(self, guild.id) + except Exception as e: + logger.error(f"Failed to initialize guild {guild.id}: {str(e)}") + continue + + # Initialize queue manager after components are ready + queue_path = self.data_path / "queue_state.json" + queue_path.parent.mkdir(parents=True, exist_ok=True) + self.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), + ) + + # Initialize update checker + self.update_checker = UpdateChecker(self.bot, self.config_manager) + + # Initialize processor with queue manager and shared FFmpeg manager + self.processor = VideoProcessor( + self.bot, + self.config_manager, + self.components, + queue_manager=self.queue_manager, + ffmpeg_mgr=self.ffmpeg_mgr, + db=self.db, # Pass database to processor (None by default) + ) + + # Start update checker + await self.update_checker.start() + + # Set ready flag + self.ready.set() + + logger.info("VideoArchiver initialization completed successfully") + + except Exception as e: + logger.error(f"Critical error during initialization: {str(e)}") + await self._cleanup() + raise + + async def cog_load(self) -> None: + """Handle cog loading""" + try: + await asyncio.wait_for(self.ready.wait(), timeout=30) + except asyncio.TimeoutError: + await self._cleanup() + raise ProcessingError("Cog initialization timed out") + except Exception as e: + await self._cleanup() + raise ProcessingError(f"Error during cog load: {str(e)}") + + async def cog_unload(self) -> None: + """Clean up when cog is unloaded with timeout""" + self._unloading = True + try: + # Create cleanup task with timeout + cleanup_task = asyncio.create_task(self._cleanup()) + try: + await asyncio.wait_for(cleanup_task, timeout=UNLOAD_TIMEOUT) + except asyncio.TimeoutError: + logger.error("Cog unload timed out, forcing cleanup") + # Force cleanup of any remaining resources + await force_cleanup_resources(self) + except Exception as e: + logger.error(f"Error during cog unload: {str(e)}") + await force_cleanup_resources(self) + finally: + self._unloading = False + + async def _cleanup(self) -> None: + """Clean up all resources with proper handling""" + await cleanup_resources(self) diff --git a/videoarchiver/core/cleanup.py b/videoarchiver/core/cleanup.py new file mode 100644 index 0000000..088e1c0 --- /dev/null +++ b/videoarchiver/core/cleanup.py @@ -0,0 +1,109 @@ +"""Cleanup functionality for VideoArchiver""" + +import logging +import asyncio +from typing import TYPE_CHECKING + +from ..utils.file_ops import cleanup_downloads + +if TYPE_CHECKING: + from .base import VideoArchiver + +logger = logging.getLogger("VideoArchiver") + +CLEANUP_TIMEOUT = 15 # seconds + +async def cleanup_resources(cog: "VideoArchiver") -> None: + """Clean up all resources with proper handling""" + try: + # Cancel initialization if still running + if cog._init_task and not cog._init_task.done(): + cog._init_task.cancel() + try: + await asyncio.wait_for(cog._init_task, timeout=CLEANUP_TIMEOUT) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + + # Stop update checker + if hasattr(cog, "update_checker"): + try: + await asyncio.wait_for( + cog.update_checker.stop(), timeout=CLEANUP_TIMEOUT + ) + except asyncio.TimeoutError: + pass + + # Clean up processor + if hasattr(cog, "processor"): + try: + await asyncio.wait_for( + cog.processor.cleanup(), timeout=CLEANUP_TIMEOUT + ) + except asyncio.TimeoutError: + await cog.processor.force_cleanup() + + # Clean up queue manager + if hasattr(cog, "queue_manager"): + try: + await asyncio.wait_for( + cog.queue_manager.cleanup(), timeout=CLEANUP_TIMEOUT + ) + except asyncio.TimeoutError: + cog.queue_manager.force_stop() + + # Clean up components for each guild + if hasattr(cog, "components"): + for guild_id, components in cog.components.items(): + try: + if "message_manager" in components: + await components["message_manager"].cancel_all_deletions() + if "downloader" in components: + components["downloader"] = None + if "ffmpeg_mgr" in components: + components["ffmpeg_mgr"] = None + except Exception as e: + logger.error(f"Error cleaning up guild {guild_id}: {str(e)}") + + cog.components.clear() + + # Clean up download directory + if hasattr(cog, "download_path") and cog.download_path.exists(): + try: + await cleanup_downloads(str(cog.download_path)) + cog.download_path.rmdir() + except Exception as e: + logger.error(f"Error cleaning up download directory: {str(e)}") + + except Exception as e: + logger.error(f"Error during cleanup: {str(e)}") + raise + finally: + cog.ready.clear() + +async def force_cleanup_resources(cog: "VideoArchiver") -> None: + """Force cleanup of resources when timeout occurs""" + try: + # Cancel all tasks + if hasattr(cog, "processor"): + await cog.processor.force_cleanup() + + # Force stop queue manager + if hasattr(cog, "queue_manager"): + cog.queue_manager.force_stop() + + # Kill any remaining FFmpeg processes + if hasattr(cog, "ffmpeg_mgr"): + cog.ffmpeg_mgr.kill_all_processes() + + # Clean up download directory + if hasattr(cog, "download_path") and cog.download_path.exists(): + try: + await cleanup_downloads(str(cog.download_path)) + cog.download_path.rmdir() + except Exception as e: + logger.error(f"Error force cleaning download directory: {str(e)}") + + except Exception as e: + logger.error(f"Error during force cleanup: {str(e)}") + finally: + cog.ready.clear() diff --git a/videoarchiver/core/commands.py b/videoarchiver/core/commands.py new file mode 100644 index 0000000..4f6caa9 --- /dev/null +++ b/videoarchiver/core/commands.py @@ -0,0 +1,274 @@ +"""Command handlers for VideoArchiver""" + +import logging +import discord +import traceback +from redbot.core import commands, checks +from discord import app_commands +from typing import TYPE_CHECKING + +from ..utils.exceptions import ( + ConfigurationError as ConfigError, + VideoArchiverError as ProcessingError, +) +from ..database.video_archive_db import VideoArchiveDB + +if TYPE_CHECKING: + from .base import VideoArchiver + +logger = logging.getLogger("VideoArchiver") + +def setup_commands(cog: "VideoArchiver") -> None: + """Set up command handlers for the cog""" + + @cog.hybrid_group(name="archivedb", fallback="help") + @commands.guild_only() + async def archivedb(ctx: commands.Context): + """Manage the video archive database.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @archivedb.command(name="enable") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + async def enable_database(ctx: commands.Context): + """Enable the video archive database.""" + try: + current_setting = await cog.config_manager.get_setting( + ctx.guild.id, "use_database" + ) + if current_setting: + await ctx.send("The video archive database is already enabled.") + return + + # Initialize database if it's being enabled + cog.db = VideoArchiveDB(cog.data_path) + # Update processor with database + cog.processor.db = cog.db + cog.processor.queue_handler.db = cog.db + + await cog.config_manager.update_setting(ctx.guild.id, "use_database", True) + await ctx.send("Video archive database has been enabled.") + + except Exception as e: + logger.error(f"Error enabling database: {e}") + await ctx.send("An error occurred while enabling the database.") + + @archivedb.command(name="disable") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + async def disable_database(ctx: commands.Context): + """Disable the video archive database.""" + try: + current_setting = await cog.config_manager.get_setting( + ctx.guild.id, "use_database" + ) + if not current_setting: + await ctx.send("The video archive database is already disabled.") + return + + # Remove database references + cog.db = None + cog.processor.db = None + cog.processor.queue_handler.db = None + + await cog.config_manager.update_setting(ctx.guild.id, "use_database", False) + await ctx.send("Video archive database has been disabled.") + + except Exception as e: + logger.error(f"Error disabling database: {e}") + await ctx.send("An error occurred while disabling the database.") + + @cog.hybrid_command() + @commands.guild_only() + @app_commands.describe(url="The URL of the video to check") + async def checkarchived(ctx: commands.Context, url: str): + """Check if a video URL has been archived and get its Discord link if it exists.""" + try: + if not cog.db: + await ctx.send( + "The archive database is not enabled. Ask an admin to enable it with `/archivedb enable`" + ) + return + + result = cog.db.get_archived_video(url) + if result: + discord_url, message_id, channel_id, guild_id = result + embed = discord.Embed( + title="Video Found in Archive", + description=f"This video has been archived!\n\nOriginal URL: {url}", + color=discord.Color.green(), + ) + embed.add_field(name="Archived Link", value=discord_url) + await ctx.send(embed=embed) + else: + embed = discord.Embed( + title="Video Not Found", + description="This video has not been archived yet.", + color=discord.Color.red(), + ) + await ctx.send(embed=embed) + except Exception as e: + logger.error(f"Error checking archived video: {e}") + await ctx.send("An error occurred while checking the archive.") + + @cog.hybrid_group(name="archiver", fallback="help") + @commands.guild_only() + async def archiver(ctx: commands.Context): + """Manage video archiver settings.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @archiver.command(name="enable") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + async def enable_archiver(ctx: commands.Context): + """Enable video archiving in this server.""" + try: + current_setting = await cog.config_manager.get_setting( + ctx.guild.id, "enabled" + ) + if current_setting: + await ctx.send("Video archiving is already enabled.") + return + + await cog.config_manager.update_setting(ctx.guild.id, "enabled", True) + await ctx.send("Video archiving has been enabled.") + + except Exception as e: + logger.error(f"Error enabling archiver: {e}") + await ctx.send("An error occurred while enabling video archiving.") + + @archiver.command(name="disable") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + async def disable_archiver(ctx: commands.Context): + """Disable video archiving in this server.""" + try: + current_setting = await cog.config_manager.get_setting( + ctx.guild.id, "enabled" + ) + if not current_setting: + await ctx.send("Video archiving is already disabled.") + return + + await cog.config_manager.update_setting(ctx.guild.id, "enabled", False) + await ctx.send("Video archiving has been disabled.") + + except Exception as e: + logger.error(f"Error disabling archiver: {e}") + await ctx.send("An error occurred while disabling video archiving.") + + @archiver.command(name="setchannel") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + @app_commands.describe(channel="The channel where archived videos will be stored") + async def set_archive_channel(ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where archived videos will be stored.""" + try: + await cog.config_manager.update_setting( + ctx.guild.id, "archive_channel", channel.id + ) + await ctx.send(f"Archive channel has been set to {channel.mention}.") + except Exception as e: + logger.error(f"Error setting archive channel: {e}") + await ctx.send("An error occurred while setting the archive channel.") + + @archiver.command(name="setlog") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + @app_commands.describe(channel="The channel where log messages will be sent") + async def set_log_channel(ctx: commands.Context, channel: discord.TextChannel): + """Set the channel where log messages will be sent.""" + try: + await cog.config_manager.update_setting( + ctx.guild.id, "log_channel", channel.id + ) + await ctx.send(f"Log channel has been set to {channel.mention}.") + except Exception as e: + logger.error(f"Error setting log channel: {e}") + await ctx.send("An error occurred while setting the log channel.") + + @archiver.command(name="addchannel") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + @app_commands.describe(channel="The channel to monitor for videos") + async def add_enabled_channel(ctx: commands.Context, channel: discord.TextChannel): + """Add a channel to monitor for videos.""" + try: + enabled_channels = await cog.config_manager.get_setting( + ctx.guild.id, "enabled_channels" + ) + if channel.id in enabled_channels: + await ctx.send(f"{channel.mention} is already being monitored.") + return + + enabled_channels.append(channel.id) + await cog.config_manager.update_setting( + ctx.guild.id, "enabled_channels", enabled_channels + ) + await ctx.send(f"Now monitoring {channel.mention} for videos.") + except Exception as e: + logger.error(f"Error adding enabled channel: {e}") + await ctx.send("An error occurred while adding the channel.") + + @archiver.command(name="removechannel") + @commands.guild_only() + @checks.admin_or_permissions(administrator=True) + @app_commands.describe(channel="The channel to stop monitoring") + async def remove_enabled_channel(ctx: commands.Context, channel: discord.TextChannel): + """Remove a channel from video monitoring.""" + try: + enabled_channels = await cog.config_manager.get_setting( + ctx.guild.id, "enabled_channels" + ) + if channel.id not in enabled_channels: + await ctx.send(f"{channel.mention} is not being monitored.") + return + + enabled_channels.remove(channel.id) + await cog.config_manager.update_setting( + ctx.guild.id, "enabled_channels", enabled_channels + ) + await ctx.send(f"Stopped monitoring {channel.mention} for videos.") + except Exception as e: + logger.error(f"Error removing enabled channel: {e}") + await ctx.send("An error occurred while removing the channel.") + + # Error handling for commands + @cog.cog_command_error + async def cog_command_error(ctx: commands.Context, error: Exception) -> None: + """Handle command errors""" + error_msg = None + try: + if isinstance(error, commands.MissingPermissions): + error_msg = "❌ You don't have permission to use this command." + elif isinstance(error, commands.BotMissingPermissions): + error_msg = "❌ I don't have the required permissions to do that." + elif isinstance(error, commands.MissingRequiredArgument): + error_msg = f"❌ Missing required argument: {error.param.name}" + elif isinstance(error, commands.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." + ) + + if error_msg: + await ctx.send(error_msg) + + except Exception as e: + logger.error(f"Error handling command error: {str(e)}") + try: + await ctx.send( + "❌ An error occurred while handling another error. Please check the logs." + ) + except Exception: + pass # Give up if we can't even send error messages diff --git a/videoarchiver/core/events.py b/videoarchiver/core/events.py new file mode 100644 index 0000000..b6e3737 --- /dev/null +++ b/videoarchiver/core/events.py @@ -0,0 +1,87 @@ +"""Event handlers for VideoArchiver""" + +import logging +import discord +import traceback +from typing import TYPE_CHECKING + +from ..processor.reactions import REACTIONS, handle_archived_reaction +from .guild import initialize_guild_components, cleanup_guild_components + +if TYPE_CHECKING: + from .base import VideoArchiver + +logger = logging.getLogger("VideoArchiver") + +def setup_events(cog: "VideoArchiver") -> None: + """Set up event handlers for the cog""" + + @cog.bot.event + async def on_guild_join(guild: discord.Guild) -> None: + """Handle bot joining a new guild""" + if not cog.ready.is_set(): + return + + try: + await initialize_guild_components(cog, guild.id) + logger.info(f"Initialized components for new guild {guild.id}") + except Exception as e: + logger.error(f"Failed to initialize new guild {guild.id}: {str(e)}") + + @cog.bot.event + async def on_guild_remove(guild: discord.Guild) -> None: + """Handle bot leaving a guild""" + try: + await cleanup_guild_components(cog, guild.id) + except Exception as e: + logger.error(f"Error cleaning up removed guild {guild.id}: {str(e)}") + + @cog.bot.event + async def on_message(message: discord.Message) -> None: + """Handle new messages for video processing""" + if not cog.ready.is_set() or message.guild is None or message.author.bot: + return + + try: + await cog.processor.process_message(message) + except Exception as e: + logger.error( + f"Error processing message {message.id}: {traceback.format_exc()}" + ) + try: + log_channel = await 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}" + ) + except Exception as log_error: + logger.error(f"Failed to log error to guild: {str(log_error)}") + + @cog.bot.event + async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None: + """Handle reactions to messages""" + if payload.user_id == cog.bot.user.id: + return + + 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) + await handle_archived_reaction(message, user, cog.db) + + except Exception as e: + logger.error(f"Error handling reaction: {e}") diff --git a/videoarchiver/core/guild.py b/videoarchiver/core/guild.py new file mode 100644 index 0000000..dd1dc72 --- /dev/null +++ b/videoarchiver/core/guild.py @@ -0,0 +1,74 @@ +"""Guild component management for VideoArchiver""" + +import logging +from typing import TYPE_CHECKING + +from ..utils.video_downloader import VideoDownloader +from ..utils.message_manager import MessageManager +from ..utils.file_ops import cleanup_downloads +from ..utils.exceptions import VideoArchiverError as ProcessingError + +if TYPE_CHECKING: + from .base import VideoArchiver + +logger = logging.getLogger("VideoArchiver") + +async def initialize_guild_components(cog: "VideoArchiver", guild_id: int) -> None: + """Initialize or update components for a guild with error handling""" + try: + settings = await cog.config_manager.get_guild_settings(guild_id) + + # Ensure download directory exists and is clean + cog.download_path.mkdir(parents=True, exist_ok=True) + await cleanup_downloads(str(cog.download_path)) + + # Clean up old components if they exist + if guild_id in cog.components: + old_components = cog.components[guild_id] + if "message_manager" in old_components: + await old_components["message_manager"].cancel_all_deletions() + if "downloader" in old_components: + old_components["downloader"] = None + + # Initialize new components with validated settings + cog.components[guild_id] = { + "downloader": VideoDownloader( + str(cog.download_path), + settings["video_format"], + settings["video_quality"], + settings["max_file_size"], + settings["enabled_sites"] if settings["enabled_sites"] else None, + settings["concurrent_downloads"], + ffmpeg_mgr=cog.ffmpeg_mgr, # Use shared FFmpeg manager + ), + "message_manager": MessageManager( + settings["message_duration"], settings["message_template"] + ), + } + + logger.info(f"Successfully initialized components for guild {guild_id}") + + except Exception as e: + logger.error(f"Failed to initialize guild {guild_id}: {str(e)}") + raise ProcessingError(f"Guild initialization failed: {str(e)}") + +async def cleanup_guild_components(cog: "VideoArchiver", guild_id: int) -> None: + """Clean up components for a specific guild""" + try: + if guild_id in cog.components: + # Clean up components + components = cog.components[guild_id] + if "message_manager" in components: + await components["message_manager"].cancel_all_deletions() + if "downloader" in components: + components["downloader"] = None + if "ffmpeg_mgr" in components: + components["ffmpeg_mgr"] = None + + # Remove guild components + cog.components.pop(guild_id) + + logger.info(f"Cleaned up components for guild {guild_id}") + except Exception as e: + logger.error(f"Error cleaning up guild {guild_id}: {str(e)}") + raise ProcessingError(f"Guild cleanup failed: {str(e)}") diff --git a/videoarchiver/processor/message_handler.py b/videoarchiver/processor/message_handler.py index 4061d1d..6639c34 100644 --- a/videoarchiver/processor/message_handler.py +++ b/videoarchiver/processor/message_handler.py @@ -13,7 +13,7 @@ class MessageHandler: def __init__(self, bot, config_manager, queue_manager): self.bot = bot - self.config = config_manager + self.config_manager = config_manager self.queue_manager = queue_manager async def process_message(self, message: discord.Message) -> None: @@ -25,7 +25,7 @@ class MessageHandler: return # Get guild settings - settings = await self.config.get_guild_settings(message.guild.id) + settings = await self.config_manager.get_guild_settings(message.guild.id) if not settings: logger.warning(f"No settings found for guild {message.guild.id}") return diff --git a/videoarchiver/processor/queue_handler.py b/videoarchiver/processor/queue_handler.py index 10bfa8b..c9abe73 100644 --- a/videoarchiver/processor/queue_handler.py +++ b/videoarchiver/processor/queue_handler.py @@ -17,7 +17,7 @@ class QueueHandler: def __init__(self, bot, config_manager, components, db=None): self.bot = bot - self.config = config_manager + self.config_manager = config_manager self.components = components self.db = db self._unloading = False @@ -105,7 +105,7 @@ class QueueHandler: if not guild: return False, f"Guild {guild_id} not found" - archive_channel = await self.config.get_channel(guild, "archive") + archive_channel = await self.config_manager.get_channel(guild, "archive") if not archive_channel: return False, "Archive channel not configured" diff --git a/videoarchiver/update_checker.py b/videoarchiver/update_checker.py index 7527bf4..50baf02 100644 --- a/videoarchiver/update_checker.py +++ b/videoarchiver/update_checker.py @@ -31,7 +31,7 @@ class UpdateChecker: def __init__(self, bot, config_manager): self.bot = bot - self.config = config_manager + self.config_manager = config_manager self._check_task = None self._session: Optional[aiohttp.ClientSession] = None self._rate_limit_reset = 0 @@ -73,20 +73,16 @@ class UpdateChecker: while True: try: - all_guilds = await self.config.config.all_guilds() - current_time = datetime.utcnow() - - for guild_id, settings in all_guilds.items(): + for guild in self.bot.guilds: try: + settings = await self.config_manager.get_guild_settings(guild.id) if settings.get('disable_update_check', False): continue - guild = self.bot.get_guild(guild_id) - if not guild: - continue + current_time = datetime.utcnow() # Check if we've checked recently - last_check = self._last_version_check.get(guild_id) + last_check = self._last_version_check.get(guild.id) if last_check and (current_time - last_check).total_seconds() < self.UPDATE_CHECK_INTERVAL: continue @@ -99,10 +95,10 @@ class UpdateChecker: self._rate_limit_reset = 0 await self._check_guild(guild, settings) - self._last_version_check[guild_id] = current_time + self._last_version_check[guild.id] = current_time except Exception as e: - logger.error(f"Error checking updates for guild {guild_id}: {str(e)}") + logger.error(f"Error checking updates for guild {guild.id}: {str(e)}") continue except Exception as e: @@ -127,7 +123,9 @@ class UpdateChecker: return # Error already logged in _get_latest_version # Update last check time - await self.config.config.guild(guild).last_update_check.set( + await self.config_manager.update_setting( + guild.id, + "last_update_check", datetime.utcnow().isoformat() ) @@ -228,7 +226,7 @@ class UpdateChecker: timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") error_message = f"[{timestamp}] Error {context}: {str(error)}" - log_channel = await self.config.get_channel(guild, "log") + log_channel = await self.config_manager.get_channel(guild, "log") if log_channel: try: await log_channel.send(f"```\n{error_message}\n```") diff --git a/videoarchiver/video_archiver.py b/videoarchiver/video_archiver.py index 8f48a65..3622b51 100644 --- a/videoarchiver/video_archiver.py +++ b/videoarchiver/video_archiver.py @@ -1,659 +1,7 @@ """VideoArchiver cog for Red-DiscordBot""" -from __future__ import annotations +from .core import VideoArchiver -import discord -from redbot.core import commands, Config, data_manager, checks -from discord import app_commands -from pathlib import Path -import logging -import asyncio -from typing import Dict, Any, Optional -from datetime import datetime -import sys -import traceback - -from videoarchiver.config_manager import ConfigManager -from videoarchiver.update_checker import UpdateChecker -from videoarchiver.processor import VideoProcessor -from videoarchiver.utils.video_downloader import VideoDownloader -from videoarchiver.utils.message_manager import MessageManager -from videoarchiver.utils.file_ops import cleanup_downloads -from videoarchiver.queue import EnhancedVideoQueueManager -from videoarchiver.ffmpeg.ffmpeg_manager import FFmpegManager -from videoarchiver.database.video_archive_db import VideoArchiveDB -from videoarchiver.processor.reactions import REACTIONS, handle_archived_reaction -from videoarchiver.utils.exceptions import ( - VideoArchiverError as ProcessingError, - ConfigurationError as ConfigError, - VideoVerificationError as UpdateError, - QueueError, - FileCleanupError as FileOperationError, -) - -logger = logging.getLogger("VideoArchiver") - -# Constants for timeouts -UNLOAD_TIMEOUT = 30 # seconds -CLEANUP_TIMEOUT = 15 # seconds - - -class VideoArchiver(commands.Cog): - """Archive videos from Discord channels""" - - default_guild_settings = { - "enabled": False, - "archive_channel": None, - "log_channel": None, - "enabled_channels": [], - "video_format": "mp4", - "video_quality": "high", - "max_file_size": 25, # 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: commands.Bot) -> None: - """Initialize the cog with proper error handling""" - self.bot = bot - self.ready = asyncio.Event() - self._init_task: Optional[asyncio.Task] = None - self._cleanup_task: Optional[asyncio.Task] = None - self._unloading = False - self.db = None - - # Start initialization - self._init_task = asyncio.create_task(self._initialize()) - self._init_task.add_done_callback(self._init_callback) - - def _init_callback(self, task: asyncio.Task) -> None: - """Handle initialization task completion""" - try: - task.result() - except asyncio.CancelledError: - pass - except Exception as e: - logger.error(f"Initialization failed: {str(e)}") - asyncio.create_task(self._cleanup()) - - async def _initialize(self) -> None: - """Initialize all components with proper error handling""" - try: - # Initialize config first as other components depend on it - config = Config.get_conf(self, identifier=855847, force_registration=True) - config.register_guild(**self.default_guild_settings) - self.config_manager = ConfigManager(config) - - # Set up paths - self.data_path = Path(data_manager.cog_data_path(self)) - self.download_path = self.data_path / "downloads" - self.download_path.mkdir(parents=True, exist_ok=True) - - # Clean existing downloads - await cleanup_downloads(str(self.download_path)) - - # Initialize shared FFmpeg manager - self.ffmpeg_mgr = FFmpegManager() - logger.info("Initialized shared FFmpeg manager") - - # Initialize components dict first - self.components: Dict[int, Dict[str, Any]] = {} - - # Initialize components for existing guilds - for guild in self.bot.guilds: - try: - await self.initialize_guild_components(guild.id) - except Exception as e: - logger.error(f"Failed to initialize guild {guild.id}: {str(e)}") - continue - - # Initialize queue manager after components are ready - queue_path = self.data_path / "queue_state.json" - queue_path.parent.mkdir(parents=True, exist_ok=True) - self.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), - ) - - # Initialize update checker - self.update_checker = UpdateChecker(self.bot, self.config_manager) - - # Initialize processor with queue manager and shared FFmpeg manager - self.processor = VideoProcessor( - self.bot, - self.config_manager, - self.components, - queue_manager=self.queue_manager, - ffmpeg_mgr=self.ffmpeg_mgr, - db=self.db, # Pass database to processor (None by default) - ) - - # Start update checker - await self.update_checker.start() - - # Set ready flag - self.ready.set() - - logger.info("VideoArchiver initialization completed successfully") - - except Exception as e: - logger.error( - f"Critical error during initialization: {traceback.format_exc()}" - ) - await self._cleanup() - raise - - @commands.hybrid_group(name="archivedb", fallback="help") - @commands.guild_only() - async def archivedb(self, ctx: commands.Context): - """Manage the video archive database.""" - if ctx.invoked_subcommand is None: - await ctx.send_help(ctx.command) - - @archivedb.command(name="enable") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - async def enable_database(self, ctx: commands.Context): - """Enable the video archive database.""" - try: - current_setting = await self.config_manager.get_guild_setting( - ctx.guild, "use_database" - ) - if current_setting: - await ctx.send("The video archive database is already enabled.") - return - - # Initialize database if it's being enabled - self.db = VideoArchiveDB(self.data_path) - # Update processor with database - self.processor.db = self.db - self.processor.queue_handler.db = self.db - - await self.config_manager.set_guild_setting(ctx.guild, "use_database", True) - await ctx.send("Video archive database has been enabled.") - - except Exception as e: - logger.error(f"Error enabling database: {e}") - await ctx.send("An error occurred while enabling the database.") - - @archivedb.command(name="disable") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - async def disable_database(self, ctx: commands.Context): - """Disable the video archive database.""" - try: - current_setting = await self.config_manager.get_guild_setting( - ctx.guild, "use_database" - ) - if not current_setting: - await ctx.send("The video archive database is already disabled.") - return - - # Remove database references - self.db = None - self.processor.db = None - self.processor.queue_handler.db = None - - await self.config_manager.set_guild_setting( - ctx.guild, "use_database", False - ) - await ctx.send("Video archive database has been disabled.") - - except Exception as e: - logger.error(f"Error disabling database: {e}") - await ctx.send("An error occurred while disabling the database.") - - @commands.hybrid_command() - @commands.guild_only() - @app_commands.describe(url="The URL of the video to check") - async def checkarchived(self, ctx: commands.Context, url: str): - """Check if a video URL has been archived and get its Discord link if it exists.""" - try: - if not self.db: - await ctx.send( - "The archive database is not enabled. Ask an admin to enable it with `/archivedb enable`" - ) - return - - result = self.db.get_archived_video(url) - if result: - discord_url, message_id, channel_id, guild_id = result - embed = discord.Embed( - title="Video Found in Archive", - description=f"This video has been archived!\n\nOriginal URL: {url}", - color=discord.Color.green(), - ) - embed.add_field(name="Archived Link", value=discord_url) - await ctx.send(embed=embed) - else: - embed = discord.Embed( - title="Video Not Found", - description="This video has not been archived yet.", - color=discord.Color.red(), - ) - await ctx.send(embed=embed) - except Exception as e: - logger.error(f"Error checking archived video: {e}") - await ctx.send("An error occurred while checking the archive.") - - @commands.hybrid_group(name="archiver", fallback="help") - @commands.guild_only() - async def archiver(self, ctx: commands.Context): - """Manage video archiver settings.""" - if ctx.invoked_subcommand is None: - await ctx.send_help(ctx.command) - - @archiver.command(name="enable") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - async def enable_archiver(self, ctx: commands.Context): - """Enable video archiving in this server.""" - try: - current_setting = await self.config_manager.get_guild_setting( - ctx.guild, "enabled" - ) - if current_setting: - await ctx.send("Video archiving is already enabled.") - return - - await self.config_manager.set_guild_setting(ctx.guild, "enabled", True) - await ctx.send("Video archiving has been enabled.") - - except Exception as e: - logger.error(f"Error enabling archiver: {e}") - await ctx.send("An error occurred while enabling video archiving.") - - @archiver.command(name="disable") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - async def disable_archiver(self, ctx: commands.Context): - """Disable video archiving in this server.""" - try: - current_setting = await self.config_manager.get_guild_setting( - ctx.guild, "enabled" - ) - if not current_setting: - await ctx.send("Video archiving is already disabled.") - return - - await self.config_manager.set_guild_setting(ctx.guild, "enabled", False) - await ctx.send("Video archiving has been disabled.") - - except Exception as e: - logger.error(f"Error disabling archiver: {e}") - await ctx.send("An error occurred while disabling video archiving.") - - @archiver.command(name="setchannel") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - @app_commands.describe(channel="The channel where archived videos will be stored") - async def set_archive_channel( - self, ctx: commands.Context, channel: discord.TextChannel - ): - """Set the channel where archived videos will be stored.""" - try: - await self.config_manager.set_guild_setting( - ctx.guild, "archive_channel", channel.id - ) - await ctx.send(f"Archive channel has been set to {channel.mention}.") - except Exception as e: - logger.error(f"Error setting archive channel: {e}") - await ctx.send("An error occurred while setting the archive channel.") - - @archiver.command(name="setlog") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - @app_commands.describe(channel="The channel where log messages will be sent") - async def set_log_channel( - self, ctx: commands.Context, channel: discord.TextChannel - ): - """Set the channel where log messages will be sent.""" - try: - await self.config_manager.set_guild_setting( - ctx.guild, "log_channel", channel.id - ) - await ctx.send(f"Log channel has been set to {channel.mention}.") - except Exception as e: - logger.error(f"Error setting log channel: {e}") - await ctx.send("An error occurred while setting the log channel.") - - @archiver.command(name="addchannel") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - @app_commands.describe(channel="The channel to monitor for videos") - async def add_enabled_channel( - self, ctx: commands.Context, channel: discord.TextChannel - ): - """Add a channel to monitor for videos.""" - try: - enabled_channels = await self.config_manager.get_guild_setting( - ctx.guild, "enabled_channels" - ) - if channel.id in enabled_channels: - await ctx.send(f"{channel.mention} is already being monitored.") - return - - enabled_channels.append(channel.id) - await self.config_manager.set_guild_setting( - ctx.guild, "enabled_channels", enabled_channels - ) - await ctx.send(f"Now monitoring {channel.mention} for videos.") - except Exception as e: - logger.error(f"Error adding enabled channel: {e}") - await ctx.send("An error occurred while adding the channel.") - - @archiver.command(name="removechannel") - @commands.guild_only() - @checks.admin_or_permissions(administrator=True) - @app_commands.describe(channel="The channel to stop monitoring") - async def remove_enabled_channel( - self, ctx: commands.Context, channel: discord.TextChannel - ): - """Remove a channel from video monitoring.""" - try: - enabled_channels = await self.config_manager.get_guild_setting( - ctx.guild, "enabled_channels" - ) - if channel.id not in enabled_channels: - await ctx.send(f"{channel.mention} is not being monitored.") - return - - enabled_channels.remove(channel.id) - await self.config_manager.set_guild_setting( - ctx.guild, "enabled_channels", enabled_channels - ) - await ctx.send(f"Stopped monitoring {channel.mention} for videos.") - except Exception as e: - logger.error(f"Error removing enabled channel: {e}") - await ctx.send("An error occurred while removing the channel.") - - @commands.Cog.listener() - async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): - """Handle reactions to messages""" - if payload.user_id == self.bot.user.id: - return - - try: - # Get the channel and message - channel = self.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.db: - user = self.bot.get_user(payload.user_id) - await handle_archived_reaction(message, user, self.db) - - except Exception as e: - logger.error(f"Error handling reaction: {e}") - - async def cog_load(self) -> None: - """Handle cog loading""" - try: - await asyncio.wait_for(self.ready.wait(), timeout=30) - except asyncio.TimeoutError: - await self._cleanup() - raise ProcessingError("Cog initialization timed out") - except Exception as e: - await self._cleanup() - raise ProcessingError(f"Error during cog load: {str(e)}") - - async def cog_unload(self) -> None: - """Clean up when cog is unloaded with timeout""" - self._unloading = True - try: - # Create cleanup task with timeout - cleanup_task = asyncio.create_task(self._cleanup()) - try: - await asyncio.wait_for(cleanup_task, timeout=UNLOAD_TIMEOUT) - except asyncio.TimeoutError: - logger.error("Cog unload timed out, forcing cleanup") - # Force cleanup of any remaining resources - await self._force_cleanup() - except Exception as e: - logger.error(f"Error during cog unload: {str(e)}") - await self._force_cleanup() - finally: - self._unloading = False - - async def _force_cleanup(self) -> None: - """Force cleanup of resources when timeout occurs""" - try: - # Cancel all tasks - if hasattr(self, "processor"): - await self.processor.force_cleanup() - - # Force stop queue manager - if hasattr(self, "queue_manager"): - self.queue_manager.force_stop() - - # Kill any remaining FFmpeg processes - if hasattr(self, "ffmpeg_mgr"): - self.ffmpeg_mgr.kill_all_processes() - - # Clean up download directory - if hasattr(self, "download_path") and self.download_path.exists(): - try: - await cleanup_downloads(str(self.download_path)) - self.download_path.rmdir() - except Exception as e: - logger.error(f"Error force cleaning download directory: {str(e)}") - - except Exception as e: - logger.error(f"Error during force cleanup: {str(e)}") - finally: - self.ready.clear() - - async def _cleanup(self) -> None: - """Clean up all resources with proper handling""" - try: - # Cancel initialization if still running - if self._init_task and not self._init_task.done(): - self._init_task.cancel() - try: - await asyncio.wait_for(self._init_task, timeout=CLEANUP_TIMEOUT) - except (asyncio.TimeoutError, asyncio.CancelledError): - pass - - # Stop update checker - if hasattr(self, "update_checker"): - try: - await asyncio.wait_for( - self.update_checker.stop(), timeout=CLEANUP_TIMEOUT - ) - except asyncio.TimeoutError: - pass - - # Clean up processor - if hasattr(self, "processor"): - try: - await asyncio.wait_for( - self.processor.cleanup(), timeout=CLEANUP_TIMEOUT - ) - except asyncio.TimeoutError: - await self.processor.force_cleanup() - - # Clean up queue manager - if hasattr(self, "queue_manager"): - try: - await asyncio.wait_for( - self.queue_manager.cleanup(), timeout=CLEANUP_TIMEOUT - ) - except asyncio.TimeoutError: - self.queue_manager.force_stop() - - # Clean up components for each guild - if hasattr(self, "components"): - for guild_id, components in self.components.items(): - try: - if "message_manager" in components: - await components["message_manager"].cancel_all_deletions() - if "downloader" in components: - components["downloader"] = None - if "ffmpeg_mgr" in components: - components["ffmpeg_mgr"] = None - except Exception as e: - logger.error(f"Error cleaning up guild {guild_id}: {str(e)}") - - self.components.clear() - - # Clean up download directory - if hasattr(self, "download_path") and self.download_path.exists(): - try: - await cleanup_downloads(str(self.download_path)) - self.download_path.rmdir() - except Exception as e: - logger.error(f"Error cleaning up download directory: {str(e)}") - - except Exception as e: - logger.error(f"Error during cleanup: {traceback.format_exc()}") - raise ProcessingError(f"Cleanup failed: {str(e)}") - finally: - self.ready.clear() - - async def initialize_guild_components(self, guild_id: int) -> None: - """Initialize or update components for a guild with error handling""" - try: - settings = await self.config_manager.get_guild_settings(guild_id) - - # Ensure download directory exists and is clean - self.download_path.mkdir(parents=True, exist_ok=True) - await cleanup_downloads(str(self.download_path)) - - # Clean up old components if they exist - if guild_id in self.components: - old_components = self.components[guild_id] - if "message_manager" in old_components: - await old_components["message_manager"].cancel_all_deletions() - if "downloader" in old_components: - old_components["downloader"] = None - - # Initialize new components with validated settings - self.components[guild_id] = { - "downloader": VideoDownloader( - str(self.download_path), - settings["video_format"], - settings["video_quality"], - settings["max_file_size"], - settings["enabled_sites"] if settings["enabled_sites"] else None, - settings["concurrent_downloads"], - ffmpeg_mgr=self.ffmpeg_mgr, # Use shared FFmpeg manager - ), - "message_manager": MessageManager( - settings["message_duration"], settings["message_template"] - ), - } - - logger.info(f"Successfully initialized components for guild {guild_id}") - - except Exception as e: - logger.error( - f"Failed to initialize guild {guild_id}: {traceback.format_exc()}" - ) - raise ProcessingError(f"Guild initialization failed: {str(e)}") - - @commands.Cog.listener() - async def on_guild_join(self, guild: discord.Guild) -> None: - """Handle bot joining a new guild""" - if not self.ready.is_set(): - return - - try: - await self.initialize_guild_components(guild.id) - logger.info(f"Initialized components for new guild {guild.id}") - except Exception as e: - logger.error(f"Failed to initialize new guild {guild.id}: {str(e)}") - - @commands.Cog.listener() - async def on_guild_remove(self, guild: discord.Guild) -> None: - """Handle bot leaving a guild""" - try: - if guild.id in self.components: - # Clean up components - components = self.components[guild.id] - if "message_manager" in components: - await components["message_manager"].cancel_all_deletions() - if "downloader" in components: - components["downloader"] = None - if "ffmpeg_mgr" in components: - components["ffmpeg_mgr"] = None - - # Remove guild components - self.components.pop(guild.id) - - logger.info(f"Cleaned up components for removed guild {guild.id}") - except Exception as e: - logger.error(f"Error cleaning up removed guild {guild.id}: {str(e)}") - - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Handle new messages for video processing""" - if not self.ready.is_set() or message.guild is None or message.author.bot: - return - - try: - await self.processor.process_message(message) - except Exception as e: - logger.error( - f"Error processing message {message.id}: {traceback.format_exc()}" - ) - try: - log_channel = await self.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}" - ) - except Exception as log_error: - logger.error(f"Failed to log error to guild: {str(log_error)}") - - async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: - """Handle command errors""" - error_msg = None - try: - if isinstance(error, commands.MissingPermissions): - error_msg = "❌ You don't have permission to use this command." - elif isinstance(error, commands.BotMissingPermissions): - error_msg = "❌ I don't have the required permissions to do that." - elif isinstance(error, commands.MissingRequiredArgument): - error_msg = f"❌ Missing required argument: {error.param.name}" - elif isinstance(error, commands.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." - ) - - if error_msg: - await ctx.send(error_msg) - - except Exception as e: - logger.error(f"Error handling command error: {str(e)}") - try: - await ctx.send( - "❌ An error occurred while handling another error. Please check the logs." - ) - except Exception: - pass # Give up if we can't even send error messages +def setup(bot): + """Load VideoArchiver cog.""" + bot.add_cog(VideoArchiver(bot))