@@ -39,10 +39,19 @@ DB_USER = get_env_var("DB_USER")
DB_PASSWORD = get_env_var ( " DB_PASSWORD " )
DB_NAME = get_env_var ( " DB_NAME " )
BOT_VERSION = " 1.0.1 "
BOT_VERSION = " 1.0.2 "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellyfin-Discord/main/version.txt "
RELEASES_URL = " https://github.com/PenguCCN/Jellyfin-Discord/releases "
# =====================
# EVENT LOGGING
# =====================
EVENT_LOGGING = os . getenv ( " EVENT_LOGGING " , " false " ) . lower ( ) == " true "
def log_event ( message : str ) :
if EVENT_LOGGING :
print ( f " [EVENT] { datetime . datetime . utcnow ( ) . isoformat ( ) } | { message } " )
# =====================
# DISCORD SETUP
# =====================
@@ -55,6 +64,7 @@ bot = commands.Bot(command_prefix=PREFIX, intents=intents, help_command=None)
# DATABASE SETUP
# =====================
def init_db ( ) :
log_event ( f " Initiating Database... " )
# Create database if it doesn't exist
conn = mysql . connector . connect ( host = DB_HOST , user = DB_USER , password = DB_PASSWORD )
cur = conn . cursor ( )
@@ -116,28 +126,29 @@ def add_account(discord_id, username, jf_id, js_id=None):
conn . close ( )
def get_accounts ( ) :
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
)
cur = conn . cursor ( )
cur . execute ( " SELECT discord_id, jellyfin_username FROM accounts " )
cur . execute ( " SELECT discord_id, jellyfin_username, jellyfin_id, jellyseerr_id FROM accounts " )
rows = cur . fetchall ( )
cur . close ( )
conn . close ( )
return rows
def get_account_by_jellyfin ( username ) :
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
)
cur = conn . cursor ( )
cur . execute ( " SELECT discord_id FROM accounts WHERE jellyfin_username= %s " , ( username , ) )
cur . execute ( " SELECT discord_id, jellyfin_id, jellyseerr_id FROM accounts WHERE jellyfin_username= %s " , ( username , ) )
row = cur . fetchone ( )
cur . close ( )
conn . close ( )
return row
return row # (discord_id, jf_id, js_id)
def get_account_by_discord ( discord_id ) :
conn = mysql . connector . connect (
@@ -151,7 +162,7 @@ def get_account_by_discord(discord_id):
row = cur . fetchone ( )
cur . close ( )
conn . close ( )
return row # (jellyfin_username, jellyfin_id, jellyseerr _id)
return row # (jellyfin_username, jf_id, js _id)
def delete_account ( discord_id ) :
@@ -224,26 +235,35 @@ def import_jellyseerr_user(jellyfin_user_id: str) -> str:
print ( f " [Jellyseerr] Failed to import user: { e } " )
return None
def delete_jellyseerr_user ( username : str ) - > bool :
def get_jellyseerr_id ( jf_id : str ) - > str | None :
""" Return the Jellyseerr user ID for a given Jellyfin user ID. """
if not JELLYSEERR_ENABLED :
return None
headers = { " X-Api-Key " : JELLYSEERR_API_KEY }
try :
r = requests . get ( f " { JELLYSEERR_URL } /api/v1/user " , headers = headers , timeout = 10 )
if r . status_code != 200 :
return None
users = r . json ( )
for user in users :
if " jellyfinUserIds " in user and jf_id in user [ " jellyfinUserIds " ] :
return user [ " id " ]
return None
except Exception as e :
print ( f " [Jellyseerr] Failed to fetch user ID for Jellyfin ID { jf_id } : { e } " )
return None
def delete_jellyseerr_user ( js_id : str ) - > bool :
if not JELLYSEERR_ENABLED or not js_id :
return True
headers = { " X-Api-Key " : JELLYSEERR_API_KEY }
try :
# First fetch users to find matching ID
r = requests . get ( f " { JELLYSEERR_URL } /api/v1/user " , headers = headers , timeout = 10 )
if r . status_code != 200 :
return False
users = r . json ( )
for u in users :
if u . get ( " username " , " " ) . lower ( ) == username . lower ( ) :
user_id = u [ " id " ]
dr = requests . delete ( f " { JELLYSEERR_URL } /api/v1/user/ { user_id } " , headers = headers , timeout = 10 )
dr = requests . delete ( f " { JELLYSEERR_URL } /api/v1/user/ { js_id } " , headers = headers , timeout = 10 )
return dr . status_code in ( 200 , 204 )
return True # no user found, nothing to delete
except Exception as e :
print ( f " [Jellyseerr] Failed to delete user { username } : { e } " )
print ( f " [Jellyseerr] Failed to delete user { js_id } : { e } " )
return False
# =====================
@@ -306,20 +326,31 @@ async def on_message(message):
# =====================
# COMMANDS
# =====================
def command_usage ( base : str , args : list [ str ] ) - > str :
""" Return usage message for a command. """
return f " ❌ Usage: ` { base } { ' ' . join ( args ) } ` "
def log_event ( message : str ) :
""" Log events to console if enabled in .env. """
if os . getenv ( " EVENT_LOGGING " , " false " ) . lower ( ) == " true " :
print ( f " [EVENT] { message } " )
@bot.command ( )
async def createaccount ( ctx , username : str , password : str ) :
# DM-only
async def createaccount ( ctx , username : str = None , password : str = None ) :
log_event ( f " createaccount invoked by { ctx . author } " )
if username is None or password is None :
await ctx . send ( command_usage ( f " { PREFIX } createaccount " , [ " <username> " , " <password> " ] ) )
return
if not isinstance ( ctx . channel , discord . DMChannel ) :
try :
await ctx . message . delete ( )
except discord . Forbidden :
pass
try : await ctx . message . delete ( )
except discord . Forbidden : pass
await ctx . send ( f " { ctx . author . mention } ❌ Please DM me to create your Jellyfin account. " )
return
guild = bot . get_guild ( GUILD_ID )
member = guild . get_member ( ctx . author . id ) if guild else None
if not member or not has_required_role ( member ) :
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have the required role. " )
return
@@ -328,7 +359,6 @@ async def createaccount(ctx, username: str, password: str):
await ctx . send ( f " ❌ { ctx . author . mention } , you already have a Jellyfin account. " )
return
# Create Jellyfin user
if create_jellyfin_user ( username , password ) :
jf_id = get_jellyfin_user ( username )
if not jf_id :
@@ -336,24 +366,16 @@ async def createaccount(ctx, username: str, password: str):
return
js_id = None
# Import to Jellyseerr if enabled
if JELLYSEERR_ENABLED :
js_id = import_jellyseerr_user ( jf_id )
# Store account in DB
add_account ( ctx . author . id , username , jf_id , js_id )
if JELLYSEERR_ENABLED :
if js_id :
await ctx . send (
f " ✅ Jellyfin account ** { username } ** created and imported into Jellyseerr! \n "
f " 🌐 Login here: { JELLYFIN_URL } "
)
await ctx . send ( f " ✅ Jellyfin account ** { username } ** created and imported into Jellyseerr! \n 🌐 Login here: { JELLYFIN_URL } " )
else :
await ctx . send (
f " ⚠️ Jellyfin account ** { username } ** created, but Jellyseerr import failed. \n "
f " 🌐 Login here: { JELLYFIN_URL } "
)
await ctx . send ( f " ⚠️ Jellyfin account ** { username } ** created, but Jellyseerr import failed. \n 🌐 Login here: { JELLYFIN_URL } " )
else :
await ctx . send ( f " ✅ Jellyfin account ** { username } ** created! \n 🌐 Login here: { JELLYFIN_URL } " )
else :
@@ -361,64 +383,58 @@ async def createaccount(ctx, username: str, password: str):
@bot.command ( )
async def recoveraccount ( ctx , new_password : str ) :
""" DM-only: reset your Jellyfin password """
# Ensure it's a DM
async def recoveraccount ( ctx , new_password : str = None ) :
log_event ( f " recoveraccount invoked by { ctx . author } " )
if new_password is None :
await ctx . send ( command_usage ( f " { PREFIX } recoveraccount " , [ " <newpassword> " ] ) )
return
if not isinstance ( ctx . channel , discord . DMChannel ) :
await ctx . message . delete ( )
await ctx . send ( f " { ctx . author . mention } Please DM me to reset your password. " )
return
# Fetch the Jellyfin account linked to this Discord user
acc = get_account_by_discord ( ctx . author . id )
if not acc :
await ctx . send ( " ❌ You do not have a linked Jellyfin account. " )
return
username = acc [ 0 ] # the Jellyfin username
# Reset the password
username = acc [ 0 ]
if reset_jellyfin_password ( username , new_password ) :
await ctx . send (
f " ✅ Your Jellyfin password for ** { username } ** has been reset! \n "
f " 🌐 Login here: { JELLYFIN_URL } "
)
await ctx . send ( f " ✅ Your Jellyfin password for ** { username } ** has been reset! \n 🌐 Login here: { JELLYFIN_URL } " )
else :
await ctx . send ( f " ❌ Failed to reset password for ** { username } **. Please contact an admin. " )
@bot.command ( )
async def deleteaccount ( ctx , username : str ) :
async def deleteaccount ( ctx , username : str = None ) :
log_event ( f " deleteaccount invoked by { ctx . author } " )
if username is None :
await ctx . send ( command_usage ( f " { PREFIX } deleteaccount " , [ " <username> " ] ) )
return
if not isinstance ( ctx . channel , discord . DMChannel ) :
try :
await ctx . message . delete ( )
except discord . Forbidden :
pass
try : await ctx . message . delete ( )
except discord . Forbidden : pass
await ctx . send ( f " { ctx . author . mention } ❌ Please DM me to delete your Jellyfin account. " )
return
# Fetch account linked to this Discord user
acc = get_account_by_discord ( ctx . author . id )
if not acc or acc [ 0 ] . lower ( ) != username . lower ( ) :
await ctx . send ( f " ❌ { ctx . author . mention } , that Jellyfin account is not linked to you. " )
return
jf_id = acc [ 1 ] # Jellyfin ID
js_id = acc [ 2 ] if len ( acc ) > 2 else None # Jellyseerr ID
jf_id , js_id = acc [ 1 ] , acc [ 2 ] if len ( acc ) > 2 else None
# Delete Jellyfin account
if delete_jellyfin_user ( username ) :
delete_account ( ctx . author . id )
# Delete Jellyseerr user if enabled
if JELLYSEERR_ENABLED and js_id :
try :
headers = { " X-Api-Key " : JELLYSEERR_API_KEY }
dr = requests . delete ( f " { JELLYSEERR_URL } /api/v1/user/ { js_id } " , headers = headers , timeout = 10 )
if dr . status_code in ( 200 , 204 ) :
print ( f " [Jellyseerr] User { js_id } removed successfully. " )
if dr . status_code in ( 200 , 204 ) : print ( f " [Jellyseerr] User { js_id } removed successfully. " )
except Exception as e :
print ( f " [Jellyseerr] Failed to delete user { js_id } : { e } " )
await ctx . send ( f " ✅ Jellyfin account ** { username } ** deleted successfully. " )
else :
await ctx . send ( f " ❌ Failed to delete Jellyfin account ** { username } **. " )
@@ -426,6 +442,7 @@ async def deleteaccount(ctx, username: str):
@bot.command ( )
async def cleanup ( ctx ) :
log_event ( f " cleanup invoked by { ctx . author } " )
guild = bot . get_guild ( GUILD_ID )
removed = [ ]
for discord_id , jf_username in get_accounts ( ) :
@@ -441,8 +458,10 @@ async def cleanup(ctx):
await ctx . send ( " ✅ Cleanup complete. " )
@bot.command ( )
async def lastcleanup ( ctx ) :
log_event ( f " lastcleanup invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( member ) :
await ctx . send ( " ❌ You don’ t have permission to view the last cleanup. " )
@@ -461,17 +480,14 @@ async def lastcleanup(ctx):
hours , remainder = divmod ( int ( time_remaining . total_seconds ( ) ) , 3600 )
minutes , seconds = divmod ( remainder , 60 )
await ctx . send (
f " 🧹 Last cleanup ran at ** { last_run_dt . strftime ( ' % Y- % m- %d % H: % M: % S ' ) } UTC** \n "
f " ⏳ Time until next cleanup: { hours } h { minutes } m { seconds } s "
)
await ctx . send ( f " 🧹 Last cleanup ran at ** { last_run_dt . strftime ( ' % Y- % m- %d % H: % M: % S ' ) } UTC** \n ⏳ Time until next cleanup: { hours } h { minutes } m { seconds } s " )
@bot.command ( )
async def searchaccount ( ctx , username : str ) :
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( member ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
async def searchaccount ( ctx , username : str = None ) :
log_event ( f " searchaccount invoked by { ctx . author } " )
if username is None :
await ctx . send ( command_usage ( f " { PREFIX } searchaccount " , [ " <jellyfin_username> " ] ) )
return
result = get_account_by_jellyfin ( username )
@@ -482,11 +498,12 @@ async def searchaccount(ctx, username: str):
else :
await ctx . send ( " ❌ No linked Discord user found for that Jellyfin account. " )
@bot.command ( )
async def searchdiscord ( ctx , user : discord . User ) :
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( member ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
async def searchdiscord ( ctx , user : discord . User = None ) :
log_event ( f " searchdiscord invoked by { ctx . author } " )
if user is None :
await ctx . send ( command_usage ( f " { PREFIX } searchdiscord " , [ " @user " ] ) )
return
result = get_account_by_discord ( user . id )
@@ -495,52 +512,69 @@ async def searchdiscord(ctx, user: discord.User):
else :
await ctx . send ( " ❌ That Discord user does not have a linked Jellyfin account. " )
@bot.command ( )
async def scanlibraries ( ctx ) :
log_event ( f " scanlibraries invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( member ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
headers = { " X-Emby-Token " : JELLYFIN_API_KEY }
response = requests . post ( f " { JELLYFIN_URL } /Library/Refresh " , headers = headers )
response = requests . post ( f " { JELLYFIN_URL } /Library/Refresh " , headers= { " X-Emby-Token " : JELLYFIN_API_KEY } )
if response . status_code in ( 200 , 204 ) :
await ctx . send ( " ✅ All Jellyfin libraries are being scanned. " )
else :
await ctx . send ( f " ❌ Failed to start library scan. Status code: { response . status_code } " )
@bot.command ( )
async def link ( ctx , jellyfin_username : str , user : discord . User ) :
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( member ) :
await ctx . s end( " ❌ You don’ t have permission to use this command. " )
async def link ( ctx , jellyfin_username : str = None , user : discord . User = None , js_id : str = None ) :
log_event ( f " link invoked by { ctx . author } " )
usage_args = [ " <Jellyfin Account> " , " <@user> " ]
if JELLYSEERR_ENABLED : usage_args . app end( " <Jellyseerr ID> " )
if jellyfin_username is None or user is None or ( JELLYSEERR_ENABLED and js_id is None ) :
await ctx . send ( command_usage ( f " { PREFIX } link " , usage_args ) )
return
add_account ( user . id , jellyfin_username )
existing_acc = get_account_by_discord ( user . id )
if existing_acc :
await ctx . send ( f " ❌ Discord user { user . mention } already has a linked account. " )
return
jf_id = get_jellyfin_user ( jellyfin_username )
if not jf_id :
await ctx . send ( f " ❌ Could not find Jellyfin account ** { jellyfin_username } **. Make sure it exists. " )
return
add_account ( user . id , jellyfin_username , jf_id , js_id )
await ctx . send ( f " ✅ Linked Jellyfin account ** { jellyfin_username } ** to { user . mention } . " )
@bot.command ( )
async def unlink ( ctx , discord_user : discord . User ) :
""" Admin-only: unlink a Jellyfin account from a Discord user (without deleting the account) """
guild = ctx . guild
member = guild . get_member ( ctx . author . id ) if guild else None
if not member or not has_admin_role ( member ) :
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have permission to use this command. " )
@bot.command ( )
async def unlink ( ctx , discord_user : discord . User = None ) :
log_event ( f " unlink invoked by { ctx . author } " )
if discord_user is None :
await ctx . send ( command_usage ( f " { PREFIX } unlink " , [ " @user " ] ) )
return
# Check if the Discord user has a linked Jellyfin account
account = get_account_by_discord ( discord_user . id )
if not account :
await ctx . send ( f " ❌ Discord user { discord_user . mention } does not have a linked Jellyfin account. " )
return
# Remove the database entry
delete_account ( discord_user . id )
await ctx . send ( f " ✅ Unlinked Jellyfin account ** { account [ 0 ] } ** from Discord user { discord_user . mention } . " )
@bot.command ( )
async def setprefix ( ctx , new_prefix : str ) :
async def setprefix ( ctx , new_prefix : str = None ) :
log_event ( f " setprefix invoked by { ctx . author } " )
if new_prefix is None :
await ctx . send ( command_usage ( f " { PREFIX } setprefix " , [ " <symbol> " ] ) )
return
member = ctx . guild . get_member ( ctx . author . id )
if not member or not has_admin_role ( member ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
@@ -550,12 +584,9 @@ async def setprefix(ctx, new_prefix: str):
await ctx . send ( " ❌ Prefix must be a single non-alphanumeric symbol (e.g. !, $, % , ?) " )
return
# Update prefix
global PREFIX
PREFIX = new_prefix
bot . command_prefix = PREFIX
# Write to .env
lines = [ ]
with open ( " .env " , " r " ) as f :
for line in f :
@@ -563,13 +594,14 @@ async def setprefix(ctx, new_prefix: str):
lines . append ( f " PREFIX= { new_prefix } \n " )
else :
lines . append ( line )
with open ( " .env " , " w " ) as f :
f . writelines ( lines )
with open ( " .env " , " w " ) as f : f . writelines ( lines )
await ctx . send ( f " ✅ Command prefix updated to ` { new_prefix } ` " )
@bot.command ( )
async def updates ( ctx ) :
log_event ( f " updates invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( member ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
@@ -579,20 +611,49 @@ async def updates(ctx):
response = requests . get ( VERSION_URL , timeout = 10 )
if response . status_code == 200 :
latest_version = response . text . strip ( )
await ctx . send (
f " 🤖 Bot version: ` { BOT_VERSION } ` \n "
f " 🌍 Latest version: ` { latest_version } ` \n "
f " { ' ✅ Up to date! ' if BOT_VERSION == latest_version else f ' ⚠️ Update available! Get it here: { RELEASES_URL } ' } "
)
await ctx . send ( f " 🤖 Bot version: ` { BOT_VERSION } ` \n 🌍 Latest version: ` { latest_version } ` \n { ' ✅ Up to date! ' if BOT_VERSION == latest_version else f ' ⚠️ Update available! Get it here: { RELEASES_URL } ' } " )
else :
await ctx . send ( " ❌ Failed to fetch latest version info. " )
except Exception as e :
await ctx . send ( f " ❌ Error checking version: { e } " )
@bot.command ( )
async def logging ( ctx , state : str ) :
""" Admin-only: Enable or disable event logging. """
member = ctx . guild . get_member ( ctx . author . id )
if not member or not has_admin_role ( member ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
global EVENT_LOGGING
if state . lower ( ) in ( " on " , " true " , " 1 " ) :
EVENT_LOGGING = True
new_value = " true "
elif state . lower ( ) in ( " off " , " false " , " 0 " ) :
EVENT_LOGGING = False
new_value = " false "
else :
await ctx . send ( " ❌ Invalid value. Use `on` or `off`. " )
return
# Update .env
lines = [ ]
with open ( " .env " , " r " ) as f :
for line in f :
if line . startswith ( " EVENT_LOGGING= " ) :
lines . append ( f " EVENT_LOGGING= { new_value } \n " )
else :
lines . append ( line )
with open ( " .env " , " w " ) as f :
f . writelines ( lines )
await ctx . send ( f " ✅ Event logging is now { ' enabled ' if EVENT_LOGGING else ' disabled ' } . " )
log_event ( f " EVENT_LOGGING toggled to { new_value } by { ctx . author } " )
@bot.command ( name = " help " )
async def help_command ( ctx ) :
log_event ( f " Command help invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
is_admin = has_admin_role ( member )
@@ -621,6 +682,7 @@ async def help_command(ctx):
embed . add_field ( name = " Admin Bot Commands " , value = (
f " ` { PREFIX } setprefix` - Change the bots command prefix \n "
f " ` { PREFIX } updates` - Manually check for bot updates \n "
f " ` { PREFIX } logging` - Enable/Disable Console Event Logging \n "
) , inline = False )
await ctx . send ( embed = embed )
@@ -647,6 +709,7 @@ async def daily_check():
# Log last run timestamp
set_metadata ( " last_cleanup " , datetime . datetime . utcnow ( ) . isoformat ( ) )
log_event ( f " Daily cleanup: removed { len ( removed ) } accounts: { removed } " )
@tasks.loop ( hours = 1 )
async def check_for_updates ( ) :
@@ -658,11 +721,12 @@ async def check_for_updates():
log_channel = bot . get_channel ( SYNC_LOG_CHANNEL_ID )
if log_channel :
await log_channel . send (
f " ⚠️ **Update available for Jellyfin Bot!** \n "
f " 📌 Current version: ` { BOT_VERSION } ` \n "
f " ⬆️ Latest version: ` { latest_version } ` \n \n "
f " 🔗 Download/update here: { RELEASES_URL } "
f " ⬆️ Latest version: ` { latest_version } ` \n "
f " ⚠️ **Update available for Jellyfin Bot! Get it here:** \n \n "
f " { RELEASES_URL } "
)
log_event ( f " Latest Version: ' { latest_version } ' , Current Version: ' { BOT_VERSION } ' " )
except Exception as e :
print ( f " [Update Check] Failed: { e } " )