First working version of jellyseerr discord bot

This commit is contained in:
Lord Of Nougate 2025-05-25 16:05:57 +02:00
commit 1748432ddb
12 changed files with 9249 additions and 0 deletions

36
.env.example Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

322
jellyseerr_api.py Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
[tool.pyright]
venvPath = "."
venv = ".venv"

7
requirements.txt Normal file
View 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
View 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
View 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)}")