import discord from discord.ext import commands import asyncio import aiohttp from typing import Optional, List, Dict, Union, Any import re import logging import config # We'll import jellyseerr_api later to avoid circular imports logger = logging.getLogger('commands') # Get the API client from main module (to avoid circular imports) 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 if 'jellyseerr_api' in sys.modules and hasattr(sys.modules['jellyseerr_api'], 'jellyseerr_api'): jellyseerr_api = sys.modules['jellyseerr_api'].jellyseerr_api logger.debug("Retrieved jellyseerr_api instance from module") 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. 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: return int(value) except (ValueError, TypeError): 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). 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 media_id = safe_int_convert(media_id) if not media_id or media_id <= 0: await ctx.send("Media ID must be a positive number.") return async with ctx.typing(): try: api = get_api() logger.info(f"Looking up media with ID: {media_id}") # Try to get as movie first try: movie = await api.get_movie_details(media_id) if movie and movie.get('title'): # It's a movie, show movie details embed = discord.Embed( title=f"{movie.get('title')} ({movie.get('releaseDate', 'Unknown')[:4]})", description=movie.get('overview'), color=self.embed_color, url=f"{config.JELLYSEERR_URL}/movie/{media_id}" ) # Add poster if available if movie.get('posterPath'): embed.set_thumbnail(url=f"https://image.tmdb.org/t/p/w500{movie.get('posterPath')}") # Add rating and runtime rating_value = movie.get('voteAverage', 0) rating_str = f"⭐ {rating_value}/10" if rating_value else "Not rated" runtime = movie.get('runtime', 0) runtime_str = f"{runtime} mins" if runtime else "Unknown" embed.add_field(name="Type", value="Movie", inline=True) embed.add_field(name="Rating", value=rating_str, inline=True) embed.add_field(name="Runtime", value=runtime_str, inline=True) # Add genres genres = movie.get('genres', []) if genres: genres_str = ", ".join([genre.get('name') for genre in genres]) embed.add_field(name="Genres", value=genres_str, inline=True) # Add media status if available media_info = movie.get('mediaInfo') if media_info: status = media_info.get('status') status_map = { 1: "Unknown", 2: "Pending", 3: "Processing", 4: "Partially Available", 5: "Available" } status_str = status_map.get(status, "Unknown") embed.add_field(name="Status", value=status_str, inline=True) # Add request button info if not media_info or media_info.get('status') < 5: embed.add_field( name="Request this Movie", value=f"Use `{config.BOT_PREFIX}request movie {media_id}` to request this movie", inline=False ) await ctx.send(embed=embed) return except Exception as movie_error: # Not a movie, try as TV show pass # Try as TV show try: tv = await api.get_tv_details(media_id) if tv and tv.get('name'): # It's a TV show, show TV details title = tv.get('name') first_air_date = tv.get('firstAirDate', '') year = f" ({first_air_date[:4]})" if first_air_date else "" embed = discord.Embed( title=f"{title}{year}", description=tv.get('overview'), color=self.embed_color, url=f"{config.JELLYSEERR_URL}/tv/{media_id}" ) # Add poster if available if tv.get('posterPath'): embed.set_thumbnail(url=f"https://image.tmdb.org/t/p/w500{tv.get('posterPath')}") # Add rating and seasons info rating_value = tv.get('voteAverage', 0) rating_str = f"⭐ {rating_value}/10" if rating_value else "Not rated" seasons_count = tv.get('numberOfSeasons', 0) episodes_count = tv.get('numberOfEpisodes', 0) seasons_str = f"{seasons_count} seasons ({episodes_count} episodes)" embed.add_field(name="Type", value="TV Show", inline=True) embed.add_field(name="Rating", value=rating_str, inline=True) embed.add_field(name="Seasons", value=seasons_str, inline=True) # Add status status = tv.get('status', 'Unknown') embed.add_field(name="Status", value=status, inline=True) # Add genres genres = tv.get('genres', []) if genres: genres_str = ", ".join([genre.get('name') for genre in genres]) embed.add_field(name="Genres", value=genres_str, inline=True) # Add networks networks = tv.get('networks', []) if networks: networks_str = ", ".join([network.get('name') for network in networks]) embed.add_field(name="Networks", value=networks_str, inline=True) # Add media status if available media_info = tv.get('mediaInfo') if media_info: status = media_info.get('status') status_map = { 1: "Unknown", 2: "Pending", 3: "Processing", 4: "Partially Available", 5: "Available" } status_str = status_map.get(status, "Unknown") embed.add_field(name="Availability", value=status_str, inline=True) # Add seasons list (compact) seasons = tv.get('seasons', []) if seasons: valid_seasons = [ season for season in seasons if season.get('seasonNumber', 0) > 0 ] if valid_seasons: season_nums = [str(season.get('seasonNumber')) for season in valid_seasons] season_list = ", ".join(season_nums) embed.add_field( name=f"Available Seasons", value=season_list, inline=False ) # Add request button info if not media_info or media_info.get('status') < 5: embed.add_field( name="Request this Show", value=f"Use `{config.BOT_PREFIX}request tv {media_id}` to request all seasons or\n" f"`{config.BOT_PREFIX}request tv {media_id} ` for specific seasons", inline=False ) await ctx.send(embed=embed) return except Exception as tv_error: # Not a TV show either pass # If we get here, the ID doesn't exist as either movie or TV show await ctx.send(f"Error: Could not find media with ID {media_id}. This could be because:\n" f"1. The ID doesn't exist in TMDB\n" 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. 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 if len(query) < 2: await ctx.send("Search term must be at least 2 characters long.") return async with ctx.typing(): try: api = get_api() results = await api.search(query=query) if not results or not results.get('results'): await ctx.send(f"No results found for '{query}'") return # Filter out person results media_results = [result for result in results.get('results', []) if result.get('mediaType') in ['movie', 'tv']] if not media_results: await ctx.send(f"No movie or TV show results found for '{query}'") return # Limit to first 5 results for cleaner output displayed_results = media_results[:5] embed = discord.Embed( title=f"Search Results for '{query}'", color=self.embed_color ) for i, result in enumerate(displayed_results, 1): media_type = result.get('mediaType') title = result.get('title') if media_type == 'movie' else result.get('name') year = "" if media_type == 'movie' and result.get('releaseDate'): year = f" ({result.get('releaseDate')[:4]})" elif media_type == 'tv' and result.get('firstAirDate'): year = f" ({result.get('firstAirDate')[:4]})" media_id = result.get('id') embed.add_field( name=f"{i}. {title}{year} ({media_type.upper()})", value=f"ID: {media_id}\n{result.get('overview', '')[:100]}...", inline=False ) if len(media_results) > 5: embed.set_footer(text=f"Showing 5 out of {len(media_results)} results") 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. Args: ctx: The command context """ async with ctx.typing(): try: api = get_api() trending = await api.discover_trending() if not trending or not trending.get('results'): await ctx.send("No trending media found") return # Get first 5 results results = trending.get('results', [])[:5] embed = discord.Embed( title=f"Trending Movies & TV Shows", color=self.embed_color ) for result in results: media_type = result.get('mediaType') title = result.get('title') if media_type == 'movie' else result.get('name') year = "" if media_type == 'movie' and result.get('releaseDate'): year = f" ({result.get('releaseDate')[:4]})" elif media_type == 'tv' and result.get('firstAirDate'): year = f" ({result.get('firstAirDate')[:4]})" media_id = result.get('id') overview = result.get('overview', '') if len(overview) > 100: overview = f"{overview[:100]}..." embed.add_field( name=f"{title}{year} ({media_type.upper()})", value=f"ID: {media_id}\n{overview}", inline=False ) # Add note about the new info command embed.set_footer(text=f"Tip: Use {config.BOT_PREFIX}info to get details for any result") 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 @commands.command(name="request", aliases=["req"]) async def create_request(self, ctx, media_type: str = None, media_id = None, *args): """ Request a movie or TV show Examples: !request movie 123 !request tv 456 !request tv 456 1,2,3 (for specific seasons) Note: Requests will need to be approved in the Jellyseerr web interface """ if not media_type: await ctx.send("Please specify a media type (`movie` or `tv`). Example: `!request movie 550`") return if media_type.lower() not in ['movie', 'tv']: await ctx.send("Invalid media type. Use 'movie' or 'tv'.") return media_id = safe_int_convert(media_id) if not media_id: await ctx.send(f"Please provide a valid media ID. Example: `!request {media_type} 550`") return if media_id <= 0: await ctx.send("Media ID must be a positive number.") return async with ctx.typing(): try: # Check if there are specific seasons for TV shows seasons = None if media_type.lower() == 'tv' and args: season_arg = args[0] if season_arg.lower() == 'all': seasons = 'all' else: # Parse comma-separated season numbers try: # Check for valid format if not re.match(r'^[0-9]+(,[0-9]+)*$', season_arg): await ctx.send("Invalid season format. Use comma-separated numbers (e.g., `1,2,3`) or 'all'.") return seasons = [int(s.strip()) for s in season_arg.split(',')] # Check for invalid season numbers if any(s <= 0 for s in seasons): await ctx.send("Season numbers must be positive integers.") return except ValueError: await ctx.send("Invalid season format. Use comma-separated numbers or 'all'.") return # Get media details first to show what's being requested api = get_api() try: if media_type.lower() == 'movie': logger.info(f"Getting movie details for request: {media_id}") media_details = await api.get_movie_details(media_id) title = media_details.get('title') year = media_details.get('releaseDate', '')[:4] if media_details.get('releaseDate') else '' else: # TV show logger.info(f"Getting TV details for request: {media_id}") media_details = await api.get_tv_details(media_id) title = media_details.get('name') year = media_details.get('firstAirDate', '')[:4] if media_details.get('firstAirDate') else '' if not title: await ctx.send(f"Could not find {media_type} with ID {media_id}. Please check the ID and try again.") return except Exception as e: logger.error(f"Error retrieving media details for request: {str(e)}") await ctx.send(f"Could not retrieve details for {media_type} with ID {media_id}. Please check the ID and try again.") return # Create the request request_data = { 'media_type': media_type.lower(), 'media_id': media_id, 'is_4k': False } if media_type.lower() == 'tv' and seasons: request_data['seasons'] = seasons logger.info(f"Submitting request for {media_type} ID {media_id}") # Submit request request = await api.create_request(**request_data) # Build response embed if year: title = f"{title} ({year})" request_type = "Movie" if media_type.lower() == "movie" else "TV Show" embed = discord.Embed( title=f"{request_type} Request Submitted", description=f"**{title}** has been requested!", color=self.embed_color ) # Add info about which account was used embed.set_footer(text=f"Requested using Jellyseerr account: {config.JELLYSEERR_EMAIL}") status = request.get('status', 0) status_map = { 1: "Pending Approval", 2: "Approved", 3: "Declined" } status_text = status_map.get(status, "Unknown") embed.add_field(name="Status", value=status_text, inline=True) if media_type.lower() == 'tv' and seasons and seasons != 'all': embed.add_field(name="Requested Seasons", value=", ".join(str(s) for s in seasons), inline=True) # Add poster if available poster_path = media_details.get('posterPath') if poster_path: embed.set_thumbnail(url=f"https://image.tmdb.org/t/p/w500{poster_path}") await ctx.send(embed=embed) except Exception as e: logger.error(f"Error creating request: {str(e)}", exc_info=True) error_message = str(e) if "500" in error_message: await ctx.send(f"Error creating request: Server error (500). This could be because:\n" f"1. You might not have permission to request this media\n" f"2. The media ID doesn't exist or is invalid\n" f"3. The media may already be requested or available\n\n" f"Try searching for the media first with `{config.BOT_PREFIX}search` to get a valid ID") 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: # 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): """ List media requests with optional status filter Statuses: all, pending, approved, available, processing """ valid_statuses = ["all", "pending", "approved", "available", "processing"] if status.lower() not in valid_statuses: await ctx.send(f"Invalid status. Use one of: {', '.join(valid_statuses)}") return page = safe_int_convert(page, 1) if page <= 0: await ctx.send("Page number must be a positive integer.") return async with ctx.typing(): try: # Get requests with the specified filter api = get_api() requests_data = await api.get_requests(filter_status=status.lower(), page=page) results = requests_data.get('results', []) if not results: await ctx.send(f"No {status} requests found.") return # Build embed response embed = discord.Embed( title=f"{status.capitalize()} Media Requests", color=self.embed_color ) for req in results: media = req.get('media', {}) media_type = media.get('mediaType', 'unknown') # Get title based on media type if media_type == 'movie': title = media.get('title', 'Unknown Movie') else: title = media.get('name', 'Unknown TV Show') # Get request details request_id = req.get('id') status_code = req.get('status', 0) status_map = { 1: "📝 Pending", 2: "✅ Approved", 3: "❌ Declined", 4: "⏳ Processing" } status_text = status_map.get(status_code, "Unknown") # Get requester requested_by = req.get('requestedBy', {}).get('username', 'Unknown') # Format requested date created_at = req.get('createdAt', '') request_date = created_at.split('T')[0] if created_at else 'Unknown' # Format seasons for TV shows seasons_text = "" if media_type == 'tv' and req.get('seasons'): seasons = req.get('seasons', []) if seasons: seasons_str = ", ".join(str(s) for s in seasons) seasons_text = f"\nSeasons: {seasons_str}" embed.add_field( name=f"{title} ({media_type.upper()})", value=f"Request ID: {request_id}\n" f"Status: {status_text}\n" f"Requested by: {requested_by}\n" f"Date: {request_date}{seasons_text}", inline=False ) # Add pagination info if available page_info = requests_data.get('pageInfo', {}) if page_info: total_pages = page_info.get('pages', 1) total_results = page_info.get('results', 0) embed.set_footer(text=f"Page {page} of {total_pages} • {total_results} total requests") 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 # 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))