From 6c2e277211e2bfa327a85f0826bca0b3d0837199 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Thu, 14 Nov 2024 19:51:43 +0000 Subject: [PATCH] Add yt-dlp update functionality to VideoArchiver cog - Add automatic yt-dlp version checking - Add updateytdlp command for bot owners to update yt-dlp - Add toggleupdates command to disable update notifications per guild - Add disable_update_check setting to guild config - Update README with new features and commands - Improve update-related documentation --- videoarchiver/README.md | 187 ++++++++++ videoarchiver/video_archiver.py | 603 ++++++++++++++++++++++++++++++++ 2 files changed, 790 insertions(+) create mode 100644 videoarchiver/README.md create mode 100644 videoarchiver/video_archiver.py diff --git a/videoarchiver/README.md b/videoarchiver/README.md new file mode 100644 index 0000000..cd681ff --- /dev/null +++ b/videoarchiver/README.md @@ -0,0 +1,187 @@ +# VideoArchiver Cog for Red-DiscordBot + +A powerful video archiving cog that automatically downloads and reposts videos from monitored channels, with support for GPU-accelerated compression, multi-video processing, and role-based permissions. + +## Features + +- **Hardware-Accelerated Video Processing**: + - NVIDIA GPU support using NVENC + - AMD GPU support using AMF + - Intel GPU support using QuickSync + - ARM64/aarch64 support with V4L2 M2M encoder + - Multi-core CPU optimization +- **Smart Video Processing**: + - Intelligent quality preservation + - Only compresses when needed + - Concurrent video processing + - Default 8MB file size limit +- **Role-Based Access**: + - Restrict archiving to specific roles + - Default allows all users + - Per-guild role configuration +- **Wide Platform Support**: + - Support for multiple video platforms via [yt-dlp](https://github.com/yt-dlp/yt-dlp) + - Configurable site whitelist + - Automatic quality selection +- **Automatic Updates**: + - Automatic yt-dlp update checking + - Bot owner notifications for new versions + - Easy update command + - Configurable update notifications + +## Installation + +To install this cog, follow these steps: + +1. Ensure you have Red-DiscordBot V3 installed. +2. Add the repository to your bot: + + ``` + [p]repo add Pac-cogs https://github.com/pacnpal/Pac-cogs + ``` + +3. Install the VideoArchiver cog: + + ``` + [p]cog install Pac-cogs videoarchiver + ``` + +4. Load the cog: + + ``` + [p]load videoarchiver + ``` + +Replace `[p]` with your bot's prefix. + +The required dependencies (yt-dlp, ffmpeg-python, requests) will be installed automatically. You will also need FFmpeg installed on your system - the cog will attempt to download and manage FFmpeg automatically if it's not found. + +### Important: Keeping yt-dlp Updated + +The cog relies on [yt-dlp](https://github.com/yt-dlp/yt-dlp) for video downloading. Video platforms frequently update their sites, which may break video downloading if yt-dlp is outdated. The cog will automatically check for updates and notify the bot owner when a new version is available. + +To update yt-dlp: +```bash +[p]videoarchiver updateytdlp +``` + +You can also disable update notifications per guild: +```bash +[p]videoarchiver toggleupdates +``` + +## Configuration + +The cog supports both slash commands and traditional prefix commands. Use whichever style you prefer. + +### Channel Setup +``` +/videoarchiver setchannel #archive-channel # Set archive channel +/videoarchiver setnotification #notify-channel # Set notification channel +/videoarchiver setlogchannel #log-channel # Set log channel for errors/notifications +/videoarchiver addmonitor #videos-channel # Add channel to monitor +/videoarchiver removemonitor #channel # Remove monitored channel + +# Legacy commands also supported: +[p]videoarchiver setchannel #channel +[p]videoarchiver setnotification #channel +etc. +``` + +### Role Management +``` +/videoarchiver addrole @role # Add role that can trigger archiving +/videoarchiver removerole @role # Remove role from allowed list +/videoarchiver listroles # List all allowed roles (empty = all allowed) +``` + +### Video Settings +``` +/videoarchiver setformat mp4 # Set video format +/videoarchiver setquality 1080 # Set max quality (pixels) +/videoarchiver setmaxsize 8 # Set max size (MB, default 8MB) +/videoarchiver toggledelete # Toggle file cleanup +``` + +### Message Settings +``` +/videoarchiver setduration 24 # Set message duration (hours) +/videoarchiver settemplate "Archived video from {author}\nOriginal: {original_message}" +/videoarchiver enablesites # Configure allowed sites +``` + +### Update Settings +``` +/videoarchiver updateytdlp # Update yt-dlp to latest version +/videoarchiver toggleupdates # Toggle update notifications +``` + +## Architecture Support + +The cog supports multiple architectures: +- x86_64/amd64 +- ARM64/aarch64 +- ARMv7 (32-bit) +- Apple Silicon (M1/M2) + +Hardware acceleration is automatically configured based on your system: +- x86_64: Full GPU support (NVIDIA, AMD, Intel) +- ARM64: V4L2 M2M hardware encoding when available +- All platforms: Multi-core CPU optimization + +## Troubleshooting + +1. **Permission Issues**: + - Bot needs "Manage Messages" permission + - Bot needs "Attach Files" permission + - Bot needs "Read Message History" permission + - Bot needs "Use Application Commands" for slash commands + +2. **Video Processing Issues**: + - Ensure FFmpeg is properly installed + - Check GPU drivers are up to date + - Verify file permissions in the downloads directory + - Update yt-dlp if videos fail to download + +3. **Role Issues**: + - Verify role hierarchy (bot's role must be higher than managed roles) + - Check if roles are properly configured + +4. **Performance Issues**: + - Check available disk space + - Monitor system resource usage + +## Support + +For support: +1. First, check the [Troubleshooting](#troubleshooting) section above +2. Update yt-dlp to the latest version: + ```bash + [p]videoarchiver updateytdlp + ``` +3. If the issue persists after updating yt-dlp: + - Join the Red-DiscordBot server and ask in the #support channel + - Open an issue on GitHub with: + - Your Red-Bot version + - The output of `[p]pipinstall list` + - Steps to reproduce the issue + - Any error messages + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +Before submitting an issue: +1. Update yt-dlp to the latest version first: + ```bash + [p]videoarchiver updateytdlp + ``` +2. If the issue persists after updating yt-dlp, please include: + - Your Red-Bot version + - The output of `[p]pipinstall list` + - Steps to reproduce the issue + - Any error messages + +## License + +This cog is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details. diff --git a/videoarchiver/video_archiver.py b/videoarchiver/video_archiver.py new file mode 100644 index 0000000..af3128e --- /dev/null +++ b/videoarchiver/video_archiver.py @@ -0,0 +1,603 @@ +import os +import re +import discord +from redbot.core import commands, Config, data_manager, checks +from redbot.core.bot import Red +from redbot.core.utils.chat_formatting import box +from discord import app_commands +import logging +from pathlib import Path +import yt_dlp +import shutil +import asyncio +import subprocess +from typing import Optional, List, Set, Dict +import sys +import pkg_resources +import requests + +# Import local utils +from .utils import VideoDownloader, secure_delete_file, cleanup_downloads, MessageManager +from .ffmpeg_manager import FFmpegManager + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('VideoArchiver') + +class VideoArchiver(commands.Cog): + """Archive videos from Discord channels""" + + default_guild = { + "archive_channel": None, + "notification_channel": None, + "log_channel": None, + "monitored_channels": [], + "allowed_roles": [], + "video_format": "mp4", + "video_quality": 1080, + "max_file_size": 8, + "delete_after_repost": True, + "message_duration": 24, + "message_template": "Archived video from {author}\nOriginal: {original_message}", + "enabled_sites": [], + "concurrent_downloads": 3, + "disable_update_check": False + } + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=855847, force_registration=True) + self.config.register_guild(**self.default_guild) + + # Initialize components dict for each guild + self.components = {} + + # Set up download path in Red's data directory + 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 up downloads on load + cleanup_downloads(str(self.download_path)) + + # Initialize FFmpeg manager + self.ffmpeg_mgr = FFmpegManager() + + # Start update check task + self.update_check_task = self.bot.loop.create_task(self.check_for_updates()) + + def cog_unload(self): + """Cleanup when cog is unloaded""" + if self.download_path.exists(): + shutil.rmtree(self.download_path, ignore_errors=True) + if self.update_check_task: + self.update_check_task.cancel() + + async def check_for_updates(self): + """Check for yt-dlp updates periodically""" + await self.bot.wait_until_ready() + while True: + try: + current_version = pkg_resources.get_distribution('yt-dlp').version + response = requests.get('https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest') + latest_version = response.json()['tag_name'].lstrip('v') + + if current_version != latest_version: + # Notify bot owner if any guild has update checks enabled + all_guilds = await self.config.all_guilds() + for guild_id, settings in all_guilds.items(): + if not settings.get('disable_update_check', False): + owner = self.bot.get_user(self.bot.owner_id) + if owner: + await owner.send( + f"⚠️ A new version of yt-dlp is available!\n" + f"Current: {current_version}\n" + f"Latest: {latest_version}\n" + f"Use `[p]videoarchiver updateytdlp` to update." + ) + break + + except Exception as e: + logger.error(f"Failed to check for updates: {str(e)}") + + # Check once per day + await asyncio.sleep(86400) + + async def initialize_guild_components(self, guild_id: int): + """Initialize or update components for a guild""" + settings = await self.config.guild_from_id(guild_id).all() + + # Ensure download directory exists + self.download_path.mkdir(parents=True, exist_ok=True) + + 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 + ), + 'message_manager': MessageManager( + settings['message_duration'], + settings['message_template'] + ) + } + + def _check_user_roles(self, member: discord.Member, allowed_roles: List[int]) -> bool: + """Check if user has permission to trigger archiving""" + # If no roles are set, allow all users + if not allowed_roles: + return True + + # Check if user has any of the allowed roles + return any(role.id in allowed_roles for role in member.roles) + + async def log_message(self, guild: discord.Guild, message: str, level: str = "info"): + """Send a log message to the guild's log channel if set""" + settings = await self.config.guild(guild).all() + if settings["log_channel"]: + try: + log_channel = guild.get_channel(settings["log_channel"]) + if log_channel: + await log_channel.send(f"[{level.upper()}] {message}") + except discord.HTTPException: + logger.error(f"Failed to send log message to channel: {message}") + logger.log(getattr(logging, level.upper()), message) + + @commands.hybrid_group(name="videoarchiver", aliases=["va"]) + @commands.guild_only() + @commands.admin_or_permissions(administrator=True) + async def videoarchiver(self, ctx: commands.Context): + """Video Archiver configuration commands""" + if ctx.invoked_subcommand is None: + settings = await self.config.guild(ctx.guild).all() + embed = discord.Embed( + title="Video Archiver Settings", + color=discord.Color.blue() + ) + + archive_channel = ctx.guild.get_channel(settings["archive_channel"]) if settings["archive_channel"] else None + notification_channel = ctx.guild.get_channel(settings["notification_channel"]) if settings["notification_channel"] else None + log_channel = ctx.guild.get_channel(settings["log_channel"]) if settings["log_channel"] else None + monitored_channels = [ctx.guild.get_channel(c) for c in settings["monitored_channels"]] + monitored_channels = [c.mention for c in monitored_channels if c] + allowed_roles = [ctx.guild.get_role(r) for r in settings["allowed_roles"]] + allowed_roles = [r.name for r in allowed_roles if r] + + embed.add_field( + name="Archive Channel", + value=archive_channel.mention if archive_channel else "Not set", + inline=False + ) + embed.add_field( + name="Notification Channel", + value=notification_channel.mention if notification_channel else "Same as archive", + inline=False + ) + embed.add_field( + name="Log Channel", + value=log_channel.mention if log_channel else "Not set", + inline=False + ) + embed.add_field( + name="Monitored Channels", + value="\n".join(monitored_channels) if monitored_channels else "None", + inline=False + ) + embed.add_field( + name="Allowed Roles", + value=", ".join(allowed_roles) if allowed_roles else "All roles (no restrictions)", + inline=False + ) + embed.add_field(name="Video Format", value=settings["video_format"], inline=True) + embed.add_field(name="Max Quality", value=f"{settings['video_quality']}p", inline=True) + embed.add_field(name="Max File Size", value=f"{settings['max_file_size']}MB", inline=True) + embed.add_field(name="Delete After Repost", value=str(settings["delete_after_repost"]), inline=True) + embed.add_field(name="Message Duration", value=f"{settings['message_duration']} hours", inline=True) + embed.add_field(name="Concurrent Downloads", value=str(settings["concurrent_downloads"]), inline=True) + embed.add_field(name="Update Check Disabled", value=str(settings["disable_update_check"]), inline=True) + embed.add_field( + name="Enabled Sites", + value=", ".join(settings["enabled_sites"]) if settings["enabled_sites"] else "All sites", + inline=False + ) + + # Add hardware info + gpu_info = self.ffmpeg_mgr._gpu_info + cpu_cores = self.ffmpeg_mgr._cpu_cores + + hardware_info = f"CPU Cores: {cpu_cores}\n" + if gpu_info['nvidia']: + hardware_info += "NVIDIA GPU: Available (using NVENC)\n" + if gpu_info['amd']: + hardware_info += "AMD GPU: Available (using AMF)\n" + if gpu_info['intel']: + hardware_info += "Intel GPU: Available (using QSV)\n" + if not any(gpu_info.values()): + hardware_info += "No GPU acceleration available (using CPU)\n" + + embed.add_field(name="Hardware Info", value=hardware_info, inline=False) + + await ctx.send(embed=embed) + + @videoarchiver.command(name="updateytdlp") + @checks.is_owner() + async def update_ytdlp(self, ctx: commands.Context): + """Update yt-dlp to the latest version""" + try: + process = await asyncio.create_subprocess_exec( + sys.executable, '-m', 'pip', 'install', '--upgrade', 'yt-dlp', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + new_version = pkg_resources.get_distribution('yt-dlp').version + await ctx.send(f"✅ Successfully updated yt-dlp to version {new_version}") + else: + await ctx.send(f"❌ Failed to update yt-dlp: {stderr.decode()}") + except Exception as e: + await ctx.send(f"❌ Error updating yt-dlp: {str(e)}") + + @videoarchiver.command(name="toggleupdates") + @commands.admin_or_permissions(administrator=True) + async def toggle_update_check(self, ctx: commands.Context): + """Toggle yt-dlp update notifications""" + current = await self.config.guild(ctx.guild).disable_update_check() + await self.config.guild(ctx.guild).disable_update_check.set(not current) + state = "disabled" if not current else "enabled" + await ctx.send(f"Update notifications {state}") + await self.log_message(ctx.guild, f"Update notifications {state}") + + # [Previous commands remain unchanged...] + @videoarchiver.command(name="addrole") + async def add_allowed_role(self, ctx: commands.Context, role: discord.Role): + """Add a role that's allowed to trigger archiving""" + async with self.config.guild(ctx.guild).allowed_roles() as roles: + if role.id not in roles: + roles.append(role.id) + await ctx.send(f"Added {role.name} to allowed roles") + await self.log_message(ctx.guild, f"Added role {role.name} ({role.id}) to allowed roles") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="removerole") + async def remove_allowed_role(self, ctx: commands.Context, role: discord.Role): + """Remove a role from allowed roles""" + async with self.config.guild(ctx.guild).allowed_roles() as roles: + if role.id in roles: + roles.remove(role.id) + await ctx.send(f"Removed {role.name} from allowed roles") + await self.log_message(ctx.guild, f"Removed role {role.name} ({role.id}) from allowed roles") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="listroles") + async def list_allowed_roles(self, ctx: commands.Context): + """List all roles allowed to trigger archiving""" + roles = await self.config.guild(ctx.guild).allowed_roles() + if not roles: + await ctx.send("No roles are currently allowed (all users can trigger archiving)") + return + + role_names = [r.name for r in [ctx.guild.get_role(role_id) for role_id in roles] if r] + await ctx.send(f"Allowed roles: {', '.join(role_names)}") + + @videoarchiver.command(name="setconcurrent") + async def set_concurrent_downloads(self, ctx: commands.Context, count: int): + """Set the number of concurrent downloads (1-5)""" + if not 1 <= count <= 5: + await ctx.send("Concurrent downloads must be between 1 and 5") + return + + await self.config.guild(ctx.guild).concurrent_downloads.set(count) + await ctx.send(f"Concurrent downloads set to {count}") + await self.log_message(ctx.guild, f"Concurrent downloads set to {count}") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="setchannel") + async def set_archive_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the archive channel""" + await self.config.guild(ctx.guild).archive_channel.set(channel.id) + await ctx.send(f"Archive channel set to {channel.mention}") + await self.log_message(ctx.guild, f"Archive channel set to {channel.name} ({channel.id})") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="setnotification") + async def set_notification_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the notification channel (where archive messages appear)""" + await self.config.guild(ctx.guild).notification_channel.set(channel.id) + await ctx.send(f"Notification channel set to {channel.mention}") + await self.log_message(ctx.guild, f"Notification channel set to {channel.name} ({channel.id})") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="setlogchannel") + async def set_log_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Set the log channel for error messages and notifications""" + await self.config.guild(ctx.guild).log_channel.set(channel.id) + await ctx.send(f"Log channel set to {channel.mention}") + await self.log_message(ctx.guild, f"Log channel set to {channel.name} ({channel.id})") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="addmonitor") + async def add_monitored_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Add a channel to monitor for videos""" + async with self.config.guild(ctx.guild).monitored_channels() as channels: + if channel.id not in channels: + channels.append(channel.id) + await ctx.send(f"Now monitoring {channel.mention} for videos") + await self.log_message(ctx.guild, f"Added {channel.name} ({channel.id}) to monitored channels") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="removemonitor") + async def remove_monitored_channel(self, ctx: commands.Context, channel: discord.TextChannel): + """Remove a channel from monitoring""" + async with self.config.guild(ctx.guild).monitored_channels() as channels: + if channel.id in channels: + channels.remove(channel.id) + await ctx.send(f"Stopped monitoring {channel.mention}") + await self.log_message(ctx.guild, f"Removed {channel.name} ({channel.id}) from monitored channels") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="setformat") + async def set_video_format(self, ctx: commands.Context, format: str): + """Set the video format (e.g., mp4, webm)""" + await self.config.guild(ctx.guild).video_format.set(format.lower()) + await ctx.send(f"Video format set to {format.lower()}") + await self.log_message(ctx.guild, f"Video format set to {format.lower()}") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="setquality") + async def set_video_quality(self, ctx: commands.Context, quality: int): + """Set the maximum video quality in pixels (e.g., 1080)""" + await self.config.guild(ctx.guild).video_quality.set(quality) + await ctx.send(f"Maximum video quality set to {quality}p") + await self.log_message(ctx.guild, f"Video quality set to {quality}p") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="setmaxsize") + async def set_max_file_size(self, ctx: commands.Context, size: int): + """Set the maximum file size in MB""" + await self.config.guild(ctx.guild).max_file_size.set(size) + await ctx.send(f"Maximum file size set to {size}MB") + await self.log_message(ctx.guild, f"Maximum file size set to {size}MB") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="toggledelete") + async def toggle_delete_after_repost(self, ctx: commands.Context): + """Toggle whether to delete local files after reposting""" + current = await self.config.guild(ctx.guild).delete_after_repost() + await self.config.guild(ctx.guild).delete_after_repost.set(not current) + await ctx.send(f"Delete after repost: {not current}") + await self.log_message(ctx.guild, f"Delete after repost set to: {not current}") + + @videoarchiver.command(name="setduration") + async def set_message_duration(self, ctx: commands.Context, hours: int): + """Set how long to keep archive messages (0 for permanent)""" + await self.config.guild(ctx.guild).message_duration.set(hours) + await ctx.send(f"Archive message duration set to {hours} hours") + await self.log_message(ctx.guild, f"Message duration set to {hours} hours") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="settemplate") + async def set_message_template(self, ctx: commands.Context, *, template: str): + """Set the archive message template. Use {author}, {url}, and {original_message} as placeholders""" + await self.config.guild(ctx.guild).message_template.set(template) + await ctx.send(f"Archive message template set to:\n{template}") + await self.log_message(ctx.guild, f"Message template updated") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="enablesites") + async def enable_sites(self, ctx: commands.Context, *sites: str): + """Enable specific sites (leave empty for all sites)""" + sites = [s.lower() for s in sites] + if not sites: + await self.config.guild(ctx.guild).enabled_sites.set([]) + await ctx.send("All sites enabled") + else: + # Verify sites are valid + with yt_dlp.YoutubeDL() as ydl: + valid_sites = set(ie.IE_NAME.lower() for ie in ydl._ies) + invalid_sites = [s for s in sites 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.guild(ctx.guild).enabled_sites.set(sites) + await ctx.send(f"Enabled sites: {', '.join(sites)}") + + await self.log_message(ctx.guild, f"Enabled sites updated: {', '.join(sites) if sites else 'All sites'}") + await self.initialize_guild_components(ctx.guild.id) + + @videoarchiver.command(name="listsites") + async def list_sites(self, ctx: commands.Context): + """List all available sites and currently enabled sites""" + settings = await self.config.guild(ctx.guild).all() + enabled_sites = settings["enabled_sites"] + + embed = discord.Embed( + title="Video Sites Configuration", + color=discord.Color.blue() + ) + + with yt_dlp.YoutubeDL() as ydl: + all_sites = sorted(ie.IE_NAME for ie in ydl._ies if 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) + + @videoarchiver.command(name="updateffmpeg") + @checks.is_owner() + async def update_ffmpeg(self, ctx: commands.Context): + """Force re-download of FFmpeg binary. Use this if FFmpeg is not working properly.""" + try: + await ctx.send("Attempting to re-download FFmpeg...") + if self.ffmpeg_mgr.force_download(): + await ctx.send("✅ FFmpeg successfully updated") + else: + await ctx.send("❌ Failed to update FFmpeg. Check logs for details.") + except Exception as e: + await ctx.send(f"❌ Error updating FFmpeg: {str(e)}") + + async def process_video_url(self, url: str, message: discord.Message) -> bool: + """Process a video URL: download, reupload, and cleanup""" + guild_id = message.guild.id + + # Initialize components if needed + if guild_id not in self.components: + await self.initialize_guild_components(guild_id) + + try: + await message.add_reaction("⏳") + await self.log_message(message.guild, f"Processing video URL: {url}") + + settings = await self.config.guild(message.guild).all() + + # Check user roles + if not self._check_user_roles(message.author, settings["allowed_roles"]): + await message.add_reaction("🚫") + return False + + # Download video + success, file_path, error = await self.components[guild_id][ + "downloader" + ].download_video(url) + + if not success: + await message.add_reaction("❌") + await self.log_message( + message.guild, f"Failed to download video: {error}", "error" + ) + return False + + # Get channels + archive_channel = message.guild.get_channel(settings["archive_channel"]) + notification_channel = message.guild.get_channel( + settings["notification_channel"] + if settings["notification_channel"] + else settings["archive_channel"] + ) + + if not archive_channel or not notification_channel: + await self.log_message( + message.guild, "Required channels not found!", "error" + ) + return False + + try: + # Upload to archive channel + file = discord.File(file_path) + archive_message = await archive_channel.send(file=file) + + # Send notification with information + notification_message = await notification_channel.send( + self.components[guild_id]["message_manager"].format_archive_message( + author=message.author.mention, + url=( + archive_message.attachments[0].url + if archive_message.attachments + else "No URL available" + ), + original_message=message.jump_url, + ) + ) + + # Schedule notification message deletion if needed + await self.components[guild_id][ + "message_manager" + ].schedule_message_deletion( + notification_message.id, notification_message.delete + ) + + await message.add_reaction("✅") + await self.log_message( + message.guild, f"Successfully archived video from {message.author}" + ) + + except discord.HTTPException as e: + await self.log_message( + message.guild, f"Failed to upload video: {str(e)}", "error" + ) + await message.add_reaction("❌") + return False + + finally: + # Always attempt to delete the file if configured + if settings["delete_after_repost"]: + if secure_delete_file(file_path): + await self.log_message( + message.guild, f"Successfully deleted file: {file_path}" + ) + else: + await self.log_message( + message.guild, + f"Failed to delete file: {file_path}", + "error", + ) + # Emergency cleanup + cleanup_downloads(str(self.download_path)) + + return True + + except Exception as e: + await self.log_message( + message.guild, f"Error processing video: {str(e)}", "error" + ) + await message.add_reaction("❌") + return False + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.author.bot or not message.guild: + return + + settings = await self.config.guild(message.guild).all() + + # Check if message is in a monitored channel + if message.channel.id not in settings["monitored_channels"]: + return + + # Initialize components if needed + if message.guild.id not in self.components: + await self.initialize_guild_components(message.guild.id) + + # Find all video URLs in message + urls = [] + with yt_dlp.YoutubeDL() as ydl: + for ie in ydl._ies: + if ie._VALID_URL: + urls.extend(re.findall(ie._VALID_URL, message.content)) + + if urls: + # Process multiple URLs concurrently but limited + tasks = [] + semaphore = asyncio.Semaphore(settings["concurrent_downloads"]) + + async def process_with_semaphore(url): + async with semaphore: + return await self.process_video_url(url, message) + + for url in urls: + tasks.append(asyncio.create_task(process_with_semaphore(url))) + + # Wait for all downloads to complete + await asyncio.gather(*tasks)