@@ -9,6 +9,19 @@ 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
import json
import psutil
import platform
import logging
# =====================
# ENV + VALIDATION
@@ -34,6 +47,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 ( " / " )
@@ -45,6 +59,14 @@ JFA_USERNAME = os.getenv("JFA_USERNAME")
JFA_PASSWORD = os . getenv ( " JFA_PASSWORD " )
JFA_API_KEY = os . getenv ( " JFA_API_KEY " )
ENABLE_RADARR = os . getenv ( " ENABLE_RADARR " , " false " ) . lower ( ) == " true "
RADARR_URL = os . getenv ( " RADARR_URL " , " " ) . rstrip ( " / " )
RADARR_API_KEY = os . getenv ( " RADARR_API_KEY " , " " )
ENABLE_SONARR = os . getenv ( " ENABLE_SONARR " , " false " ) . lower ( ) == " true "
SONARR_URL = os . getenv ( " SONARR_URL " , " " ) . rstrip ( " / " )
SONARR_API_KEY = os . getenv ( " SONARR_API_KEY " , " " )
ENABLE_QBITTORRENT = os . getenv ( " ENABLE_QBITTORRENT " , " False " ) . lower ( ) == " true "
QBIT_HOST = os . getenv ( " QBIT_HOST " )
QBIT_USERNAME = os . getenv ( " QBIT_USERNAME " )
@@ -65,12 +87,32 @@ 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 " )
LOG_DIR = Path ( " logs " )
LOG_DIR . mkdir ( exist_ok = True )
LATEST_LOG = LOG_DIR / " latest.log "
ARCHIVE_DIR = LOG_DIR / " archives "
ARCHIVE_DIR . mkdir ( exist_ok = True )
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 "
TRACKING_ENABLED = os . getenv ( " TRACKING_ENABLED " , " False " ) . lower ( ) == " true "
PROMETHEUS_URL = " https://prometheus.pengucc.com/api/v1/query "
POST_ENDPOINTS = {
" botinstance " : " https://jellycordstats.pengucc.com/api/instance " ,
" jellyseerr " : " https://jellycordstats.pengucc.com/api/jellyseerr " ,
" proxmox " : " https://jellycordstats.pengucc.com/api/proxmox " ,
" jfa " : " https://jellycordstats.pengucc.com/api/jfa " ,
" qbittorrent " : " https://jellycordstats.pengucc.com/api/qbittorrent " ,
" radarr " : " https://jellycordstats.pengucc.com/api/radarr " ,
" sonarr " : " https://jellycordstats.pengucc.com/api/sonarr "
}
# =====================
# EVENT LOGGING
# =====================
@@ -218,6 +260,14 @@ def init_trial_accounts_table():
cur . close ( )
conn . close ( )
logging . basicConfig (
level = logging . INFO ,
format = " %(asctime)s [ %(levelname)s ] %(message)s " ,
handlers = [
logging . FileHandler ( LATEST_LOG , encoding = " utf-8 " ) ,
logging . StreamHandler ( )
]
)
def get_accounts ( ) :
conn = mysql . connector . connect (
@@ -271,6 +321,7 @@ def delete_account(discord_id):
# =====================
# JELLYFIN HELPERS
# =====================
def create_jellyfin_user ( username , password ) :
headers = { " X-Emby-Token " : JELLYFIN_API_KEY }
data = { " Name " : username , " Password " : password }
@@ -385,6 +436,81 @@ def delete_jellyseerr_user(js_id: str) -> bool:
print ( f " [Jellyseerr] Failed to delete user { js_id } : { e } " )
return False
# =====================
# SERVARR HELPERS
# =====================
def radarr_get_movies ( ) :
""" Return a list of all movies Radarr is managing. """
if not ENABLE_RADARR :
return None
try :
response = requests . get (
f " { RADARR_URL } /api/v3/movie " ,
headers = { " X-Api-Key " : RADARR_API_KEY } ,
timeout = 10
)
if response . status_code != 200 :
print ( f " [Radarr] Error fetching movies: { response . status_code } { response . text } " )
return None
return response . json ( )
except Exception as e :
print ( f " [Radarr] Exception: { e } " )
return None
def radarr_get_latest_movies ( count = 5 ) :
""" Return the latest added movies from Radarr. """
movies = radarr_get_movies ( )
if not movies :
return None
# Sort by 'added' field if available
sorted_movies = sorted (
movies ,
key = lambda m : m . get ( " added " , " " ) ,
reverse = True
)
return sorted_movies [ : count ]
def sonarr_get_series ( ) :
""" Return a list of all series Sonarr is managing. """
if not ENABLE_SONARR :
return None
try :
response = requests . get (
f " { SONARR_URL } /api/v3/series " ,
headers = { " X-Api-Key " : SONARR_API_KEY } ,
timeout = 10
)
if response . status_code != 200 :
print ( f " [Sonarr] Error fetching series: { response . status_code } { response . text } " )
return None
return response . json ( )
except Exception as e :
print ( f " [Sonarr] Exception: { e } " )
return None
def sonarr_get_latest_series ( count = 5 ) :
""" Return the latest added series from Sonarr. """
series = sonarr_get_series ( )
if not series :
return None
# Sonarr tracks `added` timestamps too
sorted_series = sorted (
series ,
key = lambda s : s . get ( " added " , " " ) ,
reverse = True
)
return sorted_series [ : count ]
# =====================
# QBITTORRENT HELPERS
# =====================
@@ -573,6 +699,109 @@ 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 )
def build_payload ( enabled : bool ) :
return { " value " : 1 if enabled else 0 }
def promql ( query : str ) :
""" Run a PromQL query and return results. """
try :
response = requests . get (
PROMETHEUS_URL ,
params = { " query " : query } ,
timeout = 10
)
data = response . json ( )
result = data . get ( " data " , { } ) . get ( " result " , [ ] )
return result
except Exception as e :
print ( f " [Prometheus] Error: { e } " )
return None
# =====================
# EVENTS
@@ -919,7 +1148,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 +1216,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 +1452,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. " )
@@ -1415,6 +1644,100 @@ async def activestreams(ctx):
await ctx . send ( f " ❌ Error fetching active streams: { e } " )
print ( f " [activestreams] Error: { e } " )
@bot.command ( )
async def moviestats ( ctx ) :
""" Show Radarr ' s latest 5 added movies with total count. """
if not ENABLE_RADARR :
await ctx . send ( " ⚠️ Radarr support is not enabled. " )
return
movies = radarr_get_movies ( )
if movies is None :
await ctx . send ( " ❌ Failed to connect to Radarr. " )
return
total_count = len ( movies )
# Sort by newest "added"
latest = sorted (
movies ,
key = lambda m : m . get ( " added " , " " ) ,
reverse = True
) [ : 5 ]
embed = discord . Embed (
title = " 🎞️ Latest Radarr Additions " ,
color = discord . Color . orange ( )
)
for movie in latest :
title = movie . get ( " title " , " Unknown " )
year = movie . get ( " year " , " Unknown " )
added = movie . get ( " added " , " Unknown " )
tmdb_id = movie . get ( " tmdbId " )
tmdb_link = (
f " https://www.themoviedb.org/movie/ { tmdb_id } "
if tmdb_id else " No TMDB ID "
)
embed . add_field (
name = f " { title } ( { year } ) " ,
value = f " 📅 Added: ` { added } ` \n 🔗 { tmdb_link } " ,
inline = False
)
embed . set_footer ( text = f " Total movies managed by Radarr: { total_count } " )
await ctx . send ( embed = embed )
@bot.command ( )
async def showstats ( ctx ) :
""" Show Sonarr ' s latest 5 added series with total count. """
if not ENABLE_SONARR :
await ctx . send ( " ⚠️ Sonarr support is not enabled. " )
return
series = sonarr_get_series ( )
if series is None :
await ctx . send ( " ❌ Failed to connect to Sonarr. " )
return
total_count = len ( series )
# Newest first
latest = sorted (
series ,
key = lambda s : s . get ( " added " , " " ) ,
reverse = True
) [ : 5 ]
embed = discord . Embed (
title = " 📺 Latest Sonarr Additions " ,
color = discord . Color . blue ( )
)
for show in latest :
title = show . get ( " title " , " Unknown " )
year = show . get ( " year " , " Unknown " )
added = show . get ( " added " , " Unknown " )
tvdb_id = show . get ( " tvdbId " )
tvdb_link = (
f " https://thetvdb.com/?id= { tvdb_id } &tab=series "
if tvdb_id else " No TVDB ID "
)
embed . add_field (
name = f " { title } ( { year } ) " ,
value = f " 📅 Added: ` { added } ` \n 🔗 { tvdb_link } " ,
inline = False
)
embed . set_footer ( text = f " Total series managed by Sonarr: { total_count } " )
await ctx . send ( embed = embed )
@bot.command ( )
async def qbview ( ctx ) :
""" Admin-only: View current qBittorrent downloads. """
@@ -1596,9 +1919,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 +1939,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 ( )
@@ -1634,6 +1957,103 @@ async def unlink(ctx, discord_user: discord.User = None):
delete_account ( discord_user . id )
await ctx . send ( f " ✅ Unlinked Jellyfin account ** { account [ 0 ] } ** from Discord user { discord_user . mention } . " )
@bot.command ( )
async def stats ( ctx ) :
""" Show unified system and Prometheus metrics in one compact embed. """
# -------------------
# Local System Stats
# -------------------
cpu_usage = psutil . cpu_percent ( interval = 1 )
mem = psutil . virtual_memory ( )
mem_str = f " { round ( mem . used / 1024 * * 3 , 2 ) } / { round ( mem . total / 1024 * * 3 , 2 ) } GB ( { mem . percent } %) "
disk = psutil . disk_usage ( ' / ' )
disk_str = f " { round ( disk . used / 1024 * * 3 , 2 ) } / { round ( disk . total / 1024 * * 3 , 2 ) } GB ( { disk . percent } %) "
boot_time = datetime . datetime . fromtimestamp ( psutil . boot_time ( ) )
uptime = datetime . datetime . now ( ) - boot_time
uptime_str = str ( uptime ) . split ( ' . ' ) [ 0 ]
python_version = platform . python_version ( )
bot_ver = BOT_VERSION if " BOT_VERSION " in globals ( ) else " Unknown "
# -------------------
# Prometheus Stats (Last 5 Minutes)
# -------------------
prometheus_fields = [ ]
if PROMETHEUS_URL :
metrics = {
" Instances " :
" max_over_time(instance[5m]) " ,
" Jellyseerr Enabled " :
" max_over_time(jellyseerr[5m]) " ,
" JFA Enabled " :
" max_over_time(jfa[5m]) " ,
" qBittorrent Enabled " :
" max_over_time(qbittorrent[5m]) " ,
" Radarr Enabled " :
" max_over_time(radarr[5m]) " ,
" Sonarr Enabled " :
" max_over_time(sonarr[5m]) "
}
for label , query in metrics . items ( ) :
result = promql ( query )
if result and isinstance ( result , list ) and len ( result ) > 0 :
try :
value = result [ 0 ] [ " value " ] [ 1 ]
except :
value = " N/A "
else :
value = " N/A "
prometheus_fields . append ( ( label , value ) )
# -------------------
# Build Embed
# -------------------
embed = discord . Embed (
title = " 📊 Jellycord System & Tracking Statistics " ,
color = discord . Color . blurple ( )
)
# Local stats
embed . add_field ( name = " 🧠 CPU " , value = f " { cpu_usage } % " , inline = True )
embed . add_field ( name = " 💾 Memory " , value = mem_str , inline = True )
embed . add_field ( name = " 📀 Disk " , value = disk_str , inline = True )
embed . add_field ( name = " ⏱️ Uptime " , value = uptime_str , inline = True )
embed . add_field ( name = " 🐍 Python " , value = python_version , inline = True )
embed . add_field ( name = " 🤖 Bot Version " , value = bot_ver , inline = True )
# Prometheus stats
if prometheus_fields :
embed . add_field ( name = " 📡 Tracking (Last 5 Minutes) " , value = " \u200b " , inline = False )
for label , val in prometheus_fields :
embed . add_field ( name = f " • { label } " , value = f " ` { val } ` " , inline = True )
else :
embed . add_field (
name = " 📡 Tracking " ,
value = " Prometheus disabled or unreachable " ,
inline = False
)
embed . set_footer ( text = f " Generated • { datetime . datetime . now ( ) . strftime ( ' % Y- % m- %d % H: % M: % S ' ) } " )
await ctx . send ( embed = embed )
@bot.command ( )
async def setprefix ( ctx , new_prefix : str = None ) :
@@ -1667,7 +2087,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 ) :
@@ -1788,10 +2448,12 @@ async def help_command(ctx):
f " ` { PREFIX } recoveraccount <newpassword>` - Reset your password " ,
f " ` { PREFIX } deleteaccount <username>` - Delete your Jellyfin account " ,
f " ` { PREFIX } movies2watch` - Lists 5 random movie suggestions from the Jellyfin Library " ,
f " ` { PREFIX } shows2watch` - Lists 5 random show suggestions from the Jellyfin Library "
f " ` { PREFIX } shows2watch` - Lists 5 random show suggestions from the Jellyfin Library " ,
f " ` { PREFIX } moviestats` - Lists latest 5 movies added, also shows total movie library size " ,
f " ` { PREFIX } showstats` - Lists latest 5 movies added, also shows total series library size "
]
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 +2466,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 " ,
@@ -1848,8 +2510,13 @@ async def help_command(ctx):
# Admin Bot commands
admin_bot_cmds = [
f " ` { PREFIX } stats` - View Local and Global Jellycord Stats " ,
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 +2605,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 " ) )
@@ -1995,6 +2662,83 @@ async def cleanup_task():
except Exception as e :
print ( f " [Cleanup] Failed to send removed message to sync channel: { e } " )
@tasks.loop ( hours = 24 )
async def rotate_logs ( ) :
try :
now = datetime . datetime . now ( ) . strftime ( " % Y- % m- %d _ % H- % M- % S " )
# =====================
# 1. Ensure latest.log exists
# =====================
if not LATEST_LOG . exists ( ) :
LATEST_LOG . touch ( )
# =====================
# 2. Archive the latest.log
# =====================
archive_name = ARCHIVE_DIR / f " log_ { now } .zip "
with zipfile . ZipFile ( archive_name , " w " , zipfile . ZIP_DEFLATED ) as zipf :
zipf . write ( LATEST_LOG , arcname = " latest.log " )
print ( f " [LOG ROTATE] Archived log to: { archive_name } " )
# =====================
# 3. Delete oldest archives if more than 4 exist
# =====================
archives = sorted ( ARCHIVE_DIR . glob ( " *.zip " ) , key = os . path . getmtime )
if len ( archives ) > 4 :
to_delete = archives [ : len ( archives ) - 4 ]
for old in to_delete :
old . unlink ( )
print ( f " [LOG ROTATE] Deleted old archive: { old } " )
# =====================
# 4. Clear latest.log for new logs
# =====================
with open ( LATEST_LOG , " w " , encoding = " utf-8 " ) :
pass
print ( " [LOG ROTATE] Reset latest.log " )
except Exception as e :
print ( f " [ERROR] Log rotation failed: { e } " )
@tasks.loop ( seconds = 15 )
async def periodic_post_task ( ) :
if not TRACKING_ENABLED :
return
features = {
" botinstance " : TRACKING_ENABLED ,
" jellyseerr " : JELLYSEERR_ENABLED ,
" proxmox " : ENABLE_PROXMOX ,
" jfa " : ENABLE_JFA ,
" qbittorrent " : ENABLE_QBITTORRENT ,
" radarr " : ENABLE_RADARR ,
" sonarr " : ENABLE_SONARR
}
for feature , enabled in features . items ( ) :
url = POST_ENDPOINTS . get ( feature )
if not url :
print ( f " [POST LOOP] No endpoint for: { feature } " )
continue
# Skip POST if the feature is disabled (0)
if not enabled :
print ( f " [POST LOOP] Skipping { feature } because it ' s disabled. " )
continue
payload = build_payload ( enabled )
try :
response = requests . post ( url , json = payload , timeout = 10 )
print ( f " [POST LOOP] Sent { feature } → { response . status_code } | Payload: { payload } " )
except Exception as e :
print ( f " [POST LOOP] Error sending POST for { feature } : { e } " )
# =====================
# JFA-Go Scheduled Token Refresh
# =====================
@@ -2071,6 +2815,12 @@ async def on_ready():
if not check_for_updates . is_running ( ) :
check_for_updates . start ( )
if TRACKING_ENABLED :
print ( " Tracking enabled — starting. " )
periodic_post_task . start ( )
else :
print ( " Tracking disabled via .env " )
await bot . change_presence (
activity = discord . Activity ( type = discord . ActivityType . watching , name = f " { PREFIX } help " )
)