@@ -5,6 +5,8 @@ import mysql.connector
import asyncio
import os
from dotenv import load_dotenv
import pytz
import random
# =====================
# ENV + VALIDATION
@@ -22,13 +24,14 @@ def get_env_var(key: str, cast=str, required=True):
TOKEN = get_env_var ( " DISCORD_TOKEN " )
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 ( " , " ) ]
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 )
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 "
JELLYSEERR_ENABLED = os . getenv ( " JELLYSEERR_ENABLED " , " false " ) . lower ( ) == " true "
JELLYSEERR_URL = os . getenv ( " JELLYSEERR_URL " , " " ) . rstrip ( " / " )
@@ -39,9 +42,11 @@ 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.2 "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellyfin-Discord/main/version.txt "
RELEASES_URL = " https://github.com/PenguCCN/Jellyfin-Discord/releases "
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 "
RELEASES_URL = " https://github.com/PenguCCN/Jellycord/releases "
# =====================
# EVENT LOGGING
@@ -49,8 +54,10 @@ RELEASES_URL = "https://github.com/PenguCCN/Jellyfin-Discord/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
@@ -65,25 +72,27 @@ bot = commands.Bot(command_prefix=PREFIX, intents=intents, help_command=None)
# =====================
def init_db ( ) :
log_event ( f " Initiating Database... " )
# Create database if it doesn't exist
conn = mysql . connector . connect ( host = DB_HOST , user = DB_USER , password = DB_PASSWORD )
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD
)
cur = conn . cursor ( )
cur . execute ( f " CREATE DATABASE IF NOT EXISTS ` { DB_NAME } ` " )
conn . commit ( )
cur . close ( )
conn . close ( )
# Connect to the database
conn = mysql . connector . connect ( host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME )
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
)
cur = conn . cursor ( )
# Create accounts table if it doesn't exist
# Normal accounts table
cur . execute ( """
CREATE TABLE IF NOT EXISTS accounts (
discord_id BIGINT PRIMARY KEY,
jellyfin_username VARCHAR(255) NOT NULL,
jellyfin_id VARCHAR(255) NOT NULL,
jellyseerr_id VARCHAR(255) DEFAULT NULL
jellyseerr_id VARCHAR(255)
)
""" )
@@ -99,7 +108,18 @@ def init_db():
cur . execute ( " ALTER TABLE accounts ADD COLUMN jellyseerr_id VARCHAR(255) DEFAULT NULL " )
print ( " [DB] Added missing column ' jellyseerr_id ' to accounts table. " )
# Create bot_metadata table if it doesn't exist
# Trial accounts table (persistent history, one-time only)
cur . execute ( """
CREATE TABLE IF NOT EXISTS trial_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
discord_id BIGINT NOT NULL UNIQUE,
jellyfin_username VARCHAR(255),
jellyfin_id VARCHAR(255),
trial_created_at DATETIME NOT NULL,
expired BOOLEAN DEFAULT 0
)
""" )
cur . execute ( """
CREATE TABLE IF NOT EXISTS bot_metadata (
key_name VARCHAR(255) PRIMARY KEY,
@@ -107,6 +127,14 @@ def init_db():
)
""" )
# Cleanup logs table
cur . execute ( """
CREATE TABLE IF NOT EXISTS cleanup_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
run_at DATETIME NOT NULL
)
""" )
conn . commit ( )
cur . close ( )
conn . close ( )
@@ -125,6 +153,26 @@ def add_account(discord_id, username, jf_id, js_id=None):
cur . close ( )
conn . close ( )
def init_trial_accounts_table ( ) :
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
)
cur = conn . cursor ( )
# Persistent trial accounts table
cur . execute ( """
CREATE TABLE IF NOT EXISTS trial_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
discord_id BIGINT NOT NULL UNIQUE,
jellyfin_username VARCHAR(255) NOT NULL,
jellyfin_id VARCHAR(255) NOT NULL,
trial_created_at DATETIME NOT NULL,
expired BOOLEAN DEFAULT 0
)
""" )
conn . commit ( )
cur . close ( )
conn . close ( )
def get_accounts ( ) :
conn = mysql . connector . connect (
@@ -269,11 +317,30 @@ def delete_jellyseerr_user(js_id: str) -> bool:
# =====================
# 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 ) :
return any ( role . id in ADMIN_ROLE_IDS for role in member . roles )
def has_required _role ( user : discord . User | discord . Member ) - > bool :
""" 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
@@ -302,6 +369,33 @@ def get_metadata(key):
conn . close ( )
return row [ 0 ] if row else None
def create_trial_jellyfin_user ( username , password ) :
payload = {
" Name " : username ,
" Password " : password ,
" Policy " : {
" EnableDownloads " : False ,
" EnableSyncTranscoding " : False ,
" EnableRemoteControlOfOtherUsers " : False ,
" EnableLiveTvAccess " : False ,
" IsAdministrator " : False ,
" IsHidden " : False ,
" IsDisabled " : False
}
}
headers = {
" X-Emby-Token " : JELLYFIN_API_KEY ,
" Content-Type " : " application/json "
}
response = requests . post ( f " { JELLYFIN_URL } /Users/New " , json = payload , headers = headers )
if response . status_code == 200 :
return response . json ( ) . get ( " Id " )
else :
print ( f " [Jellyfin] Trial user creation failed. Status: { response . status_code } , Response: { response . text } " )
return None
# =====================
# EVENTS
# =====================
@@ -349,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. " )
return
guild = bot . get_guild ( GUILD_ID )
member = guild . get_member ( ctx . author . id ) if guild else None
member = 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 ) :
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have the required role. " )
return
@@ -381,6 +481,83 @@ 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 trialaccount ( ctx , username : str = None , password : str = None ) :
""" Create a 24-hour trial Jellyfin account. DM-only, one-time per user. """
log_event ( f " trialaccount invoked by { ctx . author } " )
# Ensure trial accounts are enabled
if not ENABLE_TRIAL_ACCOUNTS :
await ctx . send ( " ❌ Trial accounts are currently disabled. " )
return
# Ensure it's a DM
if not isinstance ( ctx . channel , discord . DMChannel ) :
try :
await ctx . message . delete ( )
except discord . Forbidden :
pass
await ctx . send ( f " { ctx . author . mention } ❌ Please DM me to create a trial account. " )
return
# Ensure required arguments
if username is None or password is None :
await ctx . send ( command_usage ( f " { PREFIX } trialaccount " , [ " <username> " , " <password> " ] ) )
return
member = 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 ) :
await ctx . send ( f " ❌ { ctx . author . mention } , you don’ t have the required role. " )
return
# Check if user already has a normal Jellyfin account
if get_account_by_discord ( ctx . author . id ) :
await ctx . send ( f " ❌ { ctx . author . mention } , you already have a Jellyfin account. " )
return
# Check if user already had a trial account (one-time)
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
)
cur = conn . cursor ( )
cur . execute ( " SELECT * FROM trial_accounts WHERE discord_id= %s " , ( ctx . author . id , ) )
existing_trial = cur . fetchone ( )
if existing_trial :
cur . close ( )
conn . close ( )
await ctx . send ( f " ❌ { ctx . author . mention } , you have already used your trial account. You cannot create another. " )
return
# Create Jellyfin trial user
if create_jellyfin_user ( username , password ) :
jf_id = get_jellyfin_user ( username )
if not jf_id :
await ctx . send ( f " ❌ Failed to fetch Jellyfin ID for ** { username } **. Please contact an admin. " )
return
# Store trial account info in separate persistent table
cur . execute ( """
INSERT INTO trial_accounts (discord_id, jellyfin_username, jellyfin_id, trial_created_at, expired)
VALUES ( %s , %s , %s , NOW(), 0)
""" , ( ctx . author . id , username , jf_id ) )
conn . commit ( )
cur . close ( )
conn . close ( )
await ctx . send ( f " ✅ Trial Jellyfin account ** { username } ** created! It will expire in 24 hours. \n 🌐 Login here: { JELLYFIN_URL } " )
log_event ( f " Trial account created for { ctx . author } ( { username } ) " )
else :
cur . close ( )
conn . close ( )
await ctx . send ( f " ❌ Failed to create trial account ** { username } **. It may already exist. " )
@bot.command ( )
async def recoveraccount ( ctx , new_password : str = None ) :
@@ -439,17 +616,87 @@ 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 ) :
log_event ( f " cleanup invoked by { ctx . author } " )
guild = bot . get_guild ( GUILD_ID )
removed = [ ]
for discord_id , jf_username in get_accounts ( ) :
m = guild . get_member ( discord_id )
if m is None or not has_required_role ( m ) :
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 guild :
member = guild . get_member ( discord_id )
if member :
break
if member is None or not has_required_role ( member ) :
if delete_jellyfin_user ( jf_username ) :
delete_account ( discord_id )
if JELLYSEERR_ENABLED and js_id :
try :
headers = { " X-Api-Key " : JELLYSEERR_API_KEY }
dr = requests . delete ( f " { JELLYSEERR_URL } /api/v1/user/ { js_id } " , headers = headers , timeout = 10 )
if dr . status_code in ( 200 , 204 ) :
print ( f " [Jellyseerr] User { js_id } removed successfully. " )
except Exception as e :
print ( f " [Jellyseerr] Failed to delete user { js_id } : { e } " )
removed . append ( jf_username )
log_channel = bot . get_channel ( SYNC_LOG_CHANNEL_ID )
@@ -458,12 +705,59 @@ async def cleanup(ctx):
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 ( )
async def lastcleanup ( ctx ) :
log_event ( f " lastcleanup invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to view the last cleanup. " )
return
@@ -472,15 +766,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 ( )
@@ -517,7 +817,7 @@ async def searchdiscord(ctx, user: discord.User = None):
async def scanlibraries ( ctx ) :
log_event ( f " scanlibraries invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
@@ -528,6 +828,70 @@ 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. """
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 ( )
async def link ( ctx , jellyfin_username : str = None , user : discord . User = None , js_id : str = None ) :
log_event ( f " link invoked by { ctx . author } " )
@@ -576,7 +940,7 @@ async def setprefix(ctx, new_prefix: str = None):
return
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. " )
return
@@ -603,7 +967,7 @@ async def setprefix(ctx, new_prefix: str = None):
async def updates ( ctx ) :
log_event ( f " updates invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
if not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
@@ -621,7 +985,7 @@ async def updates(ctx):
async def logging ( ctx , state : str ) :
""" Admin-only: Enable or disable event logging. """
member = ctx . guild . get_member ( ctx . author . id )
if not member or not has_admin_role ( membe r) :
if not has_admin_role ( ctx . autho r) :
await ctx . send ( " ❌ You don’ t have permission to use this command. " )
return
@@ -655,7 +1019,7 @@ async def logging(ctx, state: str):
async def help_command ( ctx ) :
log_event ( f " Command help invoked by { ctx . author } " )
member = ctx . guild . get_member ( ctx . author . id )
is_admin = has_admin_role ( membe r)
is_admin = has_admin_role ( ctx . autho r)
embed = discord . Embed (
title = f " 📖 Jellyfin Bot Help { BOT_VERSION } " ,
@@ -663,24 +1027,41 @@ async def help_command(ctx):
color = discord . Color . blue ( )
)
embed . add_field ( name = " User C ommands" , value = (
f " ` { PREFIX } createaccount <username> <password>` - Create your Jellyfin account \n "
f " ` { PREFIX } recover account <new password>` - Reset your password \n "
f " ` { PREFIX } delete account <username >` - Del ete your Jellyfin account \n "
) , inline = False )
# User c ommands
user_cmds = [
f " ` { PREFIX } c reate account <username> < password>` - Create your Jellyfin account " ,
f " ` { PREFIX } recover account <newpassword >` - Res et your password " ,
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 )
# Admin commands
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 = (
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 } 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 " ` { 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 "
) , inline = False )
embed . add_field ( name = " Admin Bot Commands " , value = (
f " ` { PREFIX } setprefix` - Change the bots command prefix \n "
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 )
@@ -691,25 +1072,139 @@ async def help_command(ctx):
# 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( ) :
guild = bot . get_guild ( GUILD_ID )
async def cleanup_tas k( ) :
log_event ( " 🧹 Running daily account cleanup check... " )
removed = [ ]
for discord_id , jf_username in get_accounts ( ) :
m = guild . get_member ( discord_id )
if m is None or not has_required_role ( m ) :
i f delete_jellyfin_user ( jf_username ) :
# =======================
# Normal accounts cleanup
# =======================
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 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 )
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
# ======================
try :
conn = mysql . connector . connect (
host = DB_HOST , user = DB_USER , password = DB_PASSWORD , database = DB_NAME
)
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_utc = trial . get ( " trial_created_at " ) or trial . get ( " created_at " )
if not created_at_utc :
continue
# 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 } " )
# Mark trial as expired
try :
cur . execute ( " UPDATE trial_accounts SET expired=1 WHERE discord_id= %s " , ( trial [ " discord_id " ] , ) )
conn . commit ( )
except Exception as e :
print ( f " [Trial Cleanup] Error marking trial expired for { trial [ ' discord_id ' ] } : { e } " )
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 :
print ( f " Daily 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 } " )
# Log last run timestamp
set_metadata ( " last_cleanup " , datetime . datetime . utcnow ( ) . isoformat ( ) )
log_event ( f " Daily cleanup: removed { len ( removed ) } accounts: { removed } " )
@tasks.loop ( hours = 1 )
async def check_for_updates ( ) :
@@ -731,8 +1226,6 @@ async def check_for_updates():
print ( f " [Update Check] Failed: { e } " )
@bot.event
async def on_ready ( ) :
print ( f " Logged in as { bot . user } " )
@@ -741,15 +1234,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 ( )
check_for_updates . start ( )
await bot . change_presence ( activity = discord . Activity ( type = discord . ActivityType . watching , name = f " { PREFIX } help " ) )
# 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 " )
)
log_event ( f " ✅ Bot ready. Current time: { datetime . datetime . now ( LOCAL_TZ ) . strftime ( ' % Y- % m- %d % H: % M: % S % Z ' ) } " )
bot . run ( TOKEN )