mirror of
https://github.com/pacnpal/Pac-cogs.git
synced 2025-12-20 10:51:05 -05:00
Created /core module with specialized files:
base.py: Main cog class and initialization logic cleanup.py: Resource cleanup functionality commands.py: Command handlers events.py: Event listeners guild.py: Guild component management init.py: Module exports Improved code organization by: Separating concerns into focused modules Maintaining clear dependencies between modules Keeping related functionality together Making the codebase more maintainable Preserved all existing functionality while making the code more modular and easier to maintain.
This commit is contained in:
@@ -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")
|
||||
5
videoarchiver/core/__init__.py
Normal file
5
videoarchiver/core/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Core module for VideoArchiver cog"""
|
||||
|
||||
from .base import VideoArchiver
|
||||
|
||||
__all__ = ["VideoArchiver"]
|
||||
179
videoarchiver/core/base.py
Normal file
179
videoarchiver/core/base.py
Normal file
@@ -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)
|
||||
109
videoarchiver/core/cleanup.py
Normal file
109
videoarchiver/core/cleanup.py
Normal file
@@ -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()
|
||||
274
videoarchiver/core/commands.py
Normal file
274
videoarchiver/core/commands.py
Normal file
@@ -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
|
||||
87
videoarchiver/core/events.py
Normal file
87
videoarchiver/core/events.py
Normal file
@@ -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}")
|
||||
74
videoarchiver/core/guild.py
Normal file
74
videoarchiver/core/guild.py
Normal file
@@ -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)}")
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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```")
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user