10 Commits
1.0.4 ... 1.0.6

Author SHA1 Message Date
e5e22b584a Added qBittorrent Support, reformatted help command 2025-09-10 19:26:23 -05:00
1f2c251e80 Jfa-Go Support 2025-09-10 18:29:02 -05:00
a0b4a2087a Added Progress bar to Active Streams 2025-09-10 17:21:30 -05:00
d62f6f6470 Fix help command 2025-09-10 17:08:45 -05:00
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
7 changed files with 630 additions and 74 deletions

14
.env
View File

@@ -15,12 +15,26 @@ JELLYSEERR_ENABLED=false
JELLYSEERR_URL=http://localhost:5055
JELLYSEERR_API_KEY=your_api_key_here
# JFA-Go
ENABLE_JFA=false
JFA_URL=http://localhost:8056
JFA_API_KEY=your_api_key_here
# QBittorrent
ENABLE_QBITTORRENT=false
QBIT_HOST=http://localhost:8080
QBIT_USERNAME=your_username
QBIT_PASSWORD=your_password
# MySQL
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=password
DB_NAME=jellyfin_bot
# Time Settings
TIMEZONE=America/Chicago
# Logs
SYNC_LOG_CHANNEL_ID=555555555555555555
EVENT_LOGGING=false

View File

@@ -1,3 +1,16 @@
# 1.0.6
- Added Progress bar to Active Streams
- Added JFA-Go support for external invites
- Added qBittorrent Support
- Reformated Help Command
# 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)

View File

@@ -44,22 +44,35 @@ Fill out values in the .env and you're good to go!
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/role-required.png)
***User Commands***
***🎬 User Commands***
- `!createaccount` <username> <password> - Create your Jellyfin account
- `!recoveraccount` <username> <newpassword> - Reset your password
- `!deleteaccount` <username> - Delete your Jellyfin account
- `!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
- `!help` - Displays help command
***Admin Commands***
***🛠️ Admin Commands***
- `!link` <jellyfin_username> @user - Manually link accounts
- `!unlink` @user - Manually unlink accounts
- `!listvalidusers` - Show number of valid and invalid accounts
- `!cleanup` - Remove Jellyfin accounts from users without roles
- `!lastcleanup` - See Last cleanup time, and time remaining before next cleanup
- `!searchaccount` <jellyfin_username> - Find linked Discord user
- `!searchdiscord` @user - Find linked Jellyfin account
- `!scanlibraries` - Scan all Jellyfin libraries
- `!link` <jellyfin_username> @user - Manually link accounts
- `!unlink` @user - Manually unlink accounts
- `!activestreams` - View all Active Jellyfin streams
***Admin Bot Commands***
***💾 qBittorrent Commands***
- `!qbview` - View current qBittorrent downloads
***🔑 JFA Commands***
- `!createinvite` - Create a new JFA invite link
- `!listinvites` - List all active JFA invite links
- `!deleteinvite <code>` - Delete a specific JFA Invite
***⚙️ Admin Bot Commands***
- `!setprefix` - Change the bots command prefix
- `!updates` - Manually check for bot updates
- `!logging` - Enable/Disable Console Event Logging

641
app.py
View File

@@ -5,6 +5,9 @@ import mysql.connector
import asyncio
import os
from dotenv import load_dotenv
import pytz
import random
import qbittorrentapi
# =====================
# ENV + VALIDATION
@@ -35,12 +38,23 @@ JELLYSEERR_ENABLED = os.getenv("JELLYSEERR_ENABLED", "false").lower() == "true"
JELLYSEERR_URL = os.getenv("JELLYSEERR_URL", "").rstrip("/")
JELLYSEERR_API_KEY = os.getenv("JELLYSEERR_API_KEY", "")
ENABLE_JFA = os.getenv("ENABLE_JFA", "False").lower() == "true"
JFA_URL = os.getenv("JFA_URL")
JFA_API_KEY = os.getenv("JFA_API_KEY")
ENABLE_QBITTORRENT = os.getenv("ENABLE_QBITTORRENT", "False").lower() == "true"
QBIT_HOST = os.getenv("QBIT_HOST")
QBIT_USERNAME = os.getenv("QBIT_USERNAME")
QBIT_PASSWORD = os.getenv("QBIT_PASSWORD")
DB_HOST = get_env_var("DB_HOST")
DB_USER = get_env_var("DB_USER")
DB_PASSWORD = get_env_var("DB_PASSWORD")
DB_NAME = get_env_var("DB_NAME")
BOT_VERSION = "1.0.4"
LOCAL_TZ = pytz.timezone(get_env_var("LOCAL_TZ", str, required=False) or "America/Chicago")
BOT_VERSION = "1.0.6"
VERSION_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt"
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
@@ -50,8 +64,10 @@ RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
EVENT_LOGGING = os.getenv("EVENT_LOGGING", "false").lower() == "true"
def log_event(message: str):
"""Log events to console if enabled in .env."""
if EVENT_LOGGING:
print(f"[EVENT] {datetime.datetime.utcnow().isoformat()} | {message}")
now_local = datetime.datetime.now(LOCAL_TZ)
print(f"[EVENT] {now_local.isoformat()} | {message}")
# =====================
# DISCORD SETUP
@@ -61,6 +77,20 @@ intents.members = True
intents.message_content = True
bot = commands.Bot(command_prefix=PREFIX, intents=intents, help_command=None)
# =====================
# QBITTORRENT SETUP
# =====================
qb = qbittorrentapi.Client(
host=QBIT_HOST,
username=QBIT_USERNAME,
password=QBIT_PASSWORD
)
try:
qb.auth_log_in()
except qbittorrentapi.LoginFailed:
print("Failed to log in to qBittorrent API")
# =====================
# DATABASE SETUP
# =====================
@@ -308,6 +338,16 @@ def delete_jellyseerr_user(js_id: str) -> bool:
print(f"[Jellyseerr] Failed to delete user {js_id}: {e}")
return False
# =====================
# QBITTORRENT HELPERS
# =====================
def progress_bar(progress: float, length: int = 20) -> str:
"""Return a textual progress bar for the embed."""
filled_length = int(length * progress)
bar = '' * filled_length + '' * (length - filled_length)
return f"[{bar}] {progress*100:.2f}%"
# =====================
# DISCORD HELPERS
# =====================
@@ -475,6 +515,243 @@ async def createaccount(ctx, username: str = None, password: str = None):
else:
await ctx.send(f"❌ Failed to create Jellyfin account **{username}**. It may already exist.")
@bot.command()
async def createinvite(ctx):
"""Admin-only: Create a new JFA-Go invite link (create -> fetch latest invite)."""
if not ENABLE_JFA:
await ctx.send("❌ JFA support is not enabled in the bot configuration.")
return
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
payload = {"days": 7, "max_uses": 1}
base = JFA_URL.rstrip("/")
# Try Bearer, fallback to X-Api-Key
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
r = requests.post(f"{base}/invites", headers=headers, json=payload, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.post(f"{base}/invites", headers=headers, json=payload, timeout=10)
if r.status_code not in (200, 201):
await ctx.send(f"❌ Failed to create invite. Status code: {r.status_code}\nResponse: {r.text}")
return
# Fetch invites list (some JFA builds only return success on POST)
r2 = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r2.status_code not in (200, 201):
await ctx.send(f"❌ Failed to fetch invite list. Status code: {r2.status_code}\nResponse: {r2.text}")
return
invites_resp = r2.json()
# Normalize different shapes: either {'invites': [...]} or a list
if isinstance(invites_resp, dict) and "invites" in invites_resp:
invites_list = invites_resp["invites"]
elif isinstance(invites_resp, list):
invites_list = invites_resp
else:
# unexpected shape
print(f"[createinvite] Unexpected invites response shape: {invites_resp}")
await ctx.send("❌ Unexpected response from JFA when fetching invites. Check bot logs.")
return
if not invites_list:
await ctx.send("❌ No invites found after creation.")
return
latest = invites_list[-1] # assume newest is last; adjust if your JFA sorts differently
print(f"[createinvite] Latest invite object: {latest}") # debug log
code = latest.get("code") or latest.get("id") or latest.get("token")
url = latest.get("url") or latest.get("link")
if not url and code:
# Common invite URL pattern; adjust if your instance is different
url = f"{base}/invite/{code}"
# created: JFA gives epoch seconds in 'created'
created_local_str = None
created_ts = latest.get("created")
if created_ts:
try:
created_dt = datetime.datetime.utcfromtimestamp(int(created_ts)).replace(tzinfo=datetime.timezone.utc)
created_local = created_dt.astimezone(LOCAL_TZ)
created_local_str = created_local.strftime("%Y-%m-%d %H:%M:%S %Z")
except Exception:
created_local_str = None
remaining = latest.get("remaining-uses", "N/A")
embed = discord.Embed(
title="🎟️ New Jellyfin Invite Created",
color=discord.Color.blue()
)
embed.add_field(name="Code", value=f"`{code}`" if code else "N/A", inline=True)
embed.add_field(name="Link", value=f"[Click here]({url})" if url else "N/A", inline=True)
footer_parts = []
if created_local_str:
footer_parts.append(f"Created: {created_local_str}")
footer_parts.append(f"Remaining uses: {remaining}")
embed.set_footer(text="".join(footer_parts))
embed.set_author(name=f"Created by {ctx.author.display_name}", icon_url=ctx.author.avatar.url if ctx.author.avatar else None)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error creating invite: {e}")
print(f"[createinvite] Error: {e}", exc_info=True)
@bot.command()
async def listinvites(ctx):
"""Admin-only: List all active JFA-Go invites."""
if not ENABLE_JFA:
await ctx.send("❌ JFA support is not enabled in the bot configuration.")
return
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
base = JFA_URL.rstrip("/")
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code not in (200, 201):
await ctx.send(f"❌ Failed to fetch invites. Status code: {r.status_code}\nResponse: {r.text}")
return
invites_resp = r.json()
print(f"[listinvites] Raw response: {invites_resp}") # Debug
# Normalize to a list of invite dicts
if isinstance(invites_resp, dict) and "invites" in invites_resp:
invites_list = invites_resp["invites"]
elif isinstance(invites_resp, list):
invites_list = invites_resp
else:
await ctx.send("❌ Unexpected invite response format. Check logs.")
return
if not invites_list:
await ctx.send(" No active invites found.")
return
embed = discord.Embed(
title="📋 Active Jellyfin Invites",
color=discord.Color.green()
)
for invite in invites_list:
code = invite.get("code")
url = f"{base}/invite/{code}" if code else None
remaining = invite.get("remaining-uses", "N/A")
created_str = None
created_ts = invite.get("created")
if created_ts:
try:
created_dt = datetime.datetime.utcfromtimestamp(int(created_ts)).replace(tzinfo=datetime.timezone.utc)
created_local = created_dt.astimezone(LOCAL_TZ)
created_str = created_local.strftime("%Y-%m-%d %H:%M:%S %Z")
except Exception:
created_str = None
value = f"Uses left: {remaining}"
if url:
value += f"\n[Invite Link]({url})"
if created_str:
value += f"\nCreated: {created_str}"
embed.add_field(
name=f"🔑 {code}",
value=value,
inline=False
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error fetching invites: {e}")
print(f"[listinvites] Error: {e}", exc_info=True)
@bot.command()
async def deleteinvite(ctx, code: str):
"""Admin-only: Delete a specific JFA-Go invite by code."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
base = JFA_URL.rstrip("/")
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
# Try DELETE with body (legacy API)
r = requests.delete(f"{base}/invites", headers=headers, json={"code": code}, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.delete(f"{base}/invites", headers=headers, json={"code": code}, timeout=10)
if r.status_code in (200, 204):
await ctx.send(f"✅ Invite `{code}` has been deleted.")
else:
await ctx.send(f"❌ Failed to delete invite `{code}`. Status code: {r.status_code}\nResponse: {r.text}")
except Exception as e:
await ctx.send(f"❌ Error deleting invite: {e}")
print(f"[deleteinvite] Error: {e}", exc_info=True)
@bot.command()
async def clearinvites(ctx):
"""Admin-only: Delete ALL JFA-Go invites (use with caution!)."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
base = JFA_URL.rstrip("/")
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code not in (200, 201):
await ctx.send(f"❌ Failed to fetch invites. Status code: {r.status_code}\nResponse: {r.text}")
return
invites_resp = r.json()
invites_list = invites_resp["invites"] if isinstance(invites_resp, dict) and "invites" in invites_resp else invites_resp
if not invites_list:
await ctx.send(" No invites to delete.")
return
deleted = 0
for invite in invites_list:
code = invite.get("code")
if not code:
continue
dr = requests.delete(f"{base}/invites", headers=headers, json={"code": code}, timeout=10)
if dr.status_code in (200, 204):
deleted += 1
await ctx.send(f"✅ Deleted {deleted} invites.")
except Exception as e:
await ctx.send(f"❌ Error clearing invites: {e}")
print(f"[clearinvites] Error: {e}", exc_info=True)
@bot.command()
async def trialaccount(ctx, username: str = None, password: str = None):
"""Create a 24-hour trial Jellyfin account. DM-only, one-time per user."""
@@ -610,6 +887,59 @@ async def deleteaccount(ctx, username: str = None):
else:
await ctx.send(f"❌ Failed to delete Jellyfin account **{username}**.")
@bot.command()
async def what2watch(ctx):
"""Pick 5 random movies from the Jellyfin library with embeds and posters."""
member = ctx.guild.get_member(ctx.author.id) if ctx.guild else None
if not member or not has_required_role(member):
await ctx.send(f"{ctx.author.mention}, you 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()
async def cleanup(ctx):
@@ -707,15 +1037,21 @@ async def lastcleanup(ctx):
await ctx.send(" No cleanup has been run yet.")
return
last_run_dt = datetime.datetime.fromisoformat(last_run)
now = datetime.datetime.utcnow()
next_run_dt = last_run_dt + datetime.timedelta(hours=24)
time_remaining = next_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 = 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)
minutes, seconds = divmod(remainder, 60)
await ctx.send(f"🧹 Last cleanup ran at **{last_run_dt.strftime('%Y-%m-%d %H:%M:%S')} UTC**\n⏳ Time until next cleanup: {hours}h {minutes}m {seconds}s")
await ctx.send(
f"🧹 Last cleanup ran at **{last_run_local.strftime('%Y-%m-%d %H:%M:%S %Z')}**\n"
f"⏳ Time until next cleanup: {hours}h {minutes}m {seconds}s"
)
@bot.command()
@@ -763,6 +1099,132 @@ async def scanlibraries(ctx):
await ctx.send(f"❌ Failed to start library scan. Status code: {response.status_code}")
@bot.command()
async def activestreams(ctx):
"""Admin-only: Show currently active Jellyfin user streams (movies/episodes only) with progress bar."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You 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) # avoid div by zero
position_seconds = position_ticks / 10_000_000
runtime_seconds = runtime_ticks / 10_000_000
position_str = str(datetime.timedelta(seconds=int(position_seconds)))
runtime_str = str(datetime.timedelta(seconds=int(runtime_seconds)))
# Progress bar
percent = position_seconds / runtime_seconds if runtime_seconds > 0 else 0
bar_length = 10
filled_length = int(round(bar_length * percent))
bar = "" * filled_length + "" * (bar_length - filled_length)
progress_str = f"{bar} {int(percent*100)}%\n[{position_str} / {runtime_str}]"
except Exception:
progress_str = "Unknown"
embed.add_field(
name=f"{media_name} ({media_type})",
value=f"👤 {user_name}\n📱 {device}\n⏱ Progress: {progress_str}",
inline=False
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error fetching active streams: {e}")
print(f"[activestreams] Error: {e}")
@bot.command()
async def qbview(ctx):
"""Admin-only: View current qBittorrent downloads."""
if not ENABLE_QBITTORRENT:
await ctx.send("❌ qBittorrent support is not enabled in the bot configuration.")
return
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
torrents = qb.torrents_info()
embed = discord.Embed(title="qBittorrent Downloads", color=0x00ff00)
if not torrents:
embed.description = "No torrents found."
await ctx.send(embed=embed)
return
# Group torrents by state
state_groups = {
"Downloading / Uploading": [],
"Finished": [],
"Stalled": [],
"Checking / Metadata": [],
"Other": []
}
for t in torrents:
if t.state in ("downloading", "uploading"):
state_groups["Downloading / Uploading"].append(t)
elif t.state in ("completed", "pausedUP", "pausedDL"):
state_groups["Finished"].append(t)
elif t.state in ("stalledUP", "stalledDL"):
state_groups["Stalled"].append(t)
elif t.state in ("checkingUP", "checkingDL", "checking", "metaDL"):
state_groups["Checking / Metadata"].append(t)
else:
state_groups["Other"].append(t)
# Add torrents to embed
for group_name, torrents_list in state_groups.items():
if torrents_list:
value_text = ""
for torrent in torrents_list:
value_text += (
f"{torrent.name}\n"
f"{progress_bar(torrent.progress)}\n"
f"Peers: {torrent.num_leechs} | Seeders: {torrent.num_seeds}\n"
f"Status: {torrent.state}\n\n"
)
embed.add_field(name=group_name, value=value_text, inline=False)
await ctx.send(embed=embed)
@bot.command()
async def link(ctx, jellyfin_username: str = None, user: discord.User = None, js_id: str = None):
log_event(f"link invoked by {ctx.author}")
@@ -898,75 +1360,100 @@ async def help_command(ctx):
color=discord.Color.blue()
)
# User commands
# --- Jellyfin User Commands ---
user_cmds = [
f"`{PREFIX}createaccount <username> <password>` - Create your Jellyfin account",
f"`{PREFIX}recoveraccount <newpassword>` - Reset your password",
f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account"
f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account",
f"`{PREFIX}what2watch` - Lists 5 random movie suggestions from the Jellyfin Library"
]
# Only show trialaccount if enabled
if ENABLE_TRIAL_ACCOUNTS:
user_cmds.append(f"`{PREFIX}trialaccount <username> <password>` - Create a 24-hour trial Jellyfin account")
embed.add_field(name="User Commands", value="\n".join(user_cmds), inline=False)
embed.add_field(name="🎬 Jellyfin Commands", value="\n".join(user_cmds), inline=False)
# Admin commands
# --- Bot Commands ---
bot_cmds = [
f"`{PREFIX}help` - Show this help message"
]
embed.add_field(name="🤖 Bot Commands", value="\n".join(bot_cmds), inline=False)
# --- Admin Commands ---
if is_admin:
# Dynamic link command line
# Admin Jellyfin commands
link_command = f"`{PREFIX}link <jellyfin_username> @user` - Manually link accounts"
if JELLYSEERR_ENABLED:
link_command = f"`{PREFIX}link <jellyfin_username> @user <Jellyseerr ID>` - Manually link accounts with Jellyseerr"
link_command = f"`{PREFIX}link <jellyfin_username> @user <Jellyseerr ID>` - 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"{link_command}\n"
f"`{PREFIX}unlink @user` - Manually unlink accounts\n"
), inline=False)
admin_cmds = [
link_command,
f"`{PREFIX}unlink @user` - Manually unlink accounts",
f"`{PREFIX}listvalidusers` - Show number of valid and invalid accounts",
f"`{PREFIX}cleanup` - Remove Jellyfin accounts from users without roles",
f"`{PREFIX}lastcleanup` - See last cleanup time and time remaining",
f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user",
f"`{PREFIX}searchdiscord @user` - Find linked Jellyfin account",
f"`{PREFIX}scanlibraries` - Scan all Jellyfin libraries",
f"`{PREFIX}activestreams` - View all active Jellyfin streams"
]
embed.add_field(name="🛠️ Admin Commands", value="\n".join(admin_cmds), inline=False)
embed.add_field(name="Admin Bot Commands", value=(
f"`{PREFIX}setprefix` - Change the bot's command prefix\n"
f"`{PREFIX}updates` - Manually check for bot updates\n"
f"`{PREFIX}logging` - Enable/Disable Console Event Logging\n"
), inline=False)
# --- qBittorrent Commands ---
if ENABLE_QBITTORRENT:
qb_cmds = [
f"`{PREFIX}qbview` - Show current qBittorrent downloads with progress, peers, and seeders",
]
embed.add_field(name="💾 qBittorrent Commands", value="\n".join(qb_cmds), inline=False)
# --- JFA Commands ---
if ENABLE_JFA:
jfa_cmds = [
f"`{PREFIX}createinvite` - Create a new JFA invite link",
f"`{PREFIX}listinvites` - List all active JFA invite links",
f"`{PREFIX}deleteinvite <code>` - Delete a specific JFA invite"
]
embed.add_field(name="🔑 JFA Commands", value="\n".join(jfa_cmds), inline=False)
# Admin Bot commands
admin_bot_cmds = [
f"`{PREFIX}setprefix` - Change the bot's command prefix",
f"`{PREFIX}updates` - Manually check for bot updates",
f"`{PREFIX}logging` - Enable/disable console event logging"
]
embed.add_field(name="⚙️ Admin Bot Commands", value="\n".join(admin_bot_cmds), inline=False)
await ctx.send(embed=embed)
# =====================
# TASKS
# =====================
import datetime
import pytz
import datetime
import pytz
import mysql.connector
LOCAL_TZ = pytz.timezone(os.getenv("LOCAL_TZ", "America/Chicago"))
@tasks.loop(hours=24)
async def daily_check():
log_event("Running daily account cleanup check...")
async def cleanup_task():
log_event("🧹 Running daily account cleanup check...")
removed = []
# =======================
# Normal accounts cleanup
for row in get_accounts():
# safe unpacking in case schema varies
discord_id = row[0]
jf_username = row[1] if len(row) > 1 else None
jf_id = row[2] if len(row) > 2 else None
js_id = row[3] if len(row) > 3 else None
# find the member across configured guilds
# =======================
for discord_id, jf_username, jf_id, js_id in get_accounts():
member = None
for gid in GUILD_IDS:
guild = bot.get_guild(gid)
if not guild:
continue
candidate = guild.get_member(discord_id)
if candidate:
member = candidate
if guild:
member = guild.get_member(discord_id)
if member:
break
# if no member found or member doesn't have a required role -> delete account
if member is None or not has_required_role(member):
if jf_username:
try:
@@ -983,7 +1470,7 @@ async def daily_check():
except Exception as e:
print(f"[Cleanup] Error removing DB entry for Discord ID {discord_id}: {e}")
# remove from Jellyseerr if we have an id and integration enabled
# remove from Jellyseerr if applicable
if JELLYSEERR_ENABLED and js_id:
try:
if delete_jellyseerr_user(js_id):
@@ -995,7 +1482,9 @@ async def daily_check():
removed.append(jf_username or f"{discord_id}")
# Trial accounts cleanup (persistent history table)
# ======================
# Trial accounts cleanup
# ======================
try:
conn = mysql.connector.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
@@ -1003,21 +1492,27 @@ async def daily_check():
cur = conn.cursor(dictionary=True)
cur.execute("SELECT * FROM trial_accounts WHERE expired=0")
trials = cur.fetchall()
now_local = datetime.datetime.now(LOCAL_TZ)
for trial in trials:
created_at = trial.get("trial_created_at") or trial.get("created_at") # compatibility
if not created_at:
created_at_utc = trial.get("trial_created_at") or trial.get("created_at")
if not created_at_utc:
continue
# created_at is a datetime from the DB (cursor dictionary=True)
if datetime.datetime.utcnow() > created_at + datetime.timedelta(hours=24):
# delete from Jellyfin (best-effort)
# Convert DB UTC time to local TZ
if created_at_utc.tzinfo is None:
created_at_local = pytz.utc.localize(created_at_utc).astimezone(LOCAL_TZ)
else:
created_at_local = created_at_utc.astimezone(LOCAL_TZ)
if now_local > created_at_local + datetime.timedelta(hours=24):
# Delete trial Jellyfin user
try:
delete_jellyfin_user(trial.get("jellyfin_username"))
except Exception as e:
print(f"[Trial Cleanup] Error deleting trial Jellyfin user {trial.get('jellyfin_username')}: {e}")
# mark trial as expired
# Mark trial as expired
try:
cur.execute("UPDATE trial_accounts SET expired=1 WHERE discord_id=%s", (trial["discord_id"],))
conn.commit()
@@ -1034,9 +1529,11 @@ async def daily_check():
except Exception:
pass
# record last run in metadata and cleanup_logs
# ======================
# Update metadata & logs
# ======================
try:
set_metadata("last_cleanup", datetime.datetime.utcnow().isoformat())
set_metadata("last_cleanup", datetime.datetime.now(LOCAL_TZ).isoformat())
except Exception as e:
print(f"[Cleanup] Failed to set last_cleanup metadata: {e}")
@@ -1045,14 +1542,16 @@ async def daily_check():
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
cur = conn.cursor()
cur.execute("INSERT INTO cleanup_logs (run_at) VALUES (%s)", (datetime.datetime.utcnow(),))
cur.execute("INSERT INTO cleanup_logs (run_at) VALUES (%s)", (datetime.datetime.now(LOCAL_TZ),))
conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f"[Cleanup] Failed to insert cleanup_logs: {e}")
# post results to sync channel if anything removed
# ============================
# Post results to sync channel
# ============================
if removed:
msg = f"🧹 Removed {len(removed)} Jellyfin accounts: {', '.join(removed)}"
print(msg)
@@ -1092,15 +1591,29 @@ async def on_ready():
# Check last cleanup
last_run = get_metadata("last_cleanup")
if last_run:
last_run_dt = datetime.datetime.fromisoformat(last_run)
now = datetime.datetime.utcnow()
delta = now - last_run_dt
# parse UTC timestamp from DB
last_run_dt_utc = datetime.datetime.fromisoformat(last_run)
# convert to local timezone
if last_run_dt_utc.tzinfo is None:
last_run_dt_utc = pytz.utc.localize(last_run_dt_utc)
last_run_local = last_run_dt_utc.astimezone(LOCAL_TZ)
now_local = datetime.datetime.now(LOCAL_TZ)
delta = now_local - last_run_local
if delta.total_seconds() >= 24 * 3600:
print("Running missed daily cleanup...")
await daily_check() # Run immediately if overdue
await cleanup_task() # run 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)

View File

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

View File

@@ -1 +1 @@
{ "version": "1.0.4" }
{ "version": "1.0.6" }

View File

@@ -1 +1 @@
1.0.4
1.0.6