diff --git a/.env.example b/.env.example index ae8534e..ec1d7db 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,56 @@ -# Discord Jellyseerr Bot - Environment Variables +#===================================================================== +# Discord Jellyseerr Bot - Environment Configuration +#===================================================================== # Copy this file to .env and fill in your values +# All sensitive information should be kept secure -# Discord Bot Token (required) +#--------------------------------------------------------------------- +# Discord Bot Settings (REQUIRED) +#--------------------------------------------------------------------- +# Your Discord Bot Token from the Discord Developer Portal +# Create one at: https://discord.com/developers/applications DISCORD_BOT_TOKEN=your_discord_bot_token_here -# Bot command prefix (default is !) +# Command prefix for bot commands (default: !) BOT_PREFIX=! -# Jellyseerr Configuration (required) +# Color for embeds in Discord messages (hex format) +# Default: 0x3498db (Discord blue) +EMBED_COLOR=0x3498db + +#--------------------------------------------------------------------- +# Jellyseerr Connection Settings (REQUIRED) +#--------------------------------------------------------------------- +# URL of your Jellyseerr instance including protocol and port +# Example: http://localhost:5055 or https://jellyseerr.yourdomain.com JELLYSEERR_URL=http://your-jellyseerr-instance:5055 -# Jellyseerr Authentication (using local user account) +# Authentication Settings (using local Jellyseerr user account) JELLYSEERR_EMAIL=your_jellyseerr_email@example.com JELLYSEERR_PASSWORD=your_jellyseerr_password # Set to false if you want to disable local login (not recommended) +# When disabled, some functionality may be limited JELLYSEERR_LOCAL_LOGIN=true # Number of days until authentication cookie expires (default: 7) AUTH_COOKIE_EXPIRY=7 +#--------------------------------------------------------------------- # Notifications -# Jellyseerr provides webhooks for notifications, which can be configured -# directly in the Jellyseerr settings. Discord webhooks are recommended -# instead of using this bot for notifications. - -# UI Settings -EMBED_COLOR=0x3498db - -# Debug Settings -# Set to true to enable verbose logging (useful for troubleshooting) -DEBUG_MODE=false +#--------------------------------------------------------------------- +# NOTE: For notifications about media requests and availability, +# use Jellyseerr's built-in Discord webhook integration. +# Configure webhooks directly in the Jellyseerr web interface +# under Settings > Notifications. +#--------------------------------------------------------------------- +# Performance & Debug Settings +#--------------------------------------------------------------------- # API request timeout in seconds -REQUEST_TIMEOUT=30 \ No newline at end of file +# Increase this value if your Jellyseerr instance is slow to respond +REQUEST_TIMEOUT=30 + +# Set to true to enable verbose logging (useful for troubleshooting) +# Warning: Debug mode generates large log files and exposes sensitive data +DEBUG_MODE=false \ No newline at end of file diff --git a/commands.py b/commands.py index 66aaa19..3afcf67 100644 --- a/commands.py +++ b/commands.py @@ -16,6 +16,17 @@ import sys jellyseerr_api = None def get_api(): + """Get the Jellyseerr API client instance. + + This function retrieves the API client instance that was initialized in main.py. + It handles the potential circular import issue by accessing the instance through sys.modules. + + Returns: + JellyseerrAPI: The initialized API client instance + + Raises: + RuntimeError: If the API client hasn't been initialized + """ global jellyseerr_api if jellyseerr_api is None: # Get the API client instance from the main module @@ -25,10 +36,19 @@ def get_api(): else: # This should not happen, but just in case logger.error("Could not find jellyseerr_api instance") + raise RuntimeError("API client not initialized. This is likely a bug.") return jellyseerr_api def safe_int_convert(value, default=None): - """Safely convert a value to integer, returning default if conversion fails""" + """Safely convert a value to integer, returning default if conversion fails. + + Args: + value: The value to convert to an integer + default: The default value to return if conversion fails + + Returns: + int or default: The converted integer or the default value + """ if value is None: return default try: @@ -37,13 +57,25 @@ def safe_int_convert(value, default=None): return default class MediaCommands(commands.Cog): + """Commands for searching and displaying media information.""" + def __init__(self, bot): + """Initialize the MediaCommands cog. + + Args: + bot: The Discord bot instance + """ self.bot = bot self.embed_color = config.EMBED_COLOR @commands.command(name="info", aliases=["i"]) async def get_media_info(self, ctx, media_id = None): - """Get detailed information about a movie or TV show by ID (auto-detects type)""" + """Get detailed information about a movie or TV show by ID (auto-detects type). + + Args: + ctx: The command context + media_id: The TMDB ID of the movie or TV show + """ if not media_id: await ctx.send("Please provide a media ID. Example: `!info 550` (for Fight Club)") return @@ -219,13 +251,24 @@ class MediaCommands(commands.Cog): f"2. Your Jellyseerr account doesn't have permission to view this media\n\n" f"Try using `{config.BOT_PREFIX}testmedia {media_id}` for more information.") + except aiohttp.ClientConnectorError as e: + logger.error(f"Connection error retrieving media details for ID {media_id}: {str(e)}", exc_info=True) + await ctx.send(f"Error connecting to Jellyseerr server. Please check if the server is running and try again later.") + except asyncio.TimeoutError: + logger.error(f"Request timed out for media ID {media_id}", exc_info=True) + await ctx.send(f"Request timed out. The Jellyseerr server might be slow or overloaded. Please try again later.") except Exception as e: logger.error(f"Error retrieving media details for ID {media_id}: {str(e)}", exc_info=True) await ctx.send(f"Error retrieving media details: {str(e)}") @commands.command(name="search", aliases=["s"]) async def search_media(self, ctx, *, query: str = None): - """Search for movies and TV shows by title""" + """Search for movies and TV shows by title. + + Args: + ctx: The command context + query: The search query/term + """ if not query: await ctx.send("Please provide a search term. Example: `!search Stranger Things`") return @@ -282,14 +325,25 @@ class MediaCommands(commands.Cog): await ctx.send(embed=embed) + except aiohttp.ClientConnectorError: + logger.error(f"Connection error while searching for '{query}'", exc_info=True) + await ctx.send(f"Error connecting to Jellyseerr server. Please check if the server is running and try again later.") + except asyncio.TimeoutError: + logger.error(f"Search request timed out for '{query}'", exc_info=True) + await ctx.send(f"Search request timed out. The Jellyseerr server might be slow or overloaded. Please try again later.") except Exception as e: + logger.error(f"Error searching for '{query}': {str(e)}", exc_info=True) await ctx.send(f"Error searching for media: {str(e)}") # Movie and TV commands removed - replaced by the unified info command @commands.command(name="trending") async def get_trending(self, ctx): - """Get trending movies and TV shows""" + """Get trending movies and TV shows. + + Args: + ctx: The command context + """ async with ctx.typing(): try: api = get_api() @@ -333,11 +387,25 @@ class MediaCommands(commands.Cog): await ctx.send(embed=embed) + except aiohttp.ClientConnectorError: + logger.error("Connection error while retrieving trending media", exc_info=True) + await ctx.send(f"Error connecting to Jellyseerr server. Please check if the server is running and try again later.") + except asyncio.TimeoutError: + logger.error("Trending request timed out", exc_info=True) + await ctx.send(f"Request timed out. The Jellyseerr server might be slow or overloaded. Please try again later.") except Exception as e: + logger.error(f"Error retrieving trending media: {str(e)}", exc_info=True) await ctx.send(f"Error retrieving trending media: {str(e)}") class RequestCommands(commands.Cog): + """Commands for managing media requests.""" + def __init__(self, bot): + """Initialize the RequestCommands cog. + + Args: + bot: The Discord bot instance + """ self.bot = bot self.embed_color = config.EMBED_COLOR @@ -481,7 +549,13 @@ class RequestCommands(commands.Cog): elif "401" in error_message or "403" in error_message: await ctx.send(f"Error creating request: Authentication error. The bot account may not have permission to make requests.") else: - await ctx.send(f"Error creating request: {str(e)}") + # Provide more specific error messages for common issues + if "already exists" in str(e).lower(): + await ctx.send(f"This media has already been requested or is already available in your library.") + elif "not found" in str(e).lower(): + await ctx.send(f"Media not found. Please check the ID and try again.") + else: + await ctx.send(f"Error creating request: {str(e)}") @commands.command(name="requests", aliases=["reqs"]) async def list_requests(self, ctx, status: str = "pending", page = 1): @@ -571,7 +645,14 @@ class RequestCommands(commands.Cog): await ctx.send(embed=embed) + except aiohttp.ClientConnectorError: + logger.error(f"Connection error while retrieving {status} requests", exc_info=True) + await ctx.send(f"Error connecting to Jellyseerr server. Please check if the server is running and try again later.") + except asyncio.TimeoutError: + logger.error(f"Request timed out while retrieving {status} requests", exc_info=True) + await ctx.send(f"Request timed out. The Jellyseerr server might be slow or overloaded. Please try again later.") except Exception as e: + logger.error(f"Error retrieving {status} requests: {str(e)}", exc_info=True) await ctx.send(f"Error retrieving requests: {str(e)}") # Approve and decline commands have been removed @@ -579,6 +660,11 @@ class RequestCommands(commands.Cog): # UtilityCommands have been moved to utility_commands.py def setup(bot): + """Set up the command cogs. + + Args: + bot: The Discord bot instance + """ bot.add_cog(MediaCommands(bot)) bot.add_cog(RequestCommands(bot)) bot.add_cog(UtilityCommands(bot)) \ No newline at end of file diff --git a/jellyseerr_api.py b/jellyseerr_api.py index 9f7910b..3aea3f2 100644 --- a/jellyseerr_api.py +++ b/jellyseerr_api.py @@ -10,17 +10,29 @@ from typing import Dict, List, Optional, Any, Union logger = logging.getLogger('jellyseerr_api') class JellyseerrAPI: + """Client for interacting with the Jellyseerr API. + + This class handles authentication, session management, and provides methods + for interacting with various Jellyseerr API endpoints. + """ def __init__(self, base_url: str, email: str = None, password: str = None): + """Initialize the Jellyseerr API client. + + Args: + base_url: The base URL of the Jellyseerr instance + email: Email for authentication with Jellyseerr + password: Password for authentication with Jellyseerr + """ self.base_url = base_url.rstrip('/') self.email = email self.password = password self.headers = { 'Content-Type': 'application/json' } - self.session = None - self.cookie_jar = None - self.auth_cookie = None - self.last_login = None + self.session: Optional[aiohttp.ClientSession] = None + self.cookie_jar: Optional[aiohttp.CookieJar] = None + self.auth_cookie: Optional[str] = None + self.last_login: Optional[datetime] = None async def __aenter__(self): if not self.cookie_jar: @@ -34,7 +46,14 @@ class JellyseerrAPI: await self.session.close() async def login(self) -> bool: - """Log in to Jellyseerr using local authentication""" + """Log in to Jellyseerr using local authentication. + + Returns: + bool: True if login was successful + + Raises: + Exception: If authentication fails for any reason + """ # Skip login if we have a valid cookie and it's not expired cookie_expiry_days = config.AUTH_COOKIE_EXPIRY if (self.auth_cookie and self.last_login and @@ -103,7 +122,21 @@ class JellyseerrAPI: logger.error(f"Login error: {str(e)}", exc_info=True) raise Exception(f"Failed to authenticate with Jellyseerr: {str(e)}") - async def _request(self, method: str, endpoint: str, params: Dict = None, data: Dict = None) -> Any: + async def _request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None) -> Any: + """Make a request to the Jellyseerr API. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint (without the base URL and /api/v1) + params: Query parameters + data: JSON request body + + Returns: + Any: The JSON response data, or None for 204 responses + + Raises: + Exception: For API errors, connection issues, or other failures + """ # Ensure we're logged in if not self.last_login: try: @@ -127,9 +160,8 @@ class JellyseerrAPI: url = f"{self.base_url}/api/v1{endpoint}" - # URL encode parameters + # URL encode parameters - only encode string values, not numbers or booleans if params: - # Create a new dict with URL-encoded values encoded_params = {} for key, value in params.items(): if isinstance(value, str): @@ -139,8 +171,17 @@ class JellyseerrAPI: params = encoded_params try: + # Set timeout for the request + timeout = aiohttp.ClientTimeout(total=config.REQUEST_TIMEOUT) + # Attempt the request - async with self.session.request(method, url, headers=self.headers, params=params, json=data) as response: + async with self.session.request( + method, url, + headers=self.headers, + params=params, + json=data, + timeout=timeout + ) as response: if response.status == 204: # No content return None @@ -153,7 +194,13 @@ class JellyseerrAPI: try: await self.login() # Retry the request after re-login - async with self.session.request(method, url, headers=self.headers, params=params, json=data) as retry_response: + async with self.session.request( + method, url, + headers=self.headers, + params=params, + json=data, + timeout=timeout + ) as retry_response: logger.debug(f"Retry response status: {retry_response.status}") if retry_response.status == 204: @@ -180,15 +227,34 @@ class JellyseerrAPI: raise Exception(f"API Error ({response.status}): {error_message}") return response_data - except aiohttp.ClientResponseError: - raise Exception(f"API Error ({response.status}): Failed to parse response") - except aiohttp.ClientError as e: + except aiohttp.ContentTypeError: + # Not JSON, get the text response + error_text = await response.text() + raise Exception(f"API Error ({response.status}): Not a valid JSON response: {error_text[:100]}...") + except aiohttp.ClientResponseError as e: + raise Exception(f"API Error ({response.status}): {str(e)}") + except aiohttp.ClientConnectorError as e: logger.error(f"Connection error: {str(e)}") + raise Exception(f"Connection error: Could not connect to {self.base_url}: {str(e)}") + except aiohttp.ClientError as e: + logger.error(f"HTTP client error: {str(e)}") raise Exception(f"Connection error: {str(e)}") + except asyncio.TimeoutError: + logger.error(f"Request timed out after {config.REQUEST_TIMEOUT} seconds") + raise Exception(f"Request timed out after {config.REQUEST_TIMEOUT} seconds. Check your network connection and Jellyseerr server status.") # Search endpoints - async def search(self, query: str, page: int = 1, language: str = 'en') -> Dict: - """Search for movies, TV shows, or people""" + async def search(self, query: str, page: int = 1, language: str = 'en') -> Dict[str, Any]: + """Search for movies, TV shows, or people. + + Args: + query: The search term + page: Page number for results + language: Language code for results (e.g., 'en', 'fr') + + Returns: + Dict containing search results + """ params = { 'query': query, 'page': page, @@ -197,32 +263,75 @@ class JellyseerrAPI: return await self._request('GET', '/search', params=params) # Media information endpoints - async def get_movie_details(self, movie_id: int, language: str = 'en') -> Dict: - """Get detailed information about a movie""" + async def get_movie_details(self, movie_id: int, language: str = 'en') -> Dict[str, Any]: + """Get detailed information about a movie. + + Args: + movie_id: TMDB ID of the movie + language: Language code for results (e.g., 'en', 'fr') + + Returns: + Dict containing movie details + """ params = {'language': language} return await self._request('GET', f'/movie/{movie_id}', params=params) - async def get_tv_details(self, tv_id: int, language: str = 'en') -> Dict: - """Get detailed information about a TV show""" + async def get_tv_details(self, tv_id: int, language: str = 'en') -> Dict[str, Any]: + """Get detailed information about a TV show. + + Args: + tv_id: TMDB ID of the TV show + language: Language code for results (e.g., 'en', 'fr') + + Returns: + Dict containing TV show details + """ params = {'language': language} return await self._request('GET', f'/tv/{tv_id}', params=params) - async def get_season_details(self, tv_id: int, season_id: int, language: str = 'en') -> Dict: - """Get detailed information about a TV season""" + async def get_season_details(self, tv_id: int, season_id: int, language: str = 'en') -> Dict[str, Any]: + """Get detailed information about a TV season. + + Args: + tv_id: TMDB ID of the TV show + season_id: Season number + language: Language code for results (e.g., 'en', 'fr') + + Returns: + Dict containing season details + """ params = {'language': language} return await self._request('GET', f'/tv/{tv_id}/season/{season_id}', params=params) # Recommendation endpoints - async def get_movie_recommendations(self, movie_id: int, page: int = 1, language: str = 'en') -> Dict: - """Get movie recommendations based on a movie""" + async def get_movie_recommendations(self, movie_id: int, page: int = 1, language: str = 'en') -> Dict[str, Any]: + """Get movie recommendations based on a movie. + + Args: + movie_id: TMDB ID of the movie + page: Page number for results + language: Language code for results (e.g., 'en', 'fr') + + Returns: + Dict containing recommended movies + """ params = { 'page': page, 'language': language } return await self._request('GET', f'/movie/{movie_id}/recommendations', params=params) - async def get_tv_recommendations(self, tv_id: int, page: int = 1, language: str = 'en') -> Dict: - """Get TV show recommendations based on a TV show""" + async def get_tv_recommendations(self, tv_id: int, page: int = 1, language: str = 'en') -> Dict[str, Any]: + """Get TV show recommendations based on a TV show. + + Args: + tv_id: TMDB ID of the TV show + page: Page number for results + language: Language code for results (e.g., 'en', 'fr') + + Returns: + Dict containing recommended TV shows + """ params = { 'page': page, 'language': language @@ -241,8 +350,21 @@ class JellyseerrAPI: async def create_request(self, media_type: str, media_id: int, seasons: Union[List[int], str] = None, - is_4k: bool = False) -> Dict: - """Create a new media request""" + is_4k: bool = False) -> Dict[str, Any]: + """Create a new media request. + + Args: + media_type: Type of media ('movie' or 'tv') + media_id: TMDB ID of the media + seasons: For TV shows, specific seasons to request or 'all' + is_4k: Whether to request 4K version + + Returns: + Dict containing the created request information + + Raises: + Exception: If the request creation fails + """ data = { 'mediaType': media_type, 'mediaId': media_id, diff --git a/main.py b/main.py index 9af8f9f..bcb5659 100644 --- a/main.py +++ b/main.py @@ -10,9 +10,14 @@ import traceback # Set up logging def setup_logging(): + """Configure and set up the logging system for the bot. + + Returns: + logging.Logger: The configured logger for the bot + """ log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' detailed_log_format = '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s' - log_level = logging.INFO + log_level = logging.DEBUG if config.DEBUG_MODE else logging.INFO # Create logs directory if it doesn't exist os.makedirs('logs', exist_ok=True) @@ -21,9 +26,14 @@ def setup_logging(): root_logger = logging.getLogger() root_logger.setLevel(log_level) + # Clear existing handlers to avoid duplication + if root_logger.handlers: + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + # Console handler console_handler = logging.StreamHandler(sys.stdout) - console_handler.setFormatter(logging.Formatter(debug_format if config.DEBUG_MODE else log_format)) + console_handler.setFormatter(logging.Formatter(detailed_log_format if config.DEBUG_MODE else log_format)) console_handler.setLevel(log_level) # File handler with rotation (5 files, 5MB each) @@ -74,6 +84,7 @@ jellyseerr_api = None # Load all cogs async def load_extensions(): + """Load all command extensions and initialize the API client.""" # Import here to avoid circular imports from commands import MediaCommands, RequestCommands from utility_commands import UtilityCommands @@ -81,23 +92,35 @@ async def load_extensions(): # Initialize the API client global jellyseerr_api - jellyseerr_api = JellyseerrAPI(config.JELLYSEERR_URL, config.JELLYSEERR_EMAIL, config.JELLYSEERR_PASSWORD) - - # Make API client available to command modules - import sys - sys.modules['jellyseerr_api'].jellyseerr_api = jellyseerr_api - - await bot.add_cog(MediaCommands(bot)) - await bot.add_cog(RequestCommands(bot)) - await bot.add_cog(UtilityCommands(bot)) - logger.info("Loaded all extensions") + try: + jellyseerr_api = JellyseerrAPI(config.JELLYSEERR_URL, config.JELLYSEERR_EMAIL, config.JELLYSEERR_PASSWORD) + + # Make API client available to command modules + import sys + sys.modules['jellyseerr_api'].jellyseerr_api = jellyseerr_api + + # Add all cogs + logger.info("Loading command extensions...") + await bot.add_cog(MediaCommands(bot)) + await bot.add_cog(RequestCommands(bot)) + await bot.add_cog(UtilityCommands(bot)) + logger.info("Loaded all extensions") + except Exception as e: + logger.error(f"Failed to initialize extensions: {str(e)}", exc_info=True) + raise @bot.event async def on_ready(): + """Handler for when the bot has connected to Discord and is ready.""" logger.info(f'Logged in as {bot.user} (ID: {bot.user.id})') logger.info(f'Connected to {len(bot.guilds)} guilds') + + # List connected guilds + guild_list = [] for guild in bot.guilds: + guild_list.append(f' - {guild.name} (ID: {guild.id})') logger.info(f' - {guild.name} (ID: {guild.id})') + logger.info(f'Using prefix: {config.BOT_PREFIX}') logger.info(f'Jellyseerr URL: {config.JELLYSEERR_URL}') @@ -132,6 +155,20 @@ async def on_ready(): ) await bot.change_presence(activity=activity) logger.info("Bot is ready and fully operational") + + # Print a nice summary to the log + bot_info = [ + "=====================================", + "Discord Jellyseerr Bot Ready", + "=====================================", + f"Bot User: {bot.user}", + f"Bot ID: {bot.user.id}", + f"Prefix: {config.BOT_PREFIX}", + f"Guilds: {len(bot.guilds)}", + f"API URL: {config.JELLYSEERR_URL}", + "=====================================" + ] + logger.info("\n".join(bot_info)) @bot.event async def on_command(ctx): @@ -143,6 +180,12 @@ async def on_command_completion(ctx): @bot.event async def on_command_error(ctx, error): + """Handle command errors and provide user-friendly responses. + + Args: + ctx: The context of the command + error: The error that occurred + """ if isinstance(error, commands.CommandNotFound): logger.debug(f"Command not found: {ctx.message.content}") return @@ -169,21 +212,35 @@ async def on_command_error(ctx, error): original = error.original if hasattr(error, 'original') else error # Create a detailed error message for logging - error_details = f"Command error in {ctx.command} invoked by {ctx.author}: {str(original)}" + command_name = ctx.command.name if ctx.command else "unknown" + error_details = f"Command error in {command_name} invoked by {ctx.author}: {str(original)}" logger.error(error_details, exc_info=True) # Create a user-friendly error message if "400" in str(original) and "must be url encoded" in str(original).lower(): await ctx.send("Error processing your request: Your input contains special characters that couldn't be processed. Please try again with simpler text.") elif "API Error" in str(original): - await ctx.send(f"Jellyseerr API error: {str(original).split('API Error')[1].strip()}") + try: + error_msg = str(original).split('API Error')[1].strip() + await ctx.send(f"Jellyseerr API error: {error_msg}") + except: + await ctx.send(f"Jellyseerr API error: {str(original)}") + elif "Connection error" in str(original): + await ctx.send(f"Connection error: Cannot connect to Jellyseerr. Please check if the server is running and try again later.") + elif "timeout" in str(original).lower(): + await ctx.send(f"The request timed out. The Jellyseerr server might be slow or unreachable. Please try again later.") else: await ctx.send(f"An error occurred while processing your command. Please try again later or check the syntax with `{config.BOT_PREFIX}help {ctx.command}`.") async def main(): + """Main function to initialize and start the bot.""" try: async with bot: logger.info("Starting Discord Jellyseerr Bot") + logger.info(f"Bot version: 1.0.0") + logger.info(f"Python version: {sys.version}") + logger.info(f"Discord.py version: {discord.__version__}") + if config.JELLYSEERR_LOCAL_LOGIN: logger.info(f"Using Jellyseerr authentication with account: {config.JELLYSEERR_EMAIL}") else: @@ -193,9 +250,13 @@ async def main(): logger.debug("DEBUG MODE ENABLED - verbose logging activated") await load_extensions() + logger.info("Connecting to Discord...") await bot.start(config.BOT_TOKEN) except KeyboardInterrupt: logger.info("Bot shutting down by keyboard interrupt") + except discord.LoginFailure: + logger.critical("Invalid Discord token. Please check your DISCORD_BOT_TOKEN in the .env file.", exc_info=True) + sys.exit(1) except Exception as e: logger.critical(f"Fatal error: {str(e)}", exc_info=True) sys.exit(1) @@ -203,6 +264,9 @@ async def main(): if __name__ == "__main__": try: asyncio.run(main()) + except KeyboardInterrupt: + print("Bot shut down by keyboard interrupt") + sys.exit(0) except Exception as e: print(f"Fatal error occurred: {str(e)}") logging.critical(f"Fatal error in main process: {str(e)}", exc_info=True) diff --git a/utility_commands.py b/utility_commands.py index 3dcfac6..342c5d1 100644 --- a/utility_commands.py +++ b/utility_commands.py @@ -11,14 +11,26 @@ from commands import safe_int_convert, get_api logger = logging.getLogger('utility_commands') class UtilityCommands(commands.Cog): + """Commands for utility functions like help, status, and testing.""" + def __init__(self, bot): + """Initialize the UtilityCommands cog. + + Args: + bot: The Discord bot instance + """ self.bot = bot self.embed_color = config.EMBED_COLOR self.tmdb_base_url = "https://api.themoviedb.org/3" @commands.command(name="help", aliases=["h"]) async def custom_help(self, ctx, command: str = None): - """Show help information for all commands or a specific command""" + """Show help information for all commands or a specific command. + + Args: + ctx: The command context + command: Optional specific command to get help for + """ if command: cmd = self.bot.get_command(command) if cmd: @@ -93,26 +105,34 @@ class UtilityCommands(commands.Cog): @commands.command(name="testmedia") async def test_media(self, ctx, media_type: str = None, media_id = None): - """ - Test if a media ID exists directly on TMDB and check correct media type + """Test if a media ID exists directly on TMDB and check correct media type. + + This command tests whether a given ID exists on TMDB and Jellyseerr, + and verifies the correct media type. Useful for troubleshooting. + + Args: + ctx: The command context + media_type: Optional media type (movie or tv) + media_id: The TMDB ID to test + Example: !testmedia movie 83936 """ - if not media_type and media_id: + if not media_type and not media_id: + await ctx.send("Please provide a media ID. Example: `!testmedia 83936` or `!testmedia movie 550`") + return + elif not media_id: # If only one parameter is provided, assume it's the ID media_id = media_type media_type = None await ctx.send(f"Testing both movie and TV show for ID {media_id}...") - elif not media_type or not media_id: - await ctx.send("Please provide a media ID. Example: `!testmedia 83936` or `!testmedia movie 550`") - return if media_type and media_type.lower() not in ['movie', 'tv']: await ctx.send("Invalid media type. Use 'movie' or 'tv' or just provide the ID to test both.") return media_id = safe_int_convert(media_id) - if not media_id: - await ctx.send(f"Please provide a valid media ID. Example: `!testmedia 550`") + if not media_id or media_id <= 0: + await ctx.send(f"Please provide a valid media ID (a positive number). Example: `!testmedia 550`") return async with ctx.typing(): @@ -128,17 +148,44 @@ class UtilityCommands(commands.Cog): # Test both movie and TV endpoints directly using HTTP requests async with aiohttp.ClientSession() as session: - # Test movie endpoint - async with session.get(f"{self.tmdb_base_url}/movie/{media_id}?api_key=12345") as movie_response: - movie_status = movie_response.status + try: + # Test movie endpoint + async with session.get(f"{self.tmdb_base_url}/movie/{media_id}", timeout=aiohttp.ClientTimeout(total=15)) as movie_response: + movie_status = movie_response.status + + # Test TV endpoint + async with session.get(f"{self.tmdb_base_url}/tv/{media_id}", timeout=aiohttp.ClientTimeout(total=15)) as tv_response: + tv_status = tv_response.status - # Test TV endpoint - async with session.get(f"{self.tmdb_base_url}/tv/{media_id}?api_key=12345") as tv_response: - tv_status = tv_response.status - - # Check which type of media this ID belongs to - is_movie = movie_status == 401 # 401 means it exists but auth failed - is_tv = tv_status == 401 # 401 means it exists but auth failed + # Check which type of media this ID belongs to + # 401 or 404 with specific message means it exists but auth failed + # Pure 404 means it doesn't exist + is_movie = movie_status == 401 or (movie_status == 404 and "authentication" in await movie_response.text().lower()) + is_tv = tv_status == 401 or (tv_status == 404 and "authentication" in await tv_response.text().lower()) + + except asyncio.TimeoutError: + logger.error(f"TMDB request timed out for ID {media_id}", exc_info=True) + embed.add_field( + name="TMDB Status", + value="❌ Request to TMDB timed out. Please try again later.", + inline=False + ) + return await interaction.followup.send(embed=embed) + except aiohttp.ClientConnectorError as e: + logger.error(f"TMDB connection error for ID {media_id}: {str(e)}", exc_info=True) + embed.add_field( + name="TMDB Status", + value="❌ Could not connect to TMDB. Please check your internet connection.", + inline=False + ) + return await interaction.followup.send(embed=embed) + except Exception as e: + embed.add_field( + name="TMDB Status", + value=f"❌ Error checking TMDB: {str(e)}", + inline=False + ) + return await interaction.followup.send(embed=embed) if is_movie and not is_tv: embed.add_field( @@ -308,7 +355,14 @@ class UtilityCommands(commands.Cog): @commands.command(name="status") async def get_status(self, ctx): - """Check Jellyseerr server status""" + """Check Jellyseerr server status and authentication. + + Provides information about the Jellyseerr server, including + version, update status, and authentication status. + + Args: + ctx: The command context + """ async with ctx.typing(): try: api = get_api() @@ -344,5 +398,12 @@ class UtilityCommands(commands.Cog): await ctx.send(embed=embed) + except aiohttp.ClientConnectorError as e: + logger.error(f"Connection error while checking server status: {str(e)}", exc_info=True) + await ctx.send(f"Error connecting to Jellyseerr server at {config.JELLYSEERR_URL}. Please check if the server is running.") + except asyncio.TimeoutError: + logger.error("Status request timed out", exc_info=True) + await ctx.send(f"Request timed out. The Jellyseerr server at {config.JELLYSEERR_URL} might be slow or overloaded.") except Exception as e: + logger.error(f"Error checking server status: {str(e)}", exc_info=True) await ctx.send(f"Error checking server status: {str(e)}") \ No newline at end of file