8 Commits
1.0.3 ... 1.0.5

Author SHA1 Message Date
3b2328423b 1.0.5 (Added Stream Viewing) 2025-09-10 11:08:08 -05:00
77f1b539e6 Added random movie suggestion command 2025-09-10 10:29:31 -05:00
c32875223c Fix TZ Issues 2025-09-10 10:22:43 -05:00
175d30abc9 Update .env 2025-09-10 10:15:11 -05:00
642cf7341d Update CHANGELOG.md 2025-09-10 10:14:43 -05:00
1f97965599 Add TimeZone support 2025-09-10 10:14:10 -05:00
2624f1ec06 1.0.4 2025-09-06 22:11:42 -05:00
bfb16c503a Multi-Guild Support 2025-09-06 22:04:15 -05:00
7 changed files with 407 additions and 78 deletions

5
.env
View File

@@ -1,7 +1,7 @@
# Discord # Discord
DISCORD_TOKEN=your_discord_bot_token DISCORD_TOKEN=your_discord_bot_token
PREFIX=! PREFIX=!
GUILD_ID=123456789012345678 GUILD_ID=123456789012345678,123456789012345678
ADMIN_ROLE_IDS=111111111111111111,222222222222222222 ADMIN_ROLE_IDS=111111111111111111,222222222222222222
REQUIRED_ROLE_IDS=333333333333333333,444444444444444444 REQUIRED_ROLE_IDS=333333333333333333,444444444444444444
@@ -21,6 +21,9 @@ DB_USER=root
DB_PASSWORD=password DB_PASSWORD=password
DB_NAME=jellyfin_bot DB_NAME=jellyfin_bot
# Time Settings
TIMEZONE=America/Chicago
# Logs # Logs
SYNC_LOG_CHANNEL_ID=555555555555555555 SYNC_LOG_CHANNEL_ID=555555555555555555
EVENT_LOGGING=false EVENT_LOGGING=false

View File

@@ -1,3 +1,14 @@
# 1.0.5
- Added Timezone support in .env
- Added the `what2watch` command. Lists 5 random movie suggestions from the Jellyfin Library
- Added `activestreams` command. Lists all active Jellyfin Streams
# 1.0.4
- Multi-Guild support (As long as a user has a required role or admin role in one server, they are able to use the bot and Jellyfin)
- Fixed link command instructions not updating based on Jellyseerr availability
# 1.0.3 # 1.0.3
- Fixed: `ValueError: too many values to unpack (expected 2)` - Fixed: `ValueError: too many values to unpack (expected 2)`

View File

@@ -49,6 +49,7 @@ Fill out values in the .env and you're good to go!
- `!recoveraccount` <username> <newpassword> - Reset your password - `!recoveraccount` <username> <newpassword> - Reset your password
- `!deleteaccount` <username> - Delete your Jellyfin account - `!deleteaccount` <username> - Delete your Jellyfin account
- `!trialaccount` <username> <password> - Create a 24-hour trial Jellyfin account. Only if ENABLE_TRIAL_ACCOUNTS=True - `!trialaccount` <username> <password> - Create a 24-hour trial Jellyfin account. Only if ENABLE_TRIAL_ACCOUNTS=True
- `!what2watch` - Lists 5 random movie suggestions from the Jellyfin Library
***Admin Commands*** ***Admin Commands***
- `!cleanup` - Remove Jellyfin accounts from users without roles - `!cleanup` - Remove Jellyfin accounts from users without roles
@@ -56,6 +57,7 @@ Fill out values in the .env and you're good to go!
- `!searchaccount` <jellyfin_username> - Find linked Discord user - `!searchaccount` <jellyfin_username> - Find linked Discord user
- `!searchdiscord` @user - Find linked Jellyfin account - `!searchdiscord` @user - Find linked Jellyfin account
- `!scanlibraries` - Scan all Jellyfin libraries - `!scanlibraries` - Scan all Jellyfin libraries
- `!activestreams` - View all Active Jellyfin streams
- `!link` <jellyfin_username> @user - Manually link accounts - `!link` <jellyfin_username> @user - Manually link accounts
- `!unlink` @user - Manually unlink accounts - `!unlink` @user - Manually unlink accounts

459
app.py
View File

@@ -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.utcnow().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 dont have the required role.") await ctx.send(f"{ctx.author.mention}, you dont 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 dont have the required role.") await ctx.send(f"{ctx.author.mention}, you dont 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 dont 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}\nRuntime: {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 dont 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(member): if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to view the last cleanup.") await ctx.send("❌ You dont 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 = next_run_dt - now last_run_local = last_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(member): if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.") await ctx.send("❌ You dont 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 dont 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(member): if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.") await ctx.send("❌ You dont 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(member): if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.") await ctx.send("❌ You dont 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(member): if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.") await ctx.send("❌ You dont 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(member) is_admin = has_admin_role(ctx.author)
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 accounts\n" f"`{PREFIX}activestreams` - View all Active Jellyfin streams\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_check(): async def cleanup_task():
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_username) 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 removed {len(removed)} accounts: {removed}") msg = f"🧹 Removed {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_check() # Run immediately if overdue await cleanup_task() # run 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)

View File

@@ -2,3 +2,5 @@ discord.py==2.3.2
requests==2.32.3 requests==2.32.3
mysql-connector-python==9.0.0 mysql-connector-python==9.0.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pytz==2025.2
apscheduler==3.11.0

View File

@@ -1 +1 @@
{ "version": "1.0.3" } { "version": "1.0.5" }

View File

@@ -1 +1 @@
1.0.3 1.0.5