First working version of jellyseerr discord bot
This commit is contained in:
commit
1748432ddb
36
.env.example
Normal file
36
.env.example
Normal file
@ -0,0 +1,36 @@
|
||||
# Discord Jellyseerr Bot - Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# Discord Bot Token (required)
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token_here
|
||||
|
||||
# Bot command prefix (default is !)
|
||||
BOT_PREFIX=!
|
||||
|
||||
# Jellyseerr Configuration (required)
|
||||
JELLYSEERR_URL=http://your-jellyseerr-instance:5055
|
||||
|
||||
# Jellyseerr Authentication (using local 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)
|
||||
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
|
||||
|
||||
# API request timeout in seconds
|
||||
REQUEST_TIMEOUT=30
|
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# Python virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Bot specific
|
||||
bot.log
|
||||
config.json
|
||||
|
||||
# OS specific
|
||||
.DS_Store
|
||||
Thumbs.db
|
106
README.md
Normal file
106
README.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Discord-Jellyseerr Bot
|
||||
|
||||
This Discord bot integrates with Jellyseerr to provide commands for searching, requesting, and getting notifications about media in your Jellyseerr instance.
|
||||
|
||||
## Features
|
||||
|
||||
- Search for movies and TV shows
|
||||
- Get detailed information about media
|
||||
- Request movies and TV shows to be added to your library
|
||||
- Get recommendations for similar content
|
||||
- Simple interface for interacting with your Jellyseerr instance
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone this repository:
|
||||
```
|
||||
git clone https://github.com/yourusername/discord-jellyseerr.git
|
||||
cd discord-jellyseerr
|
||||
```
|
||||
|
||||
2. Install the required dependencies:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Copy the `.env.example` file to `.env` and edit it with your configuration:
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. Edit the `.env` file with your Discord bot token and Jellyseerr API details:
|
||||
```
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token_here
|
||||
JELLYSEERR_URL=http://your-jellyseerr-instance:5055
|
||||
JELLYSEERR_EMAIL=your_jellyseerr_email@example.com
|
||||
JELLYSEERR_PASSWORD=your_jellyseerr_password
|
||||
```
|
||||
|
||||
5. Run the bot:
|
||||
```
|
||||
python main.py
|
||||
```
|
||||
|
||||
Alternatively, use the provided shell script:
|
||||
```
|
||||
./run.sh
|
||||
```
|
||||
|
||||
## Setting up the Discord Bot
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Create a new application and set up a bot
|
||||
4. Enable the following Privileged Gateway Intents:
|
||||
- MESSAGE CONTENT INTENT
|
||||
4. Invite the bot to your server with the following permissions:
|
||||
- Send Messages
|
||||
- Embed Links
|
||||
- Attach Files
|
||||
- Read Message History
|
||||
- Use External Emojis
|
||||
- Add Reactions
|
||||
|
||||
## Jellyseerr Authentication
|
||||
|
||||
This bot uses a local Jellyseerr user account for authentication:
|
||||
|
||||
1. Create or use an existing local user account in Jellyseerr
|
||||
2. Provide the email address and password in the .env file
|
||||
3. The bot will automatically authenticate and maintain the session
|
||||
|
||||
Using a dedicated user account provides better tracking of requests and actions performed by the bot.
|
||||
|
||||
**Important**: Jellyseerr requires the email address for login, not the username.
|
||||
|
||||
## Commands
|
||||
|
||||
### Search & Discovery
|
||||
- `!search <query>` - Search for movies and TV shows
|
||||
- `!movie <id>` - Get detailed information about a movie
|
||||
- `!tv <id>` - Get detailed information about a TV show
|
||||
- `!trending` - Show trending movies and TV shows
|
||||
|
||||
### Requests
|
||||
- `!request movie <id>` - Request a movie
|
||||
- `!request tv <id> [seasons]` - Request a TV show (all or specific seasons)
|
||||
- `!requests [status] [page]` - List media requests
|
||||
|
||||
|
||||
|
||||
### Other
|
||||
- `!help` - Show the help message
|
||||
- `!status` - Check Jellyseerr server status
|
||||
|
||||
## Webhooks
|
||||
|
||||
For notifications about request approvals, media availability, and other events, Jellyseerr provides built-in webhook support for Discord. Configure these directly in the Jellyseerr settings panel under "Notifications".
|
||||
|
||||
This bot does not handle notifications, as the native webhook integration provides a more reliable solution with customizable notifications.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
584
commands.py
Normal file
584
commands.py
Normal file
@ -0,0 +1,584 @@
|
||||
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():
|
||||
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")
|
||||
return jellyseerr_api
|
||||
|
||||
def safe_int_convert(value, default=None):
|
||||
"""Safely convert a value to integer, returning default if conversion fails"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
class MediaCommands(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
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)"""
|
||||
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} <season_nums>` 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 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"""
|
||||
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 Exception as e:
|
||||
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"""
|
||||
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 <id> to get details for any result")
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.send(f"Error retrieving trending media: {str(e)}")
|
||||
|
||||
class RequestCommands(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
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:
|
||||
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 Exception as e:
|
||||
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):
|
||||
bot.add_cog(MediaCommands(bot))
|
||||
bot.add_cog(RequestCommands(bot))
|
||||
bot.add_cog(UtilityCommands(bot))
|
42
config.py
Normal file
42
config.py
Normal file
@ -0,0 +1,42 @@
|
||||
import os
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Debug mode
|
||||
DEBUG_MODE = os.getenv('DEBUG_MODE', 'false').lower() == 'true'
|
||||
LOG_LEVEL = logging.DEBUG if DEBUG_MODE else logging.INFO
|
||||
|
||||
# Bot configuration
|
||||
BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
|
||||
BOT_PREFIX = os.getenv('BOT_PREFIX', '!') # Default prefix is ! if not specified
|
||||
|
||||
# Jellyseerr configuration
|
||||
JELLYSEERR_URL = os.getenv('JELLYSEERR_URL')
|
||||
JELLYSEERR_EMAIL = os.getenv('JELLYSEERR_EMAIL')
|
||||
JELLYSEERR_PASSWORD = os.getenv('JELLYSEERR_PASSWORD')
|
||||
JELLYSEERR_LOCAL_LOGIN = os.getenv('JELLYSEERR_LOCAL_LOGIN', 'true').lower() == 'true'
|
||||
|
||||
# Bot settings
|
||||
EMBED_COLOR = int(os.getenv('EMBED_COLOR', '0x3498db'), 0) # Default color is blue
|
||||
|
||||
# Authentication settings
|
||||
AUTH_COOKIE_EXPIRY = int(os.getenv('AUTH_COOKIE_EXPIRY', '7')) # Days until cookie expires
|
||||
|
||||
# Notification settings are not used as Jellyseerr webhooks are preferred
|
||||
# You can configure webhooks directly in Jellyseerr's settings
|
||||
|
||||
# Debug settings
|
||||
REQUEST_TIMEOUT = int(os.getenv('REQUEST_TIMEOUT', '30')) # Seconds to wait for API response
|
||||
|
||||
# Validation
|
||||
if not BOT_TOKEN:
|
||||
raise ValueError("No Discord bot token found. Please set DISCORD_BOT_TOKEN in your .env file.")
|
||||
|
||||
if not JELLYSEERR_URL:
|
||||
raise ValueError("No Jellyseerr URL found. Please set JELLYSEERR_URL in your .env file.")
|
||||
|
||||
if JELLYSEERR_LOCAL_LOGIN and (not JELLYSEERR_EMAIL or not JELLYSEERR_PASSWORD):
|
||||
raise ValueError("Jellyseerr email and password are required for local login. Please set JELLYSEERR_EMAIL and JELLYSEERR_PASSWORD in your .env file.")
|
7528
jellyseerr-api.yml
Normal file
7528
jellyseerr-api.yml
Normal file
File diff suppressed because it is too large
Load Diff
322
jellyseerr_api.py
Normal file
322
jellyseerr_api.py
Normal file
@ -0,0 +1,322 @@
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import config
|
||||
import urllib.parse
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
|
||||
logger = logging.getLogger('jellyseerr_api')
|
||||
|
||||
class JellyseerrAPI:
|
||||
def __init__(self, base_url: str, email: str = None, password: str = None):
|
||||
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
|
||||
|
||||
async def __aenter__(self):
|
||||
if not self.cookie_jar:
|
||||
self.cookie_jar = aiohttp.CookieJar()
|
||||
self.session = aiohttp.ClientSession(cookie_jar=self.cookie_jar)
|
||||
await self.login()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def login(self) -> bool:
|
||||
"""Log in to Jellyseerr using local authentication"""
|
||||
# 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
|
||||
datetime.now() < self.last_login + timedelta(days=cookie_expiry_days)):
|
||||
logger.debug("Using existing auth cookie")
|
||||
return True
|
||||
|
||||
if not self.session:
|
||||
if not self.cookie_jar:
|
||||
self.cookie_jar = aiohttp.CookieJar()
|
||||
self.session = aiohttp.ClientSession(cookie_jar=self.cookie_jar)
|
||||
|
||||
try:
|
||||
login_data = {
|
||||
'email': self.email,
|
||||
'password': self.password
|
||||
}
|
||||
|
||||
logger.info(f"Logging in to Jellyseerr with email: {self.email}")
|
||||
logger.debug(f"Login URL: {self.base_url}/api/v1/auth/local")
|
||||
|
||||
# Print request info for debugging
|
||||
logger.debug(f"Request headers: {self.session.headers}")
|
||||
logger.debug(f"Login payload (without password): {{'email': '{self.email}'}}")
|
||||
|
||||
async with self.session.post(
|
||||
f"{self.base_url}/api/v1/auth/local",
|
||||
json=login_data
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
logger.debug(f"Login response status: {response.status}")
|
||||
logger.debug(f"Login response headers: {response.headers}")
|
||||
logger.debug(f"Login response body: {response_text}")
|
||||
|
||||
if response.status == 200:
|
||||
# Login successful, cookies are automatically stored in the cookie jar
|
||||
self.last_login = datetime.now()
|
||||
logger.info("Successfully logged in to Jellyseerr")
|
||||
return True
|
||||
else:
|
||||
# Try to parse response as JSON, but handle case where it's not JSON
|
||||
try:
|
||||
error_data = json.loads(response_text)
|
||||
error_message = error_data.get('message', 'Unknown error')
|
||||
except json.JSONDecodeError:
|
||||
error_message = f"Non-JSON response (Status {response.status}): {response_text}"
|
||||
|
||||
logger.error(f"Login failed: {error_message}")
|
||||
|
||||
if response.status == 401:
|
||||
raise Exception(f"Authentication failed: Invalid email or password")
|
||||
elif response.status == 403:
|
||||
raise Exception(f"Authentication failed: Access denied. Account may be disabled or lacks permissions")
|
||||
elif response.status == 500:
|
||||
raise Exception(f"Authentication failed: Server error. Check Jellyseerr logs")
|
||||
else:
|
||||
raise Exception(f"Authentication failed ({response.status}): {error_message}")
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logger.error(f"Connection error: {str(e)}")
|
||||
raise Exception(f"Failed to connect to Jellyseerr at {self.base_url}: {str(e)}")
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Client error: {str(e)}")
|
||||
raise Exception(f"HTTP client error: {str(e)}")
|
||||
except Exception as e:
|
||||
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:
|
||||
# Ensure we're logged in
|
||||
if not self.last_login:
|
||||
try:
|
||||
await self.login()
|
||||
except Exception as e:
|
||||
logger.warning(f"Authentication failed, attempting request without authentication: {str(e)}")
|
||||
# Continue anyway - some endpoints might work without auth
|
||||
|
||||
# Create session if it doesn't exist
|
||||
if self.session is None:
|
||||
if not self.cookie_jar:
|
||||
self.cookie_jar = aiohttp.CookieJar()
|
||||
self.session = aiohttp.ClientSession(cookie_jar=self.cookie_jar)
|
||||
|
||||
# Debug info
|
||||
logger.debug(f"Making request: {method} {self.base_url}/api/v1{endpoint}")
|
||||
if params:
|
||||
logger.debug(f"Request params: {params}")
|
||||
if data:
|
||||
logger.debug(f"Request data: {data}")
|
||||
|
||||
url = f"{self.base_url}/api/v1{endpoint}"
|
||||
|
||||
# URL encode parameters
|
||||
if params:
|
||||
# Create a new dict with URL-encoded values
|
||||
encoded_params = {}
|
||||
for key, value in params.items():
|
||||
if isinstance(value, str):
|
||||
encoded_params[key] = urllib.parse.quote(value)
|
||||
else:
|
||||
encoded_params[key] = value
|
||||
params = encoded_params
|
||||
|
||||
try:
|
||||
# Attempt the request
|
||||
async with self.session.request(method, url, headers=self.headers, params=params, json=data) as response:
|
||||
if response.status == 204: # No content
|
||||
return None
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
|
||||
# If unauthorized, try to login again and retry
|
||||
if response.status == 401:
|
||||
logger.warning("Received unauthorized response, attempting to re-login")
|
||||
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:
|
||||
logger.debug(f"Retry response status: {retry_response.status}")
|
||||
|
||||
if retry_response.status == 204:
|
||||
return None
|
||||
|
||||
try:
|
||||
retry_data = await retry_response.json()
|
||||
if not retry_response.ok:
|
||||
error_message = retry_data.get('message', 'Unknown error')
|
||||
raise Exception(f"API Error ({retry_response.status}): {error_message}")
|
||||
|
||||
return retry_data
|
||||
except aiohttp.ContentTypeError:
|
||||
# Handle non-JSON responses
|
||||
retry_text = await retry_response.text()
|
||||
logger.error(f"Non-JSON response on retry: {retry_text}")
|
||||
raise Exception(f"API returned non-JSON response: {retry_text[:100]}...")
|
||||
except Exception as login_err:
|
||||
logger.error(f"Re-login failed: {str(login_err)}")
|
||||
raise Exception(f"Authentication error: {str(login_err)}")
|
||||
|
||||
if not response.ok:
|
||||
error_message = response_data.get('message', 'Unknown error')
|
||||
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:
|
||||
logger.error(f"Connection error: {str(e)}")
|
||||
raise Exception(f"Connection error: {str(e)}")
|
||||
|
||||
# Search endpoints
|
||||
async def search(self, query: str, page: int = 1, language: str = 'en') -> Dict:
|
||||
"""Search for movies, TV shows, or people"""
|
||||
params = {
|
||||
'query': query,
|
||||
'page': page,
|
||||
'language': language
|
||||
}
|
||||
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"""
|
||||
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"""
|
||||
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"""
|
||||
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"""
|
||||
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"""
|
||||
params = {
|
||||
'page': page,
|
||||
'language': language
|
||||
}
|
||||
return await self._request('GET', f'/tv/{tv_id}/recommendations', params=params)
|
||||
|
||||
# Request endpoints
|
||||
async def get_requests(self, filter_status: str = 'all', page: int = 1, page_size: int = 10) -> Dict:
|
||||
"""Get all requests with pagination"""
|
||||
params = {
|
||||
'filter': filter_status,
|
||||
'take': page_size,
|
||||
'skip': (page - 1) * page_size
|
||||
}
|
||||
return await self._request('GET', '/request', params=params)
|
||||
|
||||
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"""
|
||||
data = {
|
||||
'mediaType': media_type,
|
||||
'mediaId': media_id,
|
||||
'is4k': is_4k
|
||||
}
|
||||
|
||||
if media_type == 'tv' and seasons:
|
||||
data['seasons'] = seasons
|
||||
|
||||
return await self._request('POST', '/request', data=data)
|
||||
|
||||
async def get_request(self, request_id: int) -> Dict:
|
||||
"""Get details of a specific request"""
|
||||
return await self._request('GET', f'/request/{request_id}')
|
||||
|
||||
# Methods for approving and declining requests have been removed
|
||||
# These actions should be performed in the Jellyseerr web interface
|
||||
|
||||
# Discover endpoints
|
||||
async def discover_movies(self, page: int = 1, language: str = 'en',
|
||||
genre: Optional[str] = None, sort_by: str = 'popularity.desc') -> Dict:
|
||||
"""Discover movies with various filters"""
|
||||
params = {
|
||||
'page': page,
|
||||
'language': language,
|
||||
'sortBy': sort_by
|
||||
}
|
||||
|
||||
if genre:
|
||||
params['genre'] = genre
|
||||
|
||||
return await self._request('GET', '/discover/movies', params=params)
|
||||
|
||||
async def discover_tv(self, page: int = 1, language: str = 'en',
|
||||
genre: Optional[str] = None, sort_by: str = 'popularity.desc') -> Dict:
|
||||
"""Discover TV shows with various filters"""
|
||||
params = {
|
||||
'page': page,
|
||||
'language': language,
|
||||
'sortBy': sort_by
|
||||
}
|
||||
|
||||
if genre:
|
||||
params['genre'] = genre
|
||||
|
||||
return await self._request('GET', '/discover/tv', params=params)
|
||||
|
||||
async def discover_trending(self, page: int = 1, language: str = 'en') -> Dict:
|
||||
"""Get trending movies and TV shows"""
|
||||
params = {
|
||||
'page': page,
|
||||
'language': language
|
||||
}
|
||||
return await self._request('GET', '/discover/trending', params=params)
|
||||
|
||||
# Genre endpoints
|
||||
async def get_movie_genres(self, language: str = 'en') -> List:
|
||||
"""Get a list of movie genres"""
|
||||
params = {'language': language}
|
||||
return await self._request('GET', '/genres/movie', params=params)
|
||||
|
||||
async def get_tv_genres(self, language: str = 'en') -> List:
|
||||
"""Get a list of TV show genres"""
|
||||
params = {'language': language}
|
||||
return await self._request('GET', '/genres/tv', params=params)
|
||||
|
||||
async def get_media(self, filter_status: str = 'all', page: int = 1, page_size: int = 10) -> Dict:
|
||||
"""Get all media with pagination"""
|
||||
params = {
|
||||
'filter': filter_status,
|
||||
'take': page_size,
|
||||
'skip': (page - 1) * page_size
|
||||
}
|
||||
return await self._request('GET', '/media', params=params)
|
||||
|
||||
# Create an instance for import (but don't initialize cookie_jar yet)
|
||||
jellyseerr_api = JellyseerrAPI(config.JELLYSEERR_URL, config.JELLYSEERR_EMAIL, config.JELLYSEERR_PASSWORD)
|
209
main.py
Normal file
209
main.py
Normal file
@ -0,0 +1,209 @@
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import asyncio
|
||||
import config
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
# Set up logging
|
||||
def setup_logging():
|
||||
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
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
os.makedirs('logs', exist_ok=True)
|
||||
|
||||
# Configure root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(log_level)
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(logging.Formatter(debug_format if config.DEBUG_MODE else log_format))
|
||||
console_handler.setLevel(log_level)
|
||||
|
||||
# File handler with rotation (5 files, 5MB each)
|
||||
file_handler = RotatingFileHandler(
|
||||
filename='logs/bot.log',
|
||||
maxBytes=5 * 1024 * 1024, # 5MB
|
||||
backupCount=5,
|
||||
encoding='utf-8',
|
||||
mode='a' # Append mode instead of overwrite
|
||||
)
|
||||
file_handler.setFormatter(logging.Formatter(detailed_log_format))
|
||||
file_handler.setLevel(logging.DEBUG if config.DEBUG_MODE else log_level)
|
||||
|
||||
# Error file handler with rotation (5 files, 5MB each)
|
||||
error_file_handler = RotatingFileHandler(
|
||||
filename='logs/error.log',
|
||||
maxBytes=5 * 1024 * 1024, # 5MB
|
||||
backupCount=5,
|
||||
encoding='utf-8',
|
||||
mode='a' # Append mode instead of overwrite
|
||||
)
|
||||
error_file_handler.setFormatter(logging.Formatter(detailed_log_format))
|
||||
error_file_handler.setLevel(logging.ERROR)
|
||||
|
||||
# Add handlers
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(error_file_handler)
|
||||
|
||||
return logging.getLogger('bot')
|
||||
|
||||
logger = setup_logging()
|
||||
|
||||
# Initialize the bot with intents
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
|
||||
# Create the bot instance
|
||||
bot = commands.Bot(
|
||||
command_prefix=config.BOT_PREFIX,
|
||||
intents=intents,
|
||||
help_command=None # Disable default help command
|
||||
)
|
||||
|
||||
# Create API client variable (will be initialized later)
|
||||
jellyseerr_api = None
|
||||
|
||||
# Load all cogs
|
||||
async def load_extensions():
|
||||
# Import here to avoid circular imports
|
||||
from commands import MediaCommands, RequestCommands
|
||||
from utility_commands import UtilityCommands
|
||||
from jellyseerr_api import JellyseerrAPI
|
||||
|
||||
# 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")
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
logger.info(f'Logged in as {bot.user} (ID: {bot.user.id})')
|
||||
logger.info(f'Connected to {len(bot.guilds)} guilds')
|
||||
for guild in bot.guilds:
|
||||
logger.info(f' - {guild.name} (ID: {guild.id})')
|
||||
logger.info(f'Using prefix: {config.BOT_PREFIX}')
|
||||
logger.info(f'Jellyseerr URL: {config.JELLYSEERR_URL}')
|
||||
|
||||
# Test Jellyseerr authentication
|
||||
if config.JELLYSEERR_LOCAL_LOGIN:
|
||||
try:
|
||||
logger.info(f'Authenticating with Jellyseerr using email: {config.JELLYSEERR_EMAIL}')
|
||||
if config.DEBUG_MODE:
|
||||
logger.debug(f'Jellyseerr URL: {config.JELLYSEERR_URL}')
|
||||
logger.debug(f'Using local login with email: {config.JELLYSEERR_EMAIL}')
|
||||
|
||||
await jellyseerr_api.login()
|
||||
logger.info('Successfully authenticated with Jellyseerr')
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to authenticate with Jellyseerr: {str(e)}', exc_info=True)
|
||||
logger.warning('The bot will attempt to reconnect when commands are used')
|
||||
|
||||
if config.DEBUG_MODE:
|
||||
logger.debug(f'Authentication error details: {traceback.format_exc()}')
|
||||
logger.debug('Check that:')
|
||||
logger.debug('1. Your Jellyseerr URL is correct')
|
||||
logger.debug('2. Local login is enabled in Jellyseerr settings')
|
||||
logger.debug('3. The email and password are correct')
|
||||
logger.debug('4. The user account has not been disabled')
|
||||
else:
|
||||
logger.info('Local login disabled, proceeding without authentication')
|
||||
|
||||
# Set bot activity
|
||||
activity = discord.Activity(
|
||||
type=discord.ActivityType.watching,
|
||||
name=f"{config.BOT_PREFIX}help | Jellyseerr"
|
||||
)
|
||||
await bot.change_presence(activity=activity)
|
||||
logger.info("Bot is ready and fully operational")
|
||||
|
||||
@bot.event
|
||||
async def on_command(ctx):
|
||||
logger.info(f"Command '{ctx.command}' invoked by {ctx.author} in {ctx.guild}/{ctx.channel}")
|
||||
|
||||
@bot.event
|
||||
async def on_command_completion(ctx):
|
||||
logger.info(f"Command '{ctx.command}' completed successfully for {ctx.author}")
|
||||
|
||||
@bot.event
|
||||
async def on_command_error(ctx, error):
|
||||
if isinstance(error, commands.CommandNotFound):
|
||||
logger.debug(f"Command not found: {ctx.message.content}")
|
||||
return
|
||||
elif isinstance(error, commands.MissingRequiredArgument):
|
||||
logger.info(f"Missing required argument: {error.param.name} for command {ctx.command} by user {ctx.author}")
|
||||
await ctx.send(f"Missing required argument: `{error.param.name}`. Type `{config.BOT_PREFIX}help {ctx.command}` for correct usage.")
|
||||
elif isinstance(error, commands.BadArgument):
|
||||
logger.info(f"Bad argument for command {ctx.command} by user {ctx.author}: {str(error)}")
|
||||
await ctx.send(f"Invalid argument: `{str(error)}`. Please check your input and try again.")
|
||||
elif isinstance(error, commands.MissingPermissions):
|
||||
logger.info(f"Permission denied for {ctx.author} trying to use {ctx.command}")
|
||||
await ctx.send("You don't have permission to use this command.")
|
||||
elif isinstance(error, commands.CommandOnCooldown):
|
||||
logger.info(f"Command on cooldown for {ctx.author} trying to use {ctx.command}")
|
||||
await ctx.send(f"This command is on cooldown. Please try again in {error.retry_after:.1f} seconds.")
|
||||
elif isinstance(error, commands.DisabledCommand):
|
||||
logger.info(f"Disabled command {ctx.command} attempted by {ctx.author}")
|
||||
await ctx.send("This command is currently disabled.")
|
||||
elif isinstance(error, commands.NoPrivateMessage):
|
||||
logger.info(f"Command {ctx.command} not allowed in DMs, attempted by {ctx.author}")
|
||||
await ctx.send("This command cannot be used in private messages.")
|
||||
else:
|
||||
# Get original error if it's wrapped in CommandInvokeError
|
||||
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)}"
|
||||
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()}")
|
||||
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():
|
||||
try:
|
||||
async with bot:
|
||||
logger.info("Starting Discord Jellyseerr Bot")
|
||||
if config.JELLYSEERR_LOCAL_LOGIN:
|
||||
logger.info(f"Using Jellyseerr authentication with account: {config.JELLYSEERR_EMAIL}")
|
||||
else:
|
||||
logger.info("Local login disabled, some functionality may be limited")
|
||||
|
||||
if config.DEBUG_MODE:
|
||||
logger.debug("DEBUG MODE ENABLED - verbose logging activated")
|
||||
|
||||
await load_extensions()
|
||||
await bot.start(config.BOT_TOKEN)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot shutting down by keyboard interrupt")
|
||||
except Exception as e:
|
||||
logger.critical(f"Fatal error: {str(e)}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except Exception as e:
|
||||
print(f"Fatal error occurred: {str(e)}")
|
||||
logging.critical(f"Fatal error in main process: {str(e)}", exc_info=True)
|
||||
sys.exit(1)
|
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[tool.pyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
discord.py>=2.0.0
|
||||
python-dotenv>=0.19.0
|
||||
aiohttp>=3.8.0
|
||||
requests>=2.26.0
|
||||
asyncio>=3.4.3
|
||||
pyyaml>=6.0
|
||||
python-dateutil>=2.8.2
|
21
run.sh
Executable file
21
run.sh
Executable file
@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run the bot
|
||||
echo "Starting Discord Jellyseerr Bot..."
|
||||
python main.py
|
348
utility_commands.py
Normal file
348
utility_commands.py
Normal file
@ -0,0 +1,348 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from typing import Optional, List, Dict, Union, Any
|
||||
import logging
|
||||
|
||||
import config
|
||||
from commands import safe_int_convert, get_api
|
||||
|
||||
logger = logging.getLogger('utility_commands')
|
||||
|
||||
class UtilityCommands(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
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"""
|
||||
if command:
|
||||
cmd = self.bot.get_command(command)
|
||||
if cmd:
|
||||
embed = discord.Embed(
|
||||
title=f"Help: {config.BOT_PREFIX}{cmd.name}",
|
||||
description=cmd.help or "No description available",
|
||||
color=self.embed_color
|
||||
)
|
||||
|
||||
# Add aliases if any
|
||||
if cmd.aliases:
|
||||
embed.add_field(
|
||||
name="Aliases",
|
||||
value=", ".join([f"`{config.BOT_PREFIX}{alias}`" for alias in cmd.aliases]),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add usage
|
||||
embed.add_field(
|
||||
name="Usage",
|
||||
value=f"`{config.BOT_PREFIX}{cmd.name} {cmd.signature}`",
|
||||
inline=False
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
return
|
||||
else:
|
||||
await ctx.send(f"Command `{command}` not found. Try `{config.BOT_PREFIX}help` to see all commands.")
|
||||
return
|
||||
embed = discord.Embed(
|
||||
title="Jellyseerr Discord Bot Help",
|
||||
description="Here are the commands you can use:",
|
||||
color=self.embed_color
|
||||
)
|
||||
|
||||
# Search & Discovery Commands
|
||||
embed.add_field(
|
||||
name="🔍 Search & Discovery",
|
||||
value=(
|
||||
f"`{config.BOT_PREFIX}search <query>` - Search for movies and TV shows\n"
|
||||
f"`{config.BOT_PREFIX}info <id>` - Get detailed information about any media (auto-detects type)\n"
|
||||
f"`{config.BOT_PREFIX}trending` - Show trending movies and TV shows"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Request Commands
|
||||
embed.add_field(
|
||||
name="📝 Requests",
|
||||
value=(
|
||||
f"`{config.BOT_PREFIX}request movie <id>` - Request a movie\n"
|
||||
f"`{config.BOT_PREFIX}request tv <id> [seasons]` - Request a TV show (all or specific seasons)\n"
|
||||
f"`{config.BOT_PREFIX}requests [status] [page]` - List media requests\n"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Other Commands
|
||||
embed.add_field(
|
||||
name="🔧 Other",
|
||||
value=(
|
||||
f"`{config.BOT_PREFIX}help` - Show this help message\n"
|
||||
f"`{config.BOT_PREFIX}status` - Check Jellyseerr server status and authentication\n"
|
||||
f"`{config.BOT_PREFIX}testmedia <type> <id>` - Test if media exists in TMDB and Jellyseerr\n"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(text=f"Bot Prefix: {config.BOT_PREFIX}")
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@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
|
||||
Example: !testmedia movie 83936
|
||||
"""
|
||||
if not media_type and 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`")
|
||||
return
|
||||
|
||||
async with ctx.typing():
|
||||
try:
|
||||
# Try to get media info from Jellyseerr
|
||||
api = get_api()
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Media Test: ID {media_id}",
|
||||
description="Testing this ID against both movie and TV endpoints",
|
||||
color=self.embed_color
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
if is_movie and not is_tv:
|
||||
embed.add_field(
|
||||
name="TMDB Status",
|
||||
value="✅ This ID belongs to a **MOVIE**",
|
||||
inline=False
|
||||
)
|
||||
correct_type = "movie"
|
||||
elif is_tv and not is_movie:
|
||||
embed.add_field(
|
||||
name="TMDB Status",
|
||||
value="✅ This ID belongs to a **TV SHOW**",
|
||||
inline=False
|
||||
)
|
||||
correct_type = "tv"
|
||||
elif is_movie and is_tv:
|
||||
embed.add_field(
|
||||
name="TMDB Status",
|
||||
value="⚠️ Unusual: This ID seems to exist as both movie and TV show",
|
||||
inline=False
|
||||
)
|
||||
correct_type = "both"
|
||||
else:
|
||||
embed.add_field(
|
||||
name="TMDB Status",
|
||||
value="❌ This ID was not found in TMDB database (neither movie nor TV show)",
|
||||
inline=False
|
||||
)
|
||||
correct_type = None
|
||||
|
||||
# If media_type was specified, check if it matches
|
||||
if media_type and correct_type and correct_type != "both" and media_type.lower() != correct_type:
|
||||
embed.add_field(
|
||||
name="Media Type Mismatch",
|
||||
value=f"⚠️ You specified `{media_type}` but this ID is for a `{correct_type}`.\n"
|
||||
f"Use `{config.BOT_PREFIX}{correct_type} {media_id}` instead.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Test Jellyseerr access for the correct media type
|
||||
try:
|
||||
if correct_type == "movie" or (media_type and media_type.lower() == 'movie'):
|
||||
media_details = await api.get_movie_details(media_id)
|
||||
title = media_details.get('title', 'Unknown')
|
||||
embed.add_field(
|
||||
name="Jellyseerr Movie Status",
|
||||
value=f"✅ Found in Jellyseerr: {title}",
|
||||
inline=False
|
||||
)
|
||||
elif correct_type == "tv" or (media_type and media_type.lower() == 'tv'):
|
||||
media_details = await api.get_tv_details(media_id)
|
||||
title = media_details.get('name', 'Unknown')
|
||||
embed.add_field(
|
||||
name="Jellyseerr TV Status",
|
||||
value=f"✅ Found in Jellyseerr: {title}",
|
||||
inline=False
|
||||
)
|
||||
elif correct_type == "both":
|
||||
# Try both endpoints
|
||||
try:
|
||||
movie_details = await api.get_movie_details(media_id)
|
||||
movie_title = movie_details.get('title', 'Unknown')
|
||||
embed.add_field(
|
||||
name="Jellyseerr Movie Status",
|
||||
value=f"✅ Found as movie in Jellyseerr: {movie_title}",
|
||||
inline=False
|
||||
)
|
||||
except:
|
||||
embed.add_field(
|
||||
name="Jellyseerr Movie Status",
|
||||
value="❌ Not found as movie in Jellyseerr",
|
||||
inline=False
|
||||
)
|
||||
|
||||
try:
|
||||
tv_details = await api.get_tv_details(media_id)
|
||||
tv_title = tv_details.get('name', 'Unknown')
|
||||
embed.add_field(
|
||||
name="Jellyseerr TV Status",
|
||||
value=f"✅ Found as TV show in Jellyseerr: {tv_title}",
|
||||
inline=False
|
||||
)
|
||||
except:
|
||||
embed.add_field(
|
||||
name="Jellyseerr TV Status",
|
||||
value="❌ Not found as TV show in Jellyseerr",
|
||||
inline=False
|
||||
)
|
||||
else: # No correct type identified
|
||||
# Try both if we couldn't determine the type
|
||||
tv_success = movie_success = False
|
||||
try:
|
||||
movie_details = await api.get_movie_details(media_id)
|
||||
if movie_details.get('title'):
|
||||
embed.add_field(
|
||||
name="Jellyseerr Status (Unexpected)",
|
||||
value=f"✅ Found as MOVIE in Jellyseerr: {movie_details.get('title')}",
|
||||
inline=False
|
||||
)
|
||||
movie_success = True
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
tv_details = await api.get_tv_details(media_id)
|
||||
if tv_details.get('name'):
|
||||
embed.add_field(
|
||||
name="Jellyseerr Status (Unexpected)",
|
||||
value=f"✅ Found as TV SHOW in Jellyseerr: {tv_details.get('name')}",
|
||||
inline=False
|
||||
)
|
||||
tv_success = True
|
||||
except:
|
||||
pass
|
||||
|
||||
if not tv_success and not movie_success:
|
||||
embed.add_field(
|
||||
name="Jellyseerr Status",
|
||||
value="❌ Not found in Jellyseerr (neither as movie nor TV show)",
|
||||
inline=False
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
embed.add_field(
|
||||
name="Jellyseerr Status",
|
||||
value=f"❌ Error retrieving from Jellyseerr: {str(e)}",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Add recommendations
|
||||
if not correct_type:
|
||||
embed.add_field(
|
||||
name="Recommendation",
|
||||
value="This media ID doesn't exist in TMDB. Try searching for the correct ID with:\n"
|
||||
f"`{config.BOT_PREFIX}search movie name` or `{config.BOT_PREFIX}search tv name`",
|
||||
inline=False
|
||||
)
|
||||
elif "Error retrieving" in ''.join([f.value for f in embed.fields if hasattr(f, 'value')]):
|
||||
embed.add_field(
|
||||
name="Recommendation",
|
||||
value="The media exists in TMDB but your Jellyseerr account cannot access it. This could be due to:\n"
|
||||
"• Permission settings in your Jellyseerr account\n"
|
||||
"• Content filtering in Jellyseerr\n"
|
||||
"• The media may be excluded from your Jellyseerr instance",
|
||||
inline=False
|
||||
)
|
||||
elif correct_type == "movie":
|
||||
embed.add_field(
|
||||
name="Command to Use",
|
||||
value=f"Use `{config.BOT_PREFIX}movie {media_id}` to get details\n"
|
||||
f"Use `{config.BOT_PREFIX}request movie {media_id}` to request it",
|
||||
inline=False
|
||||
)
|
||||
elif correct_type == "tv":
|
||||
embed.add_field(
|
||||
name="Command to Use",
|
||||
value=f"Use `{config.BOT_PREFIX}tv {media_id}` to get details\n"
|
||||
f"Use `{config.BOT_PREFIX}request tv {media_id}` to request it",
|
||||
inline=False
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in test_media command: {str(e)}", exc_info=True)
|
||||
await ctx.send(f"Error testing media: {str(e)}")
|
||||
|
||||
@commands.command(name="status")
|
||||
async def get_status(self, ctx):
|
||||
"""Check Jellyseerr server status"""
|
||||
async with ctx.typing():
|
||||
try:
|
||||
api = get_api()
|
||||
status_data = await api._request('GET', '/status')
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Jellyseerr Server Status",
|
||||
color=self.embed_color
|
||||
)
|
||||
|
||||
version = status_data.get('version', 'Unknown')
|
||||
update_available = status_data.get('updateAvailable', False)
|
||||
commits_behind = status_data.get('commitsBehind', 0)
|
||||
|
||||
embed.add_field(name="Version", value=version, inline=True)
|
||||
embed.add_field(name="Update Available", value="Yes" if update_available else "No", inline=True)
|
||||
|
||||
if update_available:
|
||||
embed.add_field(name="Commits Behind", value=str(commits_behind), inline=True)
|
||||
|
||||
embed.add_field(name="Server URL", value=config.JELLYSEERR_URL, inline=False)
|
||||
embed.add_field(name="Bot Account", value=config.JELLYSEERR_EMAIL, inline=False)
|
||||
|
||||
# Check if authenticated
|
||||
try:
|
||||
api = get_api()
|
||||
if api and api.last_login:
|
||||
embed.add_field(name="Authentication", value="✅ Authenticated", inline=True)
|
||||
else:
|
||||
embed.add_field(name="Authentication", value="❌ Not authenticated", inline=True)
|
||||
except:
|
||||
embed.add_field(name="Authentication", value="❌ Not authenticated", inline=True)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.send(f"Error checking server status: {str(e)}")
|
Loading…
x
Reference in New Issue
Block a user