Code improvements

This commit is contained in:
Lord Of Nougate 2025-05-25 16:42:08 +02:00
parent 4281aafc61
commit fe567952b4
5 changed files with 435 additions and 82 deletions

View File

@ -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
# 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

View File

@ -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))

View File

@ -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,

92
main.py
View File

@ -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)

View File

@ -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)}")