@@ -7,6 +7,7 @@ import os
from dotenv import load_dotenv
from dotenv import load_dotenv
import pytz
import pytz
import random
import random
import qbittorrentapi
# =====================
# =====================
# ENV + VALIDATION
# ENV + VALIDATION
@@ -37,6 +38,15 @@ JELLYSEERR_ENABLED = os.getenv("JELLYSEERR_ENABLED", "false").lower() == "true"
JELLYSEERR_URL = os . getenv ( " JELLYSEERR_URL " , " " ) . rstrip ( " / " )
JELLYSEERR_URL = os . getenv ( " JELLYSEERR_URL " , " " ) . rstrip ( " / " )
JELLYSEERR_API_KEY = os . getenv ( " JELLYSEERR_API_KEY " , " " )
JELLYSEERR_API_KEY = os . getenv ( " JELLYSEERR_API_KEY " , " " )
ENABLE_JFA = os . getenv ( " ENABLE_JFA " , " False " ) . lower ( ) == " true "
JFA_URL = os . getenv ( " JFA_URL " )
JFA_API_KEY = os . getenv ( " JFA_API_KEY " )
ENABLE_QBITTORRENT = os . getenv ( " ENABLE_QBITTORRENT " , " False " ) . lower ( ) == " true "
QBIT_HOST = os . getenv ( " QBIT_HOST " )
QBIT_USERNAME = os . getenv ( " QBIT_USERNAME " )
QBIT_PASSWORD = os . getenv ( " QBIT_PASSWORD " )
DB_HOST = get_env_var ( " DB_HOST " )
DB_HOST = get_env_var ( " DB_HOST " )
DB_USER = get_env_var ( " DB_USER " )
DB_USER = get_env_var ( " DB_USER " )
DB_PASSWORD = get_env_var ( " DB_PASSWORD " )
DB_PASSWORD = get_env_var ( " DB_PASSWORD " )
@@ -44,7 +54,7 @@ DB_NAME = get_env_var("DB_NAME")
LOCAL_TZ = pytz . timezone ( get_env_var ( " LOCAL_TZ " , str , required = False ) or " America/Chicago " )
LOCAL_TZ = pytz . timezone ( get_env_var ( " LOCAL_TZ " , str , required = False ) or " America/Chicago " )
BOT_VERSION = " 1.0.5 "
BOT_VERSION = " 1.0.6 "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
RELEASES_URL = " https://github.com/PenguCCN/Jellycord/releases "
RELEASES_URL = " https://github.com/PenguCCN/Jellycord/releases "
@@ -67,6 +77,20 @@ intents.members = True
intents . message_content = True
intents . message_content = True
bot = commands . Bot ( command_prefix = PREFIX , intents = intents , help_command = None )
bot = commands . Bot ( command_prefix = PREFIX , intents = intents , help_command = None )
# =====================
# QBITTORRENT SETUP
# =====================
qb = qbittorrentapi . Client (
host = QBIT_HOST ,
username = QBIT_USERNAME ,
password = QBIT_PASSWORD
)
try :
qb . auth_log_in ( )
except qbittorrentapi . LoginFailed :
print ( " Failed to log in to qBittorrent API " )
# =====================
# =====================
# DATABASE SETUP
# DATABASE SETUP
# =====================
# =====================
@@ -313,6 +337,16 @@ def delete_jellyseerr_user(js_id: str) -> bool:
except Exception as e :
except Exception as e :
print ( f " [Jellyseerr] Failed to delete user { js_id } : { e } " )
print ( f " [Jellyseerr] Failed to delete user { js_id } : { e } " )
return False
return False
# =====================
# QBITTORRENT HELPERS
# =====================
def progress_bar ( progress : float , length : int = 20 ) - > str :
""" Return a textual progress bar for the embed. """
filled_length = int ( length * progress )
bar = ' █ ' * filled_length + ' ░ ' * ( length - filled_length )
return f " [ { bar } ] { progress * 100 : .2f } % "
# =====================
# =====================
# DISCORD HELPERS
# DISCORD HELPERS
@@ -481,6 +515,243 @@ async def createaccount(ctx, username: str = None, password: str = None):
else :
else :
await ctx . send ( f " ❌ Failed to create Jellyfin account ** { username } **. It may already exist. " )
await ctx . send ( f " ❌ Failed to create Jellyfin account ** { username } **. It may already exist. " )
@bot.command ( )
async def createinvite ( ctx ) :
""" Admin-only: Create a new JFA-Go invite link (create -> fetch latest invite). """
if not ENABLE_JFA :
await ctx . send ( " ❌ JFA support is not enabled in the bot configuration. " )
return
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
try :
payload = { " days " : 7 , " max_uses " : 1 }
base = JFA_URL . rstrip ( " / " )
# Try Bearer, fallback to X-Api-Key
headers = { " Authorization " : f " Bearer { JFA_API_KEY } " }
r = requests . post ( f " { base } /invites " , headers = headers , json = payload , timeout = 10 )
if r . status_code == 401 :
headers = { " X-Api-Key " : JFA_API_KEY }
r = requests . post ( f " { base } /invites " , headers = headers , json = payload , timeout = 10 )
if r . status_code not in ( 200 , 201 ) :
await ctx . send ( f " ❌ Failed to create invite. Status code: { r . status_code } \n Response: { r . text } " )
return
# Fetch invites list (some JFA builds only return success on POST)
r2 = requests . get ( f " { base } /invites " , headers = headers , timeout = 10 )
if r2 . status_code not in ( 200 , 201 ) :
await ctx . send ( f " ❌ Failed to fetch invite list. Status code: { r2 . status_code } \n Response: { r2 . text } " )
return
invites_resp = r2 . json ( )
# Normalize different shapes: either {'invites': [...]} or a list
if isinstance ( invites_resp , dict ) and " invites " in invites_resp :
invites_list = invites_resp [ " invites " ]
elif isinstance ( invites_resp , list ) :
invites_list = invites_resp
else :
# unexpected shape
print ( f " [createinvite] Unexpected invites response shape: { invites_resp } " )
await ctx . send ( " ❌ Unexpected response from JFA when fetching invites. Check bot logs. " )
return
if not invites_list :
await ctx . send ( " ❌ No invites found after creation. " )
return
latest = invites_list [ - 1 ] # assume newest is last; adjust if your JFA sorts differently
print ( f " [createinvite] Latest invite object: { latest } " ) # debug log
code = latest . get ( " code " ) or latest . get ( " id " ) or latest . get ( " token " )
url = latest . get ( " url " ) or latest . get ( " link " )
if not url and code :
# Common invite URL pattern; adjust if your instance is different
url = f " { base } /invite/ { code } "
# created: JFA gives epoch seconds in 'created'
created_local_str = None
created_ts = latest . get ( " created " )
if created_ts :
try :
created_dt = datetime . datetime . utcfromtimestamp ( int ( created_ts ) ) . replace ( tzinfo = datetime . timezone . utc )
created_local = created_dt . astimezone ( LOCAL_TZ )
created_local_str = created_local . strftime ( " % Y- % m- %d % H: % M: % S % Z " )
except Exception :
created_local_str = None
remaining = latest . get ( " remaining-uses " , " N/A " )
embed = discord . Embed (
title = " 🎟️ New Jellyfin Invite Created " ,
color = discord . Color . blue ( )
)
embed . add_field ( name = " Code " , value = f " ` { code } ` " if code else " N/A " , inline = True )
embed . add_field ( name = " Link " , value = f " [Click here]( { url } ) " if url else " N/A " , inline = True )
footer_parts = [ ]
if created_local_str :
footer_parts . append ( f " Created: { created_local_str } " )
footer_parts . append ( f " Remaining uses: { remaining } " )
embed . set_footer ( text = " • " . join ( footer_parts ) )
embed . set_author ( name = f " Created by { ctx . author . display_name } " , icon_url = ctx . author . avatar . url if ctx . author . avatar else None )
await ctx . send ( embed = embed )
except Exception as e :
await ctx . send ( f " ❌ Error creating invite: { e } " )
print ( f " [createinvite] Error: { e } " , exc_info = True )
@bot.command ( )
async def listinvites ( ctx ) :
""" Admin-only: List all active JFA-Go invites. """
if not ENABLE_JFA :
await ctx . send ( " ❌ JFA support is not enabled in the bot configuration. " )
return
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
try :
base = JFA_URL . rstrip ( " / " )
headers = { " Authorization " : f " Bearer { JFA_API_KEY } " }
r = requests . get ( f " { base } /invites " , headers = headers , timeout = 10 )
if r . status_code == 401 :
headers = { " X-Api-Key " : JFA_API_KEY }
r = requests . get ( f " { base } /invites " , headers = headers , timeout = 10 )
if r . status_code not in ( 200 , 201 ) :
await ctx . send ( f " ❌ Failed to fetch invites. Status code: { r . status_code } \n Response: { r . text } " )
return
invites_resp = r . json ( )
print ( f " [listinvites] Raw response: { invites_resp } " ) # Debug
# Normalize to a list of invite dicts
if isinstance ( invites_resp , dict ) and " invites " in invites_resp :
invites_list = invites_resp [ " invites " ]
elif isinstance ( invites_resp , list ) :
invites_list = invites_resp
else :
await ctx . send ( " ❌ Unexpected invite response format. Check logs. " )
return
if not invites_list :
await ctx . send ( " ℹ ️ No active invites found." )
return
embed = discord . Embed (
title = " 📋 Active Jellyfin Invites " ,
color = discord . Color . green ( )
)
for invite in invites_list :
code = invite . get ( " code " )
url = f " { base } /invite/ { code } " if code else None
remaining = invite . get ( " remaining-uses " , " N/A " )
created_str = None
created_ts = invite . get ( " created " )
if created_ts :
try :
created_dt = datetime . datetime . utcfromtimestamp ( int ( created_ts ) ) . replace ( tzinfo = datetime . timezone . utc )
created_local = created_dt . astimezone ( LOCAL_TZ )
created_str = created_local . strftime ( " % Y- % m- %d % H: % M: % S % Z " )
except Exception :
created_str = None
value = f " Uses left: { remaining } "
if url :
value + = f " \n [Invite Link]( { url } ) "
if created_str :
value + = f " \n Created: { created_str } "
embed . add_field (
name = f " 🔑 { code } " ,
value = value ,
inline = False
)
await ctx . send ( embed = embed )
except Exception as e :
await ctx . send ( f " ❌ Error fetching invites: { e } " )
print ( f " [listinvites] Error: { e } " , exc_info = True )
@bot.command ( )
async def deleteinvite ( ctx , code : str ) :
""" Admin-only: Delete a specific JFA-Go invite by code. """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
try :
base = JFA_URL . rstrip ( " / " )
headers = { " Authorization " : f " Bearer { JFA_API_KEY } " }
# Try DELETE with body (legacy API)
r = requests . delete ( f " { base } /invites " , headers = headers , json = { " code " : code } , timeout = 10 )
if r . status_code == 401 :
headers = { " X-Api-Key " : JFA_API_KEY }
r = requests . delete ( f " { base } /invites " , headers = headers , json = { " code " : code } , timeout = 10 )
if r . status_code in ( 200 , 204 ) :
await ctx . send ( f " ✅ Invite ` { code } ` has been deleted. " )
else :
await ctx . send ( f " ❌ Failed to delete invite ` { code } `. Status code: { r . status_code } \n Response: { r . text } " )
except Exception as e :
await ctx . send ( f " ❌ Error deleting invite: { e } " )
print ( f " [deleteinvite] Error: { e } " , exc_info = True )
@bot.command ( )
async def clearinvites ( ctx ) :
""" Admin-only: Delete ALL JFA-Go invites (use with caution!). """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
try :
base = JFA_URL . rstrip ( " / " )
headers = { " Authorization " : f " Bearer { JFA_API_KEY } " }
r = requests . get ( f " { base } /invites " , headers = headers , timeout = 10 )
if r . status_code == 401 :
headers = { " X-Api-Key " : JFA_API_KEY }
r = requests . get ( f " { base } /invites " , headers = headers , timeout = 10 )
if r . status_code not in ( 200 , 201 ) :
await ctx . send ( f " ❌ Failed to fetch invites. Status code: { r . status_code } \n Response: { r . text } " )
return
invites_resp = r . json ( )
invites_list = invites_resp [ " invites " ] if isinstance ( invites_resp , dict ) and " invites " in invites_resp else invites_resp
if not invites_list :
await ctx . send ( " ℹ ️ No invites to delete." )
return
deleted = 0
for invite in invites_list :
code = invite . get ( " code " )
if not code :
continue
dr = requests . delete ( f " { base } /invites " , headers = headers , json = { " code " : code } , timeout = 10 )
if dr . status_code in ( 200 , 204 ) :
deleted + = 1
await ctx . send ( f " ✅ Deleted { deleted } invites. " )
except Exception as e :
await ctx . send ( f " ❌ Error clearing invites: { e } " )
print ( f " [clearinvites] Error: { e } " , exc_info = True )
@bot.command ( )
@bot.command ( )
async def trialaccount ( ctx , username : str = None , password : str = None ) :
async def trialaccount ( ctx , username : str = None , password : str = None ) :
""" Create a 24-hour trial Jellyfin account. DM-only, one-time per user. """
""" Create a 24-hour trial Jellyfin account. DM-only, one-time per user. """
@@ -830,7 +1101,7 @@ async def scanlibraries(ctx):
@bot.command ( )
@bot.command ( )
async def activestreams ( ctx ) :
async def activestreams ( ctx ) :
""" Admin-only: Show currently active Jellyfin user streams (movies/episodes only) with progress. """
""" Admin-only: Show currently active Jellyfin user streams (movies/episodes only) with progress bar . """
if not has_admin_role ( ctx . author ) :
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
return
@@ -869,13 +1140,20 @@ async def activestreams(ctx):
# Get progress
# Get progress
try :
try :
position_ticks = session . get ( " PlayState " , { } ) . get ( " PositionTicks " , 0 )
position_ticks = session . get ( " PlayState " , { } ) . get ( " PositionTicks " , 0 )
runtime_ticks = media . get ( " RunTimeTicks " , 1 ) # fallback to avoid division by zero
runtime_ticks = media . get ( " RunTimeTicks " , 1 ) # avoid div by zero
# Convert ticks to seconds (1 tick = 100 ns)
position_seconds = position_ticks / 10_000_000
position_seconds = position_ticks / 10_000_000
runtime_seconds = runtime_ticks / 10_000_000
runtime_seconds = runtime_ticks / 10_000_000
position_str = str ( datetime . timedelta ( seconds = int ( position_seconds ) ) )
position_str = str ( datetime . timedelta ( seconds = int ( position_seconds ) ) )
runtime_str = str ( datetime . timedelta ( seconds = int ( runtime_seconds ) ) )
runtime_str = str ( datetime . timedelta ( seconds = int ( runtime_seconds ) ) )
progress_str = f " [ { position_str } / { runtime_str } ] "
# Progress bar
percent = position_seconds / runtime_seconds if runtime_seconds > 0 else 0
bar_length = 10
filled_length = int ( round ( bar_length * percent ) )
bar = " ■ " * filled_length + " □ " * ( bar_length - filled_length )
progress_str = f " { bar } { int ( percent * 100 ) } % \n [ { position_str } / { runtime_str } ] "
except Exception :
except Exception :
progress_str = " Unknown "
progress_str = " Unknown "
@@ -891,6 +1169,61 @@ async def activestreams(ctx):
await ctx . send ( f " ❌ Error fetching active streams: { e } " )
await ctx . send ( f " ❌ Error fetching active streams: { e } " )
print ( f " [activestreams] Error: { e } " )
print ( f " [activestreams] Error: { e } " )
@bot.command ( )
async def qbview ( ctx ) :
""" Admin-only: View current qBittorrent downloads. """
if not ENABLE_QBITTORRENT :
await ctx . send ( " ❌ qBittorrent support is not enabled in the bot configuration. " )
return
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
torrents = qb . torrents_info ( )
embed = discord . Embed ( title = " qBittorrent Downloads " , color = 0x00ff00 )
if not torrents :
embed . description = " No torrents found. "
await ctx . send ( embed = embed )
return
# Group torrents by state
state_groups = {
" Downloading / Uploading " : [ ] ,
" Finished " : [ ] ,
" Stalled " : [ ] ,
" Checking / Metadata " : [ ] ,
" Other " : [ ]
}
for t in torrents :
if t . state in ( " downloading " , " uploading " ) :
state_groups [ " Downloading / Uploading " ] . append ( t )
elif t . state in ( " completed " , " pausedUP " , " pausedDL " ) :
state_groups [ " Finished " ] . append ( t )
elif t . state in ( " stalledUP " , " stalledDL " ) :
state_groups [ " Stalled " ] . append ( t )
elif t . state in ( " checkingUP " , " checkingDL " , " checking " , " metaDL " ) :
state_groups [ " Checking / Metadata " ] . append ( t )
else :
state_groups [ " Other " ] . append ( t )
# Add torrents to embed
for group_name , torrents_list in state_groups . items ( ) :
if torrents_list :
value_text = " "
for torrent in torrents_list :
value_text + = (
f " { torrent . name } \n "
f " { progress_bar ( torrent . progress ) } \n "
f " Peers: { torrent . num_leechs } | Seeders: { torrent . num_seeds } \n "
f " Status: { torrent . state } \n \n "
)
embed . add_field ( name = group_name , value = value_text , inline = False )
await ctx . send ( embed = embed )
@bot.command ( )
@bot.command ( )
async def link ( ctx , jellyfin_username : str = None , user : discord . User = None , js_id : str = None ) :
async def link ( ctx , jellyfin_username : str = None , user : discord . User = None , js_id : str = None ) :
@@ -1027,47 +1360,71 @@ async def help_command(ctx):
color = discord . Color . blue ( )
color = discord . Color . blue ( )
)
)
# User c ommands
# --- Jellyfin User C ommands ---
user_cmds = [
user_cmds = [
f " ` { PREFIX } createaccount <username> <password>` - Create your Jellyfin account " ,
f " ` { PREFIX } createaccount <username> <password>` - Create your Jellyfin account " ,
f " ` { PREFIX } recoveraccount <newpassword>` - Reset your password " ,
f " ` { PREFIX } recoveraccount <newpassword>` - Reset your password " ,
f " ` { PREFIX } deleteaccount <username>` - Delete your Jellyfin account "
f " ` { PREFIX } deleteaccount <username>` - Delete your Jellyfin account " ,
f " ` { PREFIX } what2watch` - Lists 5 random movie suggestions from the Jellyfin Library "
f " ` { PREFIX } what2watch` - Lists 5 random movie suggestions from the Jellyfin Library "
]
]
# Only show trialaccount if enabled
if ENABLE_TRIAL_ACCOUNTS :
if ENABLE_TRIAL_ACCOUNTS :
user_cmds . append ( f " ` { PREFIX } trialaccount <username> <password>` - Create a 24-hour trial Jellyfin account " )
user_cmds . append ( f " ` { PREFIX } trialaccount <username> <password>` - Create a 24-hour trial Jellyfin account " )
embed . add_field ( name = " 🎬 Jellyfin Commands " , value = " \n " . join ( user_cmds ) , inline = False )
embed . add_field ( name = " User Commands " , value = " \n " . join ( user_cmds ) , inline = False )
# --- Bot Commands ---
bot_cmds = [
f " ` { PREFIX } help` - Show this help message "
]
embed . add_field ( name = " 🤖 Bot Commands " , value = " \n " . join ( bot_cmds ) , inline = False )
# Admin c ommands
# --- Admin C ommands ---
if is_admin :
if is_admin :
# Dynamic l ink command line
# Admin Jellyf in commands
link_command = f " ` { PREFIX } link <jellyfin_username> @user` - Manually link accounts "
link_command = f " ` { PREFIX } link <jellyfin_username> @user` - Manually link accounts "
if JELLYSEERR_ENABLED :
if JELLYSEERR_ENABLED :
link_command = f " ` { PREFIX } link <jellyfin_username> @user <Jellyseerr ID>` - Manually l ink accounts with Jellyseerr "
link_command = f " ` { PREFIX } link <jellyfin_username> @user <Jellyseerr ID>` - L ink accounts with Jellyseerr "
embed . add_field ( name = " Admin Commands " , value = (
admin_cmds = [
f " ` { PREFIX } cleanup` - Remove Jellyfin accounts from users without roles \n "
link_command ,
f " ` { PREFIX } listvalid users ` - Show number of valid and invalid accounts\n "
f " ` { PREFIX } unlink @ user` - Manually unlink accounts",
f " ` { PREFIX } la stcleanup` - See Last cleanup time, and time remaining before next cleanup \n "
f " ` { PREFIX } li stvalidusers` - Show number of valid and invalid accounts " ,
f " ` { PREFIX } searchaccount <jellyfin_username>` - Find linked Discord user \n "
f " ` { PREFIX } cleanup` - Remove Jellyfin accounts from users without roles " ,
f " ` { PREFIX } searchdiscord @user` - Find linked Jellyfin account \n "
f " ` { PREFIX } lastcleanup` - See last cleanup time and time remaining " ,
f " ` { PREFIX } scanlibraries` - Scan all Jellyfin libraries \n "
f " ` { PREFIX } searchaccount <jellyfin_username>` - Find linked Discord user " ,
f " ` { PREFIX } activestreams` - View all Active Jellyfin streams \n "
f " ` { PREFIX } searchdiscord @user` - Find linked Jellyfin account " ,
f " { link_command } \n "
f " ` { PREFIX } scanlibraries` - Scan all Jellyfin libraries " ,
f " ` { PREFIX } unlink @user` - Manually unlink accounts \n "
f " ` { PREFIX } activestreams` - View all active Jellyfin streams "
) , inline = False )
]
embed . add_field ( name = " 🛠️ Admin Commands " , value = " \n " . join ( admin_cmds ) , inline = False )
embed . add_field ( name = " Admin Bot Commands " , value = (
# --- qBittorrent Commands ---
f " ` { PREFIX } setprefix` - Change the bot ' s command prefix \n "
if ENABLE_QBITTORRENT :
f " ` { PREFIX } updates` - Manually check for bot updates \n "
qb_cmds = [
f " ` { PREFIX } logging` - Enable/Disable Console Event Logging \n "
f " ` { PREFIX } qbview` - Show current qBittorrent downloads with progress, peers, and seeders " ,
) , inline = False )
]
embed . add_field ( name = " 💾 qBittorrent Commands " , value = " \n " . join ( qb_cmds ) , inline = False )
# --- JFA Commands ---
if ENABLE_JFA :
jfa_cmds = [
f " ` { PREFIX } createinvite` - Create a new JFA invite link " ,
f " ` { PREFIX } listinvites` - List all active JFA invite links " ,
f " ` { PREFIX } deleteinvite <code>` - Delete a specific JFA invite "
]
embed . add_field ( name = " 🔑 JFA Commands " , value = " \n " . join ( jfa_cmds ) , inline = False )
# Admin Bot commands
admin_bot_cmds = [
f " ` { PREFIX } setprefix` - Change the bot ' s command prefix " ,
f " ` { PREFIX } updates` - Manually check for bot updates " ,
f " ` { PREFIX } logging` - Enable/disable console event logging "
]
embed . add_field ( name = " ⚙️ Admin Bot Commands " , value = " \n " . join ( admin_bot_cmds ) , inline = False )
await ctx . send ( embed = embed )
await ctx . send ( embed = embed )
# =====================
# =====================
# TASKS
# TASKS
# =====================
# =====================