@@ -5,6 +5,8 @@ import mysql.connector
import asyncio
import asyncio
import os
import os
from dotenv import load_dotenv
from dotenv import load_dotenv
import pytz
import random
# =====================
# =====================
# ENV + VALIDATION
# ENV + VALIDATION
@@ -22,7 +24,7 @@ def get_env_var(key: str, cast=str, required=True):
TOKEN = get_env_var ( " DISCORD_TOKEN " )
TOKEN = get_env_var ( " DISCORD_TOKEN " )
PREFIX = os . getenv ( " PREFIX " , " ! " ) # Default to "!" if not set
PREFIX = os . getenv ( " PREFIX " , " ! " ) # Default to "!" if not set
GUILD_ID = get_env_var ( " GUILD_ID " , int )
GUILD_IDS = [ int ( x . strip ( ) ) for x in get_env_var ( " GUILD_IDS " ) . split ( " , " ) ]
REQUIRED_ROLE_IDS = [ int ( x ) for x in get_env_var ( " REQUIRED_ROLE_IDS " ) . split ( " , " ) ]
REQUIRED_ROLE_IDS = [ int ( x ) for x in get_env_var ( " REQUIRED_ROLE_IDS " ) . split ( " , " ) ]
ADMIN_ROLE_IDS = [ int ( x ) for x in get_env_var ( " ADMIN_ROLE_IDS " ) . split ( " , " ) ]
ADMIN_ROLE_IDS = [ int ( x ) for x in get_env_var ( " ADMIN_ROLE_IDS " ) . split ( " , " ) ]
SYNC_LOG_CHANNEL_ID = get_env_var ( " SYNC_LOG_CHANNEL_ID " , int )
SYNC_LOG_CHANNEL_ID = get_env_var ( " SYNC_LOG_CHANNEL_ID " , int )
@@ -40,7 +42,9 @@ DB_USER = get_env_var("DB_USER")
DB_PASSWORD = get_env_var ( " DB_PASSWORD " )
DB_PASSWORD = get_env_var ( " DB_PASSWORD " )
DB_NAME = get_env_var ( " DB_NAME " )
DB_NAME = get_env_var ( " DB_NAME " )
BOT_VERSION = " 1.0.3 "
LOCAL_TZ = pytz . timezone ( get_env_var ( " LOCAL_TZ " , str , required = False ) or " America/Chicago " )
BOT_VERSION = " 1.0.5 "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
RELEASES_URL = " https://github.com/PenguCCN/Jellycord/releases "
RELEASES_URL = " https://github.com/PenguCCN/Jellycord/releases "
@@ -50,8 +54,10 @@ RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
EVENT_LOGGING = os . getenv ( " EVENT_LOGGING " , " false " ) . lower ( ) == " true "
EVENT_LOGGING = os . getenv ( " EVENT_LOGGING " , " false " ) . lower ( ) == " true "
def log_event ( message : str ) :
def log_event ( message : str ) :
""" Log events to console if enabled in .env. """
if EVENT_LOGGING :
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
# DISCORD SETUP
@@ -311,11 +317,30 @@ def delete_jellyseerr_user(js_id: str) -> bool:
# =====================
# =====================
# DISCORD HELPERS
# DISCORD HELPERS
# =====================
# =====================
def has_required_role ( member ) :
return any ( role . id in REQUIRED_ROLE_IDS for role in member . roles )
def has_admin _role ( member ) :
def has_required _role ( user : discord . User | discord . Member ) - > bool :
return any ( role . id in ADMIN_ROLE_IDS for role in member . roles )
""" Check if the user has any of the required roles across all configured guilds. """
for gid in GUILD_IDS :
guild = bot . get_guild ( gid )
if not guild :
continue
member = guild . get_member ( user . id )
if member and any ( role . id in REQUIRED_ROLE_IDS for role in member . roles ) :
return True
return False
def has_admin_role ( user : discord . User | discord . Member ) - > bool :
""" Check if the user has any of the admin roles across all configured guilds. """
for gid in GUILD_IDS :
guild = bot . get_guild ( gid )
if not guild :
continue
member = guild . get_member ( user . id )
if member and any ( role . id in ADMIN_ROLE_IDS for role in member . roles ) :
return True
return False
# =====================
# =====================
# BOT HELPERS
# BOT HELPERS
@@ -418,8 +443,14 @@ async def createaccount(ctx, username: str = None, password: str = None):
await ctx . send ( f " { ctx . author . mention } ❌ Please DM me to create your Jellyfin account. " )
await ctx . send ( f " { ctx . author . mention } ❌ Please DM me to create your Jellyfin account. " )
return
return
guild = bot . get_guild ( GUILD_ID )
member = None
member = guild . get_member ( ctx . author . id ) if guild else None
for gid in GUILD_IDS :
guild = bot . get_guild ( gid )
if guild :
member = guild . get_member ( ctx . author . id )
if member and has_required_role ( member ) :
break
if not member or not has_required_role ( member ) :
if not member or not has_required_role ( member ) :
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have the required role. " )
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have the required role. " )
return
return
@@ -474,10 +505,14 @@ async def trialaccount(ctx, username: str = None, password: str = None):
await ctx . send ( command_usage ( f " { PREFIX } trialaccount " , [ " <username> " , " <password> " ] ) )
await ctx . send ( command_usage ( f " { PREFIX } trialaccount " , [ " <username> " , " <password> " ] ) )
return
return
guild = bot . get_guild ( GUILD_ID )
member = None
member = guild . get_member ( ctx . author . id ) if guild else None
for gid in GUILD_IDS :
guild = bot . get_guild ( gid )
if guild :
member = guild . get_member ( ctx . author . id )
if member and has_required_role ( member ) :
break
# Check required server role
if not member or not has_required_role ( member ) :
if not member or not has_required_role ( member ) :
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have the required role. " )
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have the required role. " )
return
return
@@ -581,21 +616,75 @@ async def deleteaccount(ctx, username: str = None):
else :
else :
await ctx . send ( f " ❌ Failed to delete Jellyfin account ** { username } **. " )
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 ( )
@bot.command ( )
async def cleanup ( ctx ) :
async def cleanup ( ctx ) :
log_event ( f " cleanup invoked by { ctx . author } " )
log_event ( f " cleanup invoked by { ctx . author } " )
guild = bot . get_guild ( GUILD_ID )
removed = [ ]
removed = [ ]
for row in get_accounts ( ) :
for discord_id , jf_username , jf_id , js_id in get_accounts ( ) :
discord_id = row [ 0 ]
member = None
jf_username = row [ 1 ]
for gid in GUILD_IDS :
jf_id = row [ 2 ] if len ( row ) > 2 else None
guild = bot . get_guild ( gid )
js_id = row [ 3 ] if len ( row ) > 3 else None
if guild :
member = guild . get_member ( discord_id )
if member :
break
m = guild . get_member ( discord_id )
if member is None or not has_required_role ( member ) :
if m is None or not has_required_role ( m ) :
if delete_jellyfin_user ( jf_username ) :
if delete_jellyfin_user ( jf_username ) :
delete_account ( discord_id )
delete_account ( discord_id )
@@ -616,12 +705,59 @@ async def cleanup(ctx):
await ctx . send ( " ✅ Cleanup complete. " )
await ctx . send ( " ✅ Cleanup complete. " )
@bot.command ( )
async def listvalidusers ( 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. " )
return
accounts = get_accounts ( )
valid_users = [ ]
invalid_users = [ ]
for discord_id , jf_username , jf_id , js_id in accounts :
user = await bot . fetch_user ( discord_id )
if has_required_role ( user ) :
valid_users . append ( user )
else :
invalid_users . append ( user )
embed = discord . Embed (
title = " 📊 Registered User Role Status " ,
color = discord . Color . green ( )
)
embed . add_field (
name = " ✅ Valid Users " ,
value = f " { len ( valid_users ) } users " ,
inline = True
)
embed . add_field (
name = " ❌ Invalid Users " ,
value = f " { len ( invalid_users ) } users " ,
inline = True
)
if len ( valid_users ) > 0 :
embed . add_field (
name = " Valid Users List " ,
value = " \n " . join ( [ u . mention for u in valid_users [ : 20 ] ] ) + ( " ... " if len ( valid_users ) > 20 else " " ) ,
inline = False
)
if len ( invalid_users ) > 0 :
embed . add_field (
name = " Invalid Users List " ,
value = " \n " . join ( [ u . mention for u in invalid_users [ : 20 ] ] ) + ( " ... " if len ( invalid_users ) > 20 else " " ) ,
inline = False
)
await ctx . send ( embed = embed )
@bot.command ( )
@bot.command ( )
async def lastcleanup ( ctx ) :
async def lastcleanup ( ctx ) :
log_event ( f " lastcleanup invoked by { ctx . author } " )
log_event ( f " lastcleanup invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to view the last cleanup. " )
await ctx . send ( " ❌ You don’ t have permission to view the last cleanup. " )
return
return
@@ -630,15 +766,21 @@ async def lastcleanup(ctx):
await ctx . send ( " ℹ ️ No cleanup has been run yet." )
await ctx . send ( " ℹ ️ No cleanup has been run yet." )
return
return
last_run_dt = datetime . datetime . fromisoformat ( last_run )
last_run_dt_utc = datetime . datetime . fromisoformat ( last_run )
now = datetime . datetime . utcnow ( )
if last_run_dt_utc . tzinfo is None :
next_run_dt = last_run_dt + datetime . timedelta ( hours = 24 )
last_run_dt_utc = pytz . utc . localize ( last_run_dt_utc )
time_remaining = nex t_run_dt - now
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 )
hours , remainder = divmod ( int ( time_remaining . total_seconds ( ) ) , 3600 )
minutes , seconds = divmod ( remainder , 60 )
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 ( )
@bot.command ( )
@@ -675,7 +817,7 @@ async def searchdiscord(ctx, user: discord.User = None):
async def scanlibraries ( ctx ) :
async def scanlibraries ( ctx ) :
log_event ( f " scanlibraries invoked by { ctx . author } " )
log_event ( f " scanlibraries invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
return
@@ -686,6 +828,70 @@ async def scanlibraries(ctx):
await ctx . send ( f " ❌ Failed to start library scan. Status code: { response . status_code } " )
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. """
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 ) # fallback to avoid division by zero
# Convert ticks to seconds (1 tick = 100 ns)
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_str = f " [ { 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 ( )
@bot.command ( )
async def link ( ctx , jellyfin_username : str = None , user : discord . User = None , js_id : str = None ) :
async def link ( ctx , jellyfin_username : str = None , user : discord . User = None , js_id : str = None ) :
log_event ( f " link invoked by { ctx . author } " )
log_event ( f " link invoked by { ctx . author } " )
@@ -734,7 +940,7 @@ async def setprefix(ctx, new_prefix: str = None):
return
return
member = ctx . guild . get_member ( ctx . author . id )
member = ctx . guild . get_member ( ctx . author . id )
if not member or not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
return
@@ -761,7 +967,7 @@ async def setprefix(ctx, new_prefix: str = None):
async def updates ( ctx ) :
async def updates ( ctx ) :
log_event ( f " updates invoked by { ctx . author } " )
log_event ( f " updates invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
return
@@ -779,7 +985,7 @@ async def updates(ctx):
async def logging ( ctx , state : str ) :
async def logging ( ctx , state : str ) :
""" Admin-only: Enable or disable event logging. """
""" Admin-only: Enable or disable event logging. """
member = ctx . guild . get_member ( ctx . author . id )
member = ctx . guild . get_member ( ctx . author . id )
if not member or not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
return
@@ -813,7 +1019,7 @@ async def logging(ctx, state: str):
async def help_command ( ctx ) :
async def help_command ( ctx ) :
log_event ( f " Command help invoked by { ctx . author } " )
log_event ( f " Command help invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
member = ctx . guild . get_member ( ctx . author . id )
is_admin = has_admin_role ( membe r)
is_admin = has_admin_role ( ctx . autho r)
embed = discord . Embed (
embed = discord . Embed (
title = f " 📖 Jellyfin Bot Help { BOT_VERSION } " ,
title = f " 📖 Jellyfin Bot Help { BOT_VERSION } " ,
@@ -826,6 +1032,7 @@ async def help_command(ctx):
f " ` { PREFIX } createaccount <username> <password>` - Create your Jellyfin account " ,
f " ` { PREFIX } createaccount <username> <password>` - Create your Jellyfin account " ,
f " ` { PREFIX } recoveraccount <newpassword>` - Reset your password " ,
f " ` { PREFIX } recoveraccount <newpassword>` - Reset your password " ,
f " ` { PREFIX } deleteaccount <username>` - Delete your Jellyfin account "
f " ` { PREFIX } deleteaccount <username>` - Delete your Jellyfin account "
f " ` { PREFIX } what2watch` - Lists 5 random movie suggestions from the Jellyfin Library "
]
]
# Only show trialaccount if enabled
# Only show trialaccount if enabled
@@ -836,15 +1043,23 @@ async def help_command(ctx):
# Admin commands
# Admin commands
if is_admin :
if is_admin :
# Dynamic link command line
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 link accounts with Jellyseerr "
embed . add_field ( name = " Admin Commands " , value = (
embed . add_field ( name = " Admin Commands " , value = (
f " ` { PREFIX } cleanup` - Remove Jellyfin accounts from users without roles \n "
f " ` { PREFIX } cleanup` - Remove Jellyfin accounts from users without roles \n "
f " ` { PREFIX } listvalidusers` - Show number of valid and invalid accounts \n "
f " ` { PREFIX } lastcleanup` - See Last cleanup time, and time remaining before next cleanup \n "
f " ` { PREFIX } lastcleanup` - See Last cleanup time, and time remaining before next cleanup \n "
f " ` { PREFIX } searchaccount <jellyfin_username>` - Find linked Discord user \n "
f " ` { PREFIX } searchaccount <jellyfin_username>` - Find linked Discord user \n "
f " ` { PREFIX } searchdiscord @user` - Find linked Jellyfin account \n "
f " ` { PREFIX } searchdiscord @user` - Find linked Jellyfin account \n "
f " ` { PREFIX } scanlibraries` - Scan all Jellyfin libraries \n "
f " ` { PREFIX } scanlibraries` - Scan all Jellyfin libraries \n "
f " ` { PREFIX } link <jellyfin_username> @user` - Manually link account s\n "
f " ` { PREFIX } activestreams` - View all Active Jellyfin stream s\n "
f " { link_command } \n "
f " ` { PREFIX } unlink @user` - Manually unlink accounts \n "
f " ` { PREFIX } unlink @user` - Manually unlink accounts \n "
) , inline = False )
) , inline = False )
embed . add_field ( name = " Admin Bot Commands " , value = (
embed . add_field ( name = " Admin Bot Commands " , value = (
f " ` { PREFIX } setprefix` - Change the bot ' s command prefix \n "
f " ` { PREFIX } setprefix` - Change the bot ' s command prefix \n "
f " ` { PREFIX } updates` - Manually check for bot updates \n "
f " ` { PREFIX } updates` - Manually check for bot updates \n "
@@ -857,54 +1072,138 @@ async def help_command(ctx):
# TASKS
# TASKS
# =====================
# =====================
import datetime
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 )
@tasks.loop ( hours = 24 )
async def daily_chec k( ) :
async def cleanup_tas k( ) :
guild = bot . get_guild ( GUILD_ID )
log_event ( " 🧹 Running daily account cleanup check... " )
removed = [ ]
removed = [ ]
# =======================
# Normal accounts cleanup
# Normal accounts cleanup
# =======================
for discord_id , jf_username , jf_id , js_id in get_accounts ( ) :
for discord_id , jf_username , jf_id , js_id in get_accounts ( ) :
m = guild . get_member ( discord_id )
member = None
if m is None or not has_required_role ( m ) :
for gid in GUILD_IDS :
if delete_jellyfin_user ( jf_username ) :
guild = bot . get_guild ( gid )
if guild :
member = guild . get_member ( discord_id )
if member :
break
if member is None or not has_required_role ( member ) :
if jf_username :
try :
if delete_jellyfin_user ( jf_username ) :
log_event ( f " Deleted Jellyfin user { jf_username } for Discord ID { discord_id } " )
else :
log_event ( f " Failed to delete Jellyfin user { jf_username } for Discord ID { discord_id } " )
except Exception as e :
print ( f " [Cleanup] Error deleting Jellyfin user { jf_username } : { e } " )
# remove DB entry for normal account
try :
delete_account ( discord_id )
delete_account ( discord_id )
removed . append ( jf_usernam e )
except Exception as e:
print ( f " [Cleanup] Error removing DB entry for Discord ID { discord_id } : { e } " )
# remove from Jellyseerr if applicable
if JELLYSEERR_ENABLED and js_id :
try :
if delete_jellyseerr_user ( js_id ) :
log_event ( f " Deleted Jellyseerr user { js_id } for Discord ID { discord_id } " )
else :
log_event ( f " Failed to delete Jellyseerr user { js_id } for Discord ID { discord_id } " )
except Exception as e :
print ( f " [Cleanup] Failed to delete Jellyseerr user { js_id } : { e } " )
removed . append ( jf_username or f " { discord_id } " )
# ======================
# Trial accounts cleanup
# Trial accounts cleanup
conn = mysql . connector . connect (
# ======================
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
try :
)
conn = mysql . connector . connect (
cur = conn . cursor ( dictionary = True )
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
cur . execute ( " SELECT * FROM trial_accounts WHERE expired=0 " )
)
trials = cur . fetchall ( )
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 :
for trial in trials :
created_at = trial [ " trial_created_at " ]
created_at_utc = trial . get ( " trial_created_at " ) or trial . get ( " created_at " )
if created_at and datetime . datetime . utcnow ( ) > created_at + datetime . timedelta ( hours = 24 ) :
if not created_at_utc :
# Delete from Jellyfin
continue
delete_jellyfin_user ( trial [ " jellyfin_username " ] )
# Mark trial as expired
cur . execute ( " UPDATE trial_accounts SET expired=1 WHERE discord_id= %s " , ( trial [ " discord_id " ] , ) )
conn . commit ( )
removed . append ( f " { trial [ ' jellyfin_username ' ] } (trial) " )
# 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 )
cur . close ( )
if now_local > created_at_local + datetime . timedelta ( hours = 24 ) :
conn . close ( )
# 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 } " )
# Record cleanup run
# Mark trial as expired
conn = mysql . connector . connect (
try :
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
cur . execute ( " UPDATE trial_accounts SET expired=1 WHERE discord_id= %s " , ( trial [ " discord_id " ] , ) )
)
conn . commit ( )
cur = conn . cursor ( )
except Exception as e :
cur . execute ( " INSERT INTO cleanup_logs (run_at) VALUES ( %s ) " , ( datetime . datetime . utcnow ( ) , ) )
print ( f " [Trial Cleanup] Error marking trial expired for { trial [ ' discord_id ' ] } : { e } " )
conn . commit ( )
cur . close ( )
conn . close ( )
removed . append ( f " { trial . get ( ' jellyfin_username ' ) } (trial) " )
except Exception as e :
print ( f " [Trial Cleanup] Error reading trial accounts: { e } " )
finally :
try :
cur . close ( )
conn . close ( )
except Exception :
pass
# ======================
# Update metadata & logs
# ======================
try :
set_metadata ( " last_cleanup " , datetime . datetime . now ( LOCAL_TZ ) . isoformat ( ) )
except Exception as e :
print ( f " [Cleanup] Failed to set last_cleanup metadata: { e } " )
try :
conn = mysql . connector . connect (
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 . 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 removed :
if removed :
print ( f " Cleanup r emoved { len ( removed ) } accounts: { removed } " )
msg = f " 🧹 R emoved { len ( removed ) } Jellyfin accounts: { ' , ' . join ( removed ) } "
print ( msg )
try :
log_channel = bot . get_channel ( SYNC_LOG_CHANNEL_ID )
if log_channel :
await log_channel . send ( msg )
except Exception as e :
print ( f " [Cleanup] Failed to send removed message to sync channel: { e } " )
@tasks.loop ( hours = 1 )
@tasks.loop ( hours = 1 )
@@ -927,8 +1226,6 @@ async def check_for_updates():
print ( f " [Update Check] Failed: { e } " )
print ( f " [Update Check] Failed: { e } " )
@bot.event
@bot.event
async def on_ready ( ) :
async def on_ready ( ) :
print ( f " Logged in as { bot . user } " )
print ( f " Logged in as { bot . user } " )
@@ -937,15 +1234,29 @@ async def on_ready():
# Check last cleanup
# Check last cleanup
last_run = get_metadata ( " last_cleanup " )
last_run = get_metadata ( " last_cleanup " )
if last_run :
if last_run :
last_run_dt = datetime . datetime . fromisoformat ( last_run )
# parse UTC timestamp from DB
now = datetime . datetime . utcnow ( )
last_run_dt_utc = datetime . datetime . fromisoformat ( last_run )
delta = now - last_run_dt
# 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 :
if delta . total_seconds ( ) > = 24 * 3600 :
print ( " Running missed daily cleanup... " )
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
check_for_updates . start ( )
if not cleanup_task . is_running ( ) :
await bot . change_presence ( activity = discord . Activity ( type = discord . ActivityType . watching , name = f " { PREFIX } help " ) )
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 " )
)
log_event ( f " ✅ Bot ready. Current time: { datetime . datetime . now ( LOCAL_TZ ) . strftime ( ' % Y- % m- %d % H: % M: % S % Z ' ) } " )
bot . run ( TOKEN )
bot . run ( TOKEN )