@@ -5,6 +5,8 @@ import mysql.connector
import asyncio
import os
from dotenv import load_dotenv
import pytz
import random
# =====================
# ENV + VALIDATION
@@ -40,7 +42,9 @@ 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.5 "
VERSION_URL = " https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt "
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 "
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
@@ -610,6 +616,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 +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 ( )
@@ -763,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 } " )
@@ -903,6 +1032,7 @@ async def help_command(ctx):
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 } what2watch` - Lists 5 random movie suggestions from the Jellyfin Library "
]
# Only show trialaccount if enabled
@@ -925,6 +1055,7 @@ async def help_command(ctx):
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 } activestreams` - View all Active Jellyfin streams \n "
f " { link_command } \n "
f " ` { PREFIX } unlink @user` - Manually unlink accounts \n "
) , inline = False )
@@ -941,32 +1072,31 @@ 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( ) :
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 +1113,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 +1125,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 +1135,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 +1172,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 +1185,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 +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 ( )
# 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 )