@@ -5,6 +5,9 @@ import mysql.connector
import asyncio
import os
from dotenv import load_dotenv
import pytz
import random
import qbittorrentapi
# =====================
# ENV + VALIDATION
@@ -35,12 +38,23 @@ JELLYSEERR_ENABLED = os.getenv("JELLYSEERR_ENABLED", "false").lower() == "true"
JELLYSEERR_URL = os . getenv ( " JELLYSEERR_URL " , " " ) . rstrip ( " / " )
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_USER = get_env_var ( " DB_USER " )
DB_PASSWORD = get_env_var ( " DB_PASSWORD " )
DB_NAME = get_env_var ( " DB_NAME " )
BOT_VERSION = " 1.0.4 "
LOCAL_TZ = pytz . timezone ( get_env_var ( " LOCAL_TZ " , str , required = False ) or " America/Chicago " )
BOT_VERSION = " 1.0.6 "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
RELEASES_URL = " https://github.com/PenguCCN/Jellycord/releases "
@@ -50,8 +64,10 @@ RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
EVENT_LOGGING = os . getenv ( " EVENT_LOGGING " , " false " ) . lower ( ) == " true "
def log_event ( message : str ) :
""" Log events to console if enabled in .env. """
if EVENT_LOGGING :
print ( f " [EVENT] { datetime . datetime . utc now( ) . isoformat ( ) } | { message } " )
now_local = datetime . datetime . now ( LOCAL_TZ )
print ( f " [EVENT] { now_local . isoformat ( ) } | { message } " )
# =====================
# DISCORD SETUP
@@ -61,6 +77,20 @@ intents.members = True
intents . message_content = True
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
# =====================
@@ -308,6 +338,16 @@ def delete_jellyseerr_user(js_id: str) -> bool:
print ( f " [Jellyseerr] Failed to delete user { js_id } : { e } " )
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
# =====================
@@ -475,6 +515,243 @@ async def createaccount(ctx, username: str = None, password: str = None):
else :
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 ( )
async def trialaccount ( ctx , username : str = None , password : str = None ) :
""" Create a 24-hour trial Jellyfin account. DM-only, one-time per user. """
@@ -610,6 +887,59 @@ async def deleteaccount(ctx, username: str = None):
else :
await ctx . send ( f " ❌ Failed to delete Jellyfin account ** { username } **. " )
@bot.command ( )
async def what2watch ( ctx ) :
""" Pick 5 random movies from the Jellyfin library with embeds and posters. """
member = ctx . guild . get_member ( ctx . author . id ) if ctx . 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 to use this command. " )
return
headers = { " X-Emby-Token " : JELLYFIN_API_KEY }
try :
# Fetch all movies
r = requests . get ( f " { JELLYFIN_URL } /Items?IncludeItemTypes=Movie&Recursive=true " , headers = headers , timeout = 10 )
if r . status_code != 200 :
await ctx . send ( f " ❌ Failed to fetch movies. Status code: { r . status_code } " )
return
movies = r . json ( ) . get ( " Items " , [ ] )
if not movies :
await ctx . send ( " ⚠️ No movies found in the library. " )
return
# Pick 5 random movies
selection = random . sample ( movies , min ( 5 , len ( movies ) ) )
embed = discord . Embed (
title = " 🎬 What to Watch " ,
description = " Here are 5 random movie suggestions from the library: " ,
color = discord . Color . blue ( )
)
for movie in selection :
name = movie . get ( " Name " )
year = movie . get ( " ProductionYear " , " N/A " )
runtime = movie . get ( " RunTimeTicks " , None )
runtime_min = int ( runtime / 10_000_000 / 60 ) if runtime else " N/A "
# Poster URL if available
poster_url = None
if " PrimaryImageTag " in movie and movie [ " PrimaryImageTag " ] :
poster_url = f " { JELLYFIN_URL } /Items/ { movie [ ' Id ' ] } /Images/Primary?tag= { movie [ ' PrimaryImageTag ' ] } &quality=90 "
field_value = f " Year: { year } \n Runtime: { runtime_min } min "
embed . add_field ( name = name , value = field_value , inline = False )
if poster_url :
embed . set_image ( url = poster_url ) # Only last movie's poster will appear as main embed image
await ctx . send ( embed = embed )
except Exception as e :
await ctx . send ( f " ❌ Error fetching movies: { e } " )
print ( f " [what2watch] Error: { e } " )
@bot.command ( )
async def cleanup ( ctx ) :
@@ -707,15 +1037,21 @@ async def lastcleanup(ctx):
await ctx . send ( " ℹ ️ No cleanup has been run yet." )
return
last_run_dt = datetime . datetime . fromisoformat ( last_run )
now = datetime . datetime . utcnow ( )
next_run_dt = last_run_dt + datetime . timedelta ( hours = 24 )
time_remaining = nex t_run_dt - now
last_run_dt_utc = datetime . datetime . fromisoformat ( last_run )
if last_run_dt_utc . tzinfo is None :
last_run_dt_utc = pytz . utc . localize ( last_run_dt_utc )
last_run_local = las t_run_dt_utc . astimezone ( LOCAL_TZ )
now_local = datetime . datetime . now ( LOCAL_TZ )
next_run_local = last_run_local + datetime . timedelta ( hours = 24 )
time_remaining = next_run_local - now_local
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 ⏳ Time until next cleanup: { hours } h { minutes } m { seconds } s " )
await ctx . send (
f " 🧹 Last cleanup ran at ** { last_run_local . strftime ( ' % Y- % m- %d % H: % M: % S % Z ' ) } ** \n "
f " ⏳ Time until next cleanup: { hours } h { minutes } m { seconds } s "
)
@bot.command ( )
@@ -763,6 +1099,132 @@ async def scanlibraries(ctx):
await ctx . send ( f " ❌ Failed to start library scan. Status code: { response . status_code } " )
@bot.command ( )
async def activestreams ( ctx ) :
""" Admin-only: Show currently active Jellyfin user streams (movies/episodes only) with progress bar. """
if not has_admin_role ( ctx . author ) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
headers = { " X-Emby-Token " : JELLYFIN_API_KEY }
try :
r = requests . get ( f " { JELLYFIN_URL } /Sessions " , headers = headers , timeout = 10 )
if r . status_code != 200 :
await ctx . send ( f " ❌ Failed to fetch active streams. Status code: { r . status_code } " )
return
sessions = r . json ( )
# Only keep sessions that are actively playing a Movie or Episode
active_streams = [
s for s in sessions
if s . get ( " NowPlayingItem " ) and s [ " NowPlayingItem " ] . get ( " Type " ) in ( " Movie " , " Episode " )
]
if not active_streams :
await ctx . send ( " ℹ ️ No active movie or episode streams at the moment." )
return
embed = discord . Embed (
title = " 📺 Active Jellyfin Streams " ,
description = f " Currently { len ( active_streams ) } active stream(s): " ,
color = discord . Color . green ( )
)
for session in active_streams :
user_name = session . get ( " UserName " , " Unknown User " )
device = session . get ( " DeviceName " , " Unknown Device " )
media = session . get ( " NowPlayingItem " , { } )
media_type = media . get ( " Type " , " Unknown " )
media_name = media . get ( " Name " , " Unknown Title " )
# Get progress
try :
position_ticks = session . get ( " PlayState " , { } ) . get ( " PositionTicks " , 0 )
runtime_ticks = media . get ( " RunTimeTicks " , 1 ) # avoid div by zero
position_seconds = position_ticks / 10_000_000
runtime_seconds = runtime_ticks / 10_000_000
position_str = str ( datetime . timedelta ( seconds = int ( position_seconds ) ) )
runtime_str = str ( datetime . timedelta ( seconds = int ( runtime_seconds ) ) )
# 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 :
progress_str = " Unknown "
embed . add_field (
name = f " { media_name } ( { media_type } ) " ,
value = f " 👤 { user_name } \n 📱 { device } \n ⏱ Progress: { progress_str } " ,
inline = False
)
await ctx . send ( embed = embed )
except Exception as e :
await ctx . send ( f " ❌ Error fetching active streams: { 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 ( )
async def link ( ctx , jellyfin_username : str = None , user : discord . User = None , js_id : str = None ) :
log_event ( f " link invoked by { ctx . author } " )
@@ -898,75 +1360,100 @@ async def help_command(ctx):
color = discord . Color . blue ( )
)
# User c ommands
# --- Jellyfin User C ommands ---
user_cmds = [
f " ` { PREFIX } createaccount <username> <password>` - Create your Jellyfin account " ,
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 "
]
# Only show trialaccount if enabled
if ENABLE_TRIAL_ACCOUNTS :
user_cmds . append ( f " ` { PREFIX } trialaccount <username> <password>` - Create a 24-hour trial Jellyfin account " )
embed . add_field ( name = " User Commands" , value = " \n " . join ( user_cmds ) , inline = False )
embed . add_field ( name = " 🎬 Jellyfin Commands" , value = " \n " . join ( user_cmds ) , inline = False )
# Admin c ommands
# --- Bot C ommands ---
bot_cmds = [
f " ` { PREFIX } help` - Show this help message "
]
embed . add_field ( name = " 🤖 Bot Commands " , value = " \n " . join ( bot_cmds ) , inline = False )
# --- Admin Commands ---
if is_admin :
# Dynamic l ink command line
# Admin Jellyf in commands
link_command = f " ` { PREFIX } link <jellyfin_username> @user` - Manually link accounts "
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 = (
f " ` { PREFIX } cleanup` - Remove Jellyfin accounts from users without roles \n "
f " ` { PREFIX } listvalid users ` - Show number of valid and invalid accounts\n "
f " ` { PREFIX } la stcleanup` - See Last cleanup time, and time remaining before next cleanup \n "
f " ` { PREFIX } searchaccount <jellyfin_username>` - Find linked Discord user \n "
f " ` { PREFIX } searchdiscord @user` - Find linked Jellyfin account \n "
f " ` { PREFIX } scanlibraries` - Scan all Jellyfin libraries \n "
f " { link_command } \n "
f " ` { PREFIX } unlink @user ` - M anu ally unlink accounts \n "
) , inline = False )
admin_cmds = [
link_command ,
f " ` { PREFIX } unlink @ user` - Manually unlink accounts",
f " ` { PREFIX } li stvalidusers` - 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 " ,
f " ` { PREFIX } searchdiscord @user` - Find linked Jellyfin account " ,
f " ` { PREFIX } scanlibraries ` - Sc an all Jellyfin libraries " ,
f " ` { PREFIX } activestreams` - View all active Jellyfin streams "
]
embed . add_field ( name = " 🛠️ Admin Commands " , value = " \n " . join ( admin_cmds ) , inline = False )
embed . add_field ( name = " Admin Bot Commands " , value = (
f " ` { PREFIX } setprefix` - Change the bot ' s command prefix \n "
f " ` { PREFIX } updates` - Manually check for bot updates \n "
f " ` { PREFIX } logging` - Enable/Disable Console Event Logging \n "
) , inline = False )
# --- qBittorrent Commands ---
if ENABLE_QBITTORRENT :
qb_cmds = [
f " ` { PREFIX } qbview` - Show current qBittorrent downloads with progress, peers, and seeders " ,
]
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 )
# =====================
# TASKS
# =====================
import datetime
import pytz
import datetime
import pytz
import mysql . connector
LOCAL_TZ = pytz . timezone ( os . getenv ( " LOCAL_TZ " , " America/Chicago " ) )
@tasks.loop ( hours = 24 )
async def daily_chec k( ) :
log_event ( " Running daily account cleanup check... " )
async def cleanup_tas k( ) :
log_event ( " 🧹 Running daily account cleanup check..." )
removed = [ ]
# =======================
# Normal accounts cleanup
for row in get_accounts ( ) :
# safe unpacking in case schema varies
discord_id = row [ 0 ]
jf_username = row [ 1 ] if len ( row ) > 1 else None
jf_id = row [ 2 ] if len ( row ) > 2 else None
js_id = row [ 3 ] if len ( row ) > 3 else None
# find the member across configured guilds
# =======================
for discord_id , jf_username , jf_id , js_id in get_accounts ( ) :
member = None
for gid in GUILD_IDS :
guild = bot . get_guild ( gid )
if not guild :
continue
candidate = guild . get_member ( discord_id )
if candidate :
member = candidate
if guild :
member = guild . get_member ( discord_id )
if member :
break
# if no member found or member doesn't have a required role -> delete account
if member is None or not has_required_role ( member ) :
if jf_username :
try :
@@ -983,7 +1470,7 @@ async def daily_check():
except Exception as e :
print ( f " [Cleanup] Error removing DB entry for Discord ID { discord_id } : { e } " )
# remove from Jellyseerr if we have an id and integration en abled
# remove from Jellyseerr if applic able
if JELLYSEERR_ENABLED and js_id :
try :
if delete_jellyseerr_user ( js_id ) :
@@ -995,7 +1482,9 @@ async def daily_check():
removed . append ( jf_username or f " { discord_id } " )
# Trial accounts cleanup (persistent history table)
# ======================
# Trial accounts cleanup
# ======================
try :
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
@@ -1003,21 +1492,27 @@ async def daily_check():
cur = conn . cursor ( dictionary = True )
cur . execute ( " SELECT * FROM trial_accounts WHERE expired=0 " )
trials = cur . fetchall ( )
now_local = datetime . datetime . now ( LOCAL_TZ )
for trial in trials :
created_at = trial . get ( " trial_created_at " ) or trial . get ( " created_at " ) # compatibility
if not created_at :
created_at_utc = trial . get ( " trial_created_at " ) or trial . get ( " created_at " )
if not created_at_utc :
continue
# created_at is a datetime from the DB (cursor dictionary=True)
if datetime . datetime . utcnow ( ) > created_at + datetime . timedelta ( hours = 24 ) :
# delete from Jellyfin (best-effort )
# Convert DB UTC time to local TZ
if created_at_utc . tzinfo is None :
created_at_local = pytz . utc . localize ( created_at_utc ) . astimezone ( LOCAL_TZ )
else :
created_at_local = created_at_utc . astimezone ( LOCAL_TZ )
if now_local > created_at_local + datetime . timedelta ( hours = 24 ) :
# Delete trial Jellyfin user
try :
delete_jellyfin_user ( trial . get ( " jellyfin_username " ) )
except Exception as e :
print ( f " [Trial Cleanup] Error deleting trial Jellyfin user { trial . get ( ' jellyfin_username ' ) } : { e } " )
# m ark trial as expired
# M ark trial as expired
try :
cur . execute ( " UPDATE trial_accounts SET expired=1 WHERE discord_id= %s " , ( trial [ " discord_id " ] , ) )
conn . commit ( )
@@ -1034,9 +1529,11 @@ async def daily_check():
except Exception :
pass
# record last run in metadata and cleanup_logs
# ======================
# Update metadata & logs
# ======================
try :
set_metadata ( " last_cleanup " , datetime . datetime . utc now( ) . isoformat ( ) )
set_metadata ( " last_cleanup " , datetime . datetime . now ( LOCAL_TZ ) . isoformat ( ) )
except Exception as e :
print ( f " [Cleanup] Failed to set last_cleanup metadata: { e } " )
@@ -1045,14 +1542,16 @@ async def daily_check():
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
)
cur = conn . cursor ( )
cur . execute ( " INSERT INTO cleanup_logs (run_at) VALUES ( %s ) " , ( datetime . datetime . utc now( ) , ) )
cur . execute ( " INSERT INTO cleanup_logs (run_at) VALUES ( %s ) " , ( datetime . datetime . now ( LOCAL_TZ ) , ) )
conn . commit ( )
cur . close ( )
conn . close ( )
except Exception as e :
print ( f " [Cleanup] Failed to insert cleanup_logs: { e } " )
# post results to sync channel if anything removed
# ============================
# Post results to sync channel
# ============================
if removed :
msg = f " 🧹 Removed { len ( removed ) } Jellyfin accounts: { ' , ' . join ( removed ) } "
print ( msg )
@@ -1092,15 +1591,29 @@ async def on_ready():
# Check last cleanup
last_run = get_metadata ( " last_cleanup " )
if last_run :
last_run_dt = datetime . datetime . fromisoformat ( last_run )
now = datetime . datetime . utcnow ( )
delta = now - last_run_dt
# parse UTC timestamp from DB
last_run_dt_utc = datetime . datetime . fromisoformat ( last_run )
# convert to local timezone
if last_run_dt_utc . tzinfo is None :
last_run_dt_utc = pytz . utc . localize ( last_run_dt_utc )
last_run_local = last_run_dt_utc . astimezone ( LOCAL_TZ )
now_local = datetime . datetime . now ( LOCAL_TZ )
delta = now_local - last_run_local
if delta . total_seconds ( ) > = 24 * 3600 :
print ( " Running missed daily cleanup... " )
await daily_chec k( ) # R un immediately if overdue
await cleanup_tas k( ) # r un immediately if overdue
daily_check . start ( )
# Start scheduled tasks
if not cleanup_task . is_running ( ) :
cleanup_task . start ( )
if not check_for_updates . is_running ( ) :
check_for_updates . start ( )
await bot . change_presence ( activity = discord . Activity ( type = discord . ActivityType . watching , name = f " { PREFIX } help " ) )
await bot . change_presence (
activity = discord . Activity ( type = discord . ActivityType . watching , name = f " { PREFIX } help " )
)
log_event ( f " ✅ Bot ready. Current time: { datetime . datetime . now ( LOCAL_TZ ) . strftime ( ' % Y- % m- %d % H: % M: % S % Z ' ) } " )
bot . run ( TOKEN )