@@ -9,6 +9,15 @@ import pytz
import random
import qbittorrentapi
from proxmoxer import ProxmoxAPI
import subprocess
import sys
import zipfile
import io
import time
from pathlib import Path
import tempfile
import shutil
import pymysql
# =====================
# ENV + VALIDATION
@@ -34,6 +43,7 @@ SYNC_LOG_CHANNEL_ID = get_env_var("SYNC_LOG_CHANNEL_ID", int)
JELLYFIN_URL = get_env_var ( " JELLYFIN_URL " )
JELLYFIN_API_KEY = get_env_var ( " JELLYFIN_API_KEY " )
ENABLE_TRIAL_ACCOUNTS = os . getenv ( " ENABLE_TRIAL_ACCOUNTS " , " False " ) . lower ( ) == " true "
TRIAL_TIME = int ( os . getenv ( " TRIAL_TIME " , 24 ) )
JELLYSEERR_ENABLED = os . getenv ( " JELLYSEERR_ENABLED " , " false " ) . lower ( ) == " true "
JELLYSEERR_URL = os . getenv ( " JELLYSEERR_URL " , " " ) . rstrip ( " / " )
@@ -65,8 +75,11 @@ DB_PASSWORD = get_env_var("DB_PASSWORD")
DB_NAME = get_env_var ( " DB_NAME " )
LOCAL_TZ = pytz . timezone ( get_env_var ( " LOCAL_TZ " , str , required = False ) or " America/Chicago " )
ENV_FILE = " .env "
DEFAULT_ENV_FILE = " .env.example "
BACKUP_DIR = Path ( " backups " )
BOT_VERSION = " 1.0.7 "
BOT_VERSION = " 1.0.9 "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
RELEASES_URL = " https://github.com/PenguCCN/Jellycord/releases "
CHANGELOG_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/refs/heads/main/CHANGELOG.md "
@@ -573,6 +586,91 @@ def _update_env_key(key: str, value: str, env_path: str = ".env"):
if not found :
f . write ( f " { key } = { value } \n " )
def export_mysql_db ( dump_file ) :
try :
conn = mysql . connector . connect (
host = DB_HOST ,
user = DB_USER ,
password = DB_PASSWORD ,
database = DB_NAME
)
cursor = conn . cursor ( )
with open ( dump_file , " w " , encoding = " utf-8 " ) as f :
# Get tables
cursor . execute ( " SHOW TABLES " )
tables = [ row [ 0 ] for row in cursor . fetchall ( ) ]
for table in tables :
# Dump CREATE statement
cursor . execute ( f " SHOW CREATE TABLE ` { table } ` " )
create_stmt = cursor . fetchone ( ) [ 1 ]
f . write ( f " -- Table structure for ` { table } ` \n { create_stmt } ; \n \n " )
# Dump rows
cursor . execute ( f " SELECT * FROM ` { table } ` " )
rows = cursor . fetchall ( )
if rows :
columns = [ desc [ 0 ] for desc in cursor . description ]
for row in rows :
values = " , " . join (
f " ' { str ( val ) . replace ( " ' " , " ' ' " ) } ' " if val is not None else " NULL "
for val in row
)
f . write ( f " INSERT INTO ` { table } ` ( { ' , ' . join ( columns ) } ) VALUES ( { values } ); \n " )
f . write ( " \n " )
cursor . close ( )
conn . close ( )
return True
except Exception as e :
print ( f " [Backup] Database export failed: { e } " )
return False
def sync_env_file ( ) :
""" Ensure .env has all fields from .env.example, preserving existing values. """
if not os . path . exists ( DEFAULT_ENV_FILE ) :
print ( " [updatebot] No .env.example found, skipping env sync " )
return
# Load .env.example as baseline
with open ( DEFAULT_ENV_FILE , " r " ) as f :
default_lines = [ line . strip ( " \n " ) for line in f . readlines ( ) ]
# Load existing .env (create if missing)
existing = { }
if os . path . exists ( ENV_FILE ) :
with open ( ENV_FILE , " r " ) as f :
for line in f :
if " = " in line and not line . strip ( ) . startswith ( " # " ) :
key , val = line . split ( " = " , 1 )
existing [ key . strip ( ) ] = val . strip ( )
# Build new env content
new_lines = [ ]
for line in default_lines :
if " = " not in line : # comments or blank lines
new_lines . append ( line )
continue
key , default_val = line . split ( " = " , 1 )
key = key . strip ( )
if key in existing :
new_lines . append ( f " { key } = { existing [ key ] } " )
else :
new_lines . append ( line ) # use default if missing
# Write back updated .env
with open ( ENV_FILE , " w " ) as f :
f . write ( " \n " . join ( new_lines ) + " \n " )
print ( " [updatebot] Synced .env file successfully " )
def restart_bot ( ) :
""" Replace current process with a new one. """
os . execv ( sys . executable , [ sys . executable ] + sys . argv )
# =====================
# EVENTS
@@ -919,7 +1017,7 @@ async def refreshjfakey(ctx):
@bot.command ( )
async def trialaccount ( ctx , username : str = None , password : str = None ) :
""" Create a 24 -hour trial Jellyfin account. DM-only, one-time per user. """
f """ Create a { TRIAL_TIME } -hour trial Jellyfin account. DM-only, one-time per user. """
log_event ( f " trialaccount invoked by { ctx . author } " )
# Ensure trial accounts are enabled
@@ -987,7 +1085,7 @@ async def trialaccount(ctx, username: str = None, password: str = None):
cur . close ( )
conn . close ( )
await ctx . send ( f " ✅ Trial Jellyfin account ** { username } ** created! It will expire in 24 hours. \n 🌐 Login here: { JELLYFIN_URL } " )
await ctx . send ( f " ✅ Trial Jellyfin account ** { username } ** created! It will expire in { TRIAL_TIME } hours. \n 🌐 Login here: { JELLYFIN_URL } " )
log_event ( f " Trial account created for { ctx . author } ( { username } ) " )
else :
cur . close ( )
@@ -1223,7 +1321,7 @@ async def cleanup(ctx):
await ctx . send ( " ✅ Cleanup complete. " )
@bot.command ( )
async def list validusers( ctx ) :
async def validusers ( ctx ) :
""" Admin-only: List how many registered users have a valid role. """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
@@ -1596,9 +1694,9 @@ async def storage(ctx):
@bot.command ( )
async def link ( ctx , jellyfin_username : str = None , user: discord . User = None , js_id : str = None ) :
async def link ( ctx , user : discord . User = None , jellyfin_username : st r = None , js_id : str = None ) :
log_event ( f " link invoked by { ctx . author } " )
usage_args = [ " <Jellyfin Account> " , " <@user > " ]
usage_args = [ " <@user> " , " <Jellyfin Account > " ]
if JELLYSEERR_ENABLED : usage_args . append ( " <Jellyseerr ID> " )
if jellyfin_username is None or user is None or ( JELLYSEERR_ENABLED and js_id is None ) :
@@ -1616,7 +1714,7 @@ async def link(ctx, jellyfin_username: str = None, user: discord.User = None, js
return
add_account ( user . id , jellyfin_username , jf_id , js_id )
await ctx . send ( f " ✅ Linked Jellyfin account ** { jellyfin_username } ** to { user . mention } . " )
await ctx . send ( f " ✅ Linked { user . mention } to Jellyfin account ** { jellyfin_username } **. " )
@bot.command ( )
@@ -1667,7 +1765,247 @@ async def setprefix(ctx, new_prefix: str = None):
@bot.command ( )
async def updates ( ctx ) :
async def update ( ctx ) :
""" Admin-only: Check GitHub version, sync .env, and pull latest bot code. """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
try :
# Fetch latest version
version_url = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
r = requests . get ( version_url , timeout = 10 )
if r . status_code != 200 :
await ctx . send ( " ❌ Failed to fetch latest version info. " )
return
latest_version = r . text . strip ( )
if latest_version == BOT_VERSION :
await ctx . send ( f " ✅ Bot is already up-to-date (` { BOT_VERSION } `). " )
return
await ctx . send ( f " ⬆️ Update found: ` { BOT_VERSION } ` → ` { latest_version } ` " )
# Download release zip
releases_url = " https://github.com/PenguCCN/Jellycord/releases/latest/download/Jellycord.zip "
r = requests . get ( releases_url , timeout = 30 )
if r . status_code != 200 :
await ctx . send ( " ❌ Failed to download latest release zip. " )
return
with zipfile . ZipFile ( io . BytesIO ( r . content ) ) as z :
z . extractall ( " update_tmp " )
# Merge .env with .env.example
env_path = " .env "
example_path = os . path . join ( " update_tmp " , " .env.example " )
if os . path . exists ( example_path ) :
# Load current env into dict
current_env = { }
if os . path . exists ( env_path ) :
with open ( env_path , " r " ) as f :
for line in f :
if " = " in line and not line . strip ( ) . startswith ( " # " ) :
key , val = line . split ( " = " , 1 )
current_env [ key . strip ( ) ] = val . strip ( )
merged_lines = [ ]
with open ( example_path , " r " ) as f :
for line in f :
if line . strip ( ) . startswith ( " # " ) or " = " not in line :
# Keep comments & blank lines exactly as they are
merged_lines . append ( line . rstrip ( " \n " ) )
else :
key , default_val = line . split ( " = " , 1 )
key = key . strip ( )
if key in current_env :
merged_lines . append ( f " { key } = { current_env [ key ] } " )
else :
merged_lines . append ( line . rstrip ( " \n " ) )
with open ( env_path , " w " ) as f :
f . write ( " \n " . join ( merged_lines ) + " \n " )
# Overwrite all other bot files
for root , dirs , files in os . walk ( " update_tmp " ) :
for file in files :
if file == " .env.example " :
continue
src = os . path . join ( root , file )
dst = os . path . relpath ( src , " update_tmp " )
os . replace ( src , dst )
await ctx . send ( f " ✅ Update applied! Now running version ` { latest_version } `. \n ⚠️ Restart the bot to load changes. " )
restart_bot ( )
except Exception as e :
await ctx . send ( f " ❌ Update failed: { e } " )
print ( f " [updatebot] Error: { e } " )
@bot.command ( )
async def backup ( ctx ) :
""" Create a backup of the bot (files + DB). """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
await ctx . send ( " 📦 Starting backup process... " )
try :
BACKUP_DIR . mkdir ( exist_ok = True )
# Backup filename
today = datetime . datetime . now ( ) . strftime ( " % m- %d - % Y " )
backup_name = f " { today } - { BOT_VERSION } .zip "
backup_path = BACKUP_DIR / backup_name
# Temporary SQL dump file
dump_file = BACKUP_DIR / f " { DB_NAME } .sql "
if not export_mysql_db ( dump_file ) :
await ctx . send ( " ⚠️ Database export failed, continuing without DB dump... " )
with zipfile . ZipFile ( backup_path , " w " , zipfile . ZIP_DEFLATED ) as backup_zip :
# Add all files in current directory (skip backups themselves)
for root , _ , files in os . walk ( " . " ) :
if root . startswith ( " ./backups " ) :
continue
for file in files :
file_path = Path ( root ) / file
backup_zip . write ( file_path , arcname = file_path . relative_to ( " . " ) )
# Add DB dump if created
if dump_file . exists ( ) :
backup_zip . write ( dump_file , arcname = f " { DB_NAME } .sql " )
dump_file . unlink ( ) # remove temporary dump file
await ctx . send ( f " ✅ Backup created: ` { backup_name } ` " )
log_event ( f " Backup created: { backup_name } " )
except Exception as e :
await ctx . send ( f " ❌ Backup failed: { e } " )
print ( f " [Backup] Error: { e } " )
@bot.command ( )
async def restore ( ctx , backup_file : str ) :
""" Restore a backup (files + database) from a zip. Admin only. """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
backup_path = os . path . join ( " backups " , backup_file )
if not os . path . exists ( backup_path ) :
await ctx . send ( f " ❌ Backup ` { backup_file } ` not found. " )
return
await ctx . send ( f " ♻️ Starting restore from ` { backup_file } `. This may take a while... " )
temp_dir = os . path . join ( " backups " , " restore_temp " )
os . makedirs ( temp_dir , exist_ok = True )
try :
# --- Extract zip to local restore_temp folder ---
with zipfile . ZipFile ( backup_path , " r " ) as zip_ref :
zip_ref . extractall ( temp_dir )
# --- Database Restore ---
sql_files = [ f for f in os . listdir ( temp_dir ) if f . endswith ( " .sql " ) ]
if sql_files :
sql_file_path = os . path . join ( temp_dir , sql_files [ 0 ] )
with open ( sql_file_path , " r " , encoding = " utf-8 " ) as f :
sql_content = f . read ( )
conn = pymysql . connect (
host = os . getenv ( " DB_HOST " , " localhost " ) ,
user = os . getenv ( " DB_USER " ) ,
password = os . getenv ( " DB_PASSWORD " ) ,
database = os . getenv ( " DB_NAME " ) ,
autocommit = True
)
with conn . cursor ( ) as cursor :
cursor . execute ( " SET FOREIGN_KEY_CHECKS = 0; " )
cursor . execute ( " SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE(); " )
tables = cursor . fetchall ( )
for ( table_name , ) in tables :
cursor . execute ( f " DROP TABLE IF EXISTS ` { table_name } `; " )
cursor . execute ( " SET FOREIGN_KEY_CHECKS = 1; " )
for statement in sql_content . split ( " ; " ) :
stmt = statement . strip ( )
if stmt :
cursor . execute ( stmt )
conn . close ( )
await ctx . send ( " ✅ Database restored successfully! " )
else :
await ctx . send ( " ⚠️ No SQL backup found in this zip file. " )
# --- Copy files to working directory ---
for item in os . listdir ( temp_dir ) :
src_path = os . path . join ( temp_dir , item )
dest_path = os . path . join ( " . " , item )
if os . path . isdir ( src_path ) :
if os . path . exists ( dest_path ) :
shutil . rmtree ( dest_path )
shutil . copytree ( src_path , dest_path )
else :
shutil . copy2 ( src_path , dest_path )
await ctx . send ( " ✅ Files restored successfully! " )
except Exception as e :
await ctx . send ( f " ❌ Restore failed: { e } " )
return
finally :
# --- Clean up restore_temp folder ---
shutil . rmtree ( temp_dir , ignore_errors = True )
await ctx . send ( " 🔃 Restarting bot to apply changes... " )
restart_bot ( )
@bot.command ( )
async def backups ( ctx ) :
""" List all available backups in the backups directory (newest to oldest). """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
backup_folder = Path ( " backups " )
if not backup_folder . exists ( ) :
await ctx . send ( " ⚠️ No backups folder found. " )
return
# Collect all zip files in backups dir
backups = list ( backup_folder . glob ( " *.zip " ) )
if not backups :
await ctx . send ( " ⚠️ No backups found. " )
return
# Sort by modification time, newest first
backups . sort ( key = lambda f : f . stat ( ) . st_mtime , reverse = True )
embed = discord . Embed (
title = " 📂 Available Backups " ,
description = " Newest to oldest backups: " ,
color = discord . Color . green ( )
)
for backup in backups :
mtime = backup . stat ( ) . st_mtime
formatted_time = f " <t: { int ( mtime ) } :f> " # Discord timestamp formatting
embed . add_field (
name = backup . name ,
value = f " Created: { formatted_time } " ,
inline = False
)
await ctx . send ( embed = embed )
@bot.command ( )
async def version ( ctx ) :
log_event ( f " updates invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( ctx . author ) :
@@ -1791,7 +2129,7 @@ async def help_command(ctx):
f " ` { PREFIX } shows2watch` - Lists 5 random show suggestions from the Jellyfin Library "
]
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 { TRIAL_TIME } -hour trial Jellyfin account " )
embed . add_field ( name = " 🎬 Jellyfin Commands " , value = " \n " . join ( user_cmds ) , inline = False )
@@ -1804,14 +2142,14 @@ async def help_command(ctx):
# --- Admin Commands ---
if is_admin :
# Admin Jellyfin commands
link_command = f " ` { PREFIX } link <jellyfin_username> @user ` - Manually link accounts "
link_command = f " ` { PREFIX } link @user <jellyfin_username>` - Manually link accounts "
if JELLYSEERR_ENABLED :
link_command = f " ` { PREFIX } link <jellyfin_username> @user <Jellyseerr ID>` - Link accounts with Jellyseerr "
link_command = f " ` { PREFIX } link @user <jellyfin_username> <Jellyseerr ID>` - Link accounts with Jellyseerr "
admin_cmds = [
link_command ,
f " ` { PREFIX } unlink @user` - Manually unlink accounts " ,
f " ` { PREFIX } list validusers` - Show number of valid and invalid accounts" ,
f " ` { PREFIX } validusers` - Show number of valid and invalid accounts " ,
f " ` { PREFIX } cleanup` - Remove Jellyfin accounts from users without roles " ,
f " ` { PREFIX } lastcleanup` - See last cleanup time and time remaining " ,
f " ` { PREFIX } searchaccount <jellyfin_username>` - Find linked Discord user " ,
@@ -1849,7 +2187,11 @@ async def help_command(ctx):
# 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 } update` - Download latest bot version " ,
f " ` { PREFIX } backup` - Create a backup of the bot, its database and configurations " ,
f " ` { PREFIX } backups` - List backups of the bot " ,
f " ` { PREFIX } restore` - Restore a backup of the bot " ,
f " ` { PREFIX } version` - Manually check for bot updates " ,
f " ` { PREFIX } changelog` - View changelog for current bot version " ,
f " ` { PREFIX } logging` - Enable/disable console event logging "
]
@@ -1938,7 +2280,7 @@ async def cleanup_task():
else :
created_at_local = created_at_utc . astimezone ( LOCAL_TZ )
if now_local > created_at_local + datetime . timedelta ( hours = 24 ) :
if now_local > created_at_local + datetime . timedelta ( hours = TRIAL_TIME ) :
# Delete trial Jellyfin user
try :
delete_jellyfin_user ( trial . get ( " jellyfin_username " ) )