15 Commits
1.0.0 ... 1.0.3

Author SHA1 Message Date
9acbbe5644 Bump 1.0.3 2025-09-06 19:50:18 -05:00
2d2e649a5f Update README.md 2025-09-06 19:48:10 -05:00
a6f110c9c8 What is wrong with me. 2025-09-06 19:47:07 -05:00
2621fa45e5 Update Readme 2025-09-06 19:41:58 -05:00
f1acaf529a Update Repo Name 2025-09-06 18:50:24 -05:00
d970f37343 Update app.py 2025-09-06 17:45:45 -05:00
0ef8fe58d2 Update Markdown 2025-09-06 17:28:35 -05:00
24190a3b4a Trial accounts 2025-09-06 17:05:24 -05:00
4de1e6e7cb Fixing more things I forgot
- Fixed: ValueError: too many values to unpack (expected 2)
- Cleanup will now delete Jellyseerr accounts as well
2025-09-06 12:47:11 -05:00
1cc8f7fbaa 1.0.2
Running base commands with no arguments will now show you the proper arguments
2025-09-06 10:50:05 -05:00
3727112892 Add Event Logging 2025-09-06 10:39:09 -05:00
6407c1e41f Update .env 2025-09-06 09:18:40 -05:00
308d57b1f8 Update version.txt 2025-09-06 09:13:06 -05:00
aefd8780b8 Added Jellyseerr Support 2025-09-06 09:12:56 -05:00
483a66ecd1 Update README.md 2025-09-06 08:12:39 -05:00
6 changed files with 528 additions and 103 deletions

7
.env
View File

@@ -8,6 +8,12 @@ REQUIRED_ROLE_IDS=333333333333333333,444444444444444444
# Jellyfin
JELLYFIN_URL=http://127.0.0.1:8096
JELLYFIN_API_KEY=your_jellyfin_api_key
ENABLE_TRIAL_ACCOUNTS=false
# Jellyseerr
JELLYSEERR_ENABLED=false
JELLYSEERR_URL=http://localhost:5055
JELLYSEERR_API_KEY=your_api_key_here
# MySQL
DB_HOST=localhost
@@ -17,3 +23,4 @@ DB_NAME=jellyfin_bot
# Logs
SYNC_LOG_CHANNEL_ID=555555555555555555
EVENT_LOGGING=false

22
CHANGELOG.md Normal file
View File

@@ -0,0 +1,22 @@
# 1.0.3
- Fixed: `ValueError: too many values to unpack (expected 2)`
- Cleanup will now delete Jellyseerr accounts as well
- Added Trial Jellyfin account support (enable in .env). Will not create a Jellyseerr account, lasts 24 hours, one time use.
# 1.0.2
- Fixed Jellyseerr support breaking linking, unlinking, and deletion
- Added the ability to link Jellyseerr accounts when linking Jellyfin
- Added event logging support for console (Can be toggled in .env or with commands)
- Reformatted the updates message
- Running a command without any values will now show you the proper command usage
# 1.0.1
- Added Jellyseerr Support
# 1.0.0
- Bot can now track update releases
- Restrict prefixes to non-alphanumeric symbols

View File

@@ -1,4 +1,10 @@
# Jellyfin-Discord
# Jellycord
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/BannerRound.png)
[![Online Members](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscordapp.com%2Fapi%2Finvites%2FEdPJAhrDq8%3Fwith_counts%3Dtrue&query=approximate_presence_count&style=for-the-badge&logo=discord&logoColor=white&label=ONLINE%20MEMBERS&labelColor=grey&color=239eda)](https://discord.gg/EdPJAhrDq8)
![Latest Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2FPenguCCN%2FJellycord%2Fmain%2Fversion.json&query=%24.version&style=for-the-badge&logo=python&logoColor=white&label=Latest%20Version%3A&color=239eda)
Allow the creation and management of Jellyfin users via Discord
Join my [Discord](https://discord.com/invite/zJMUNCPtPy) for help, and keeping an eye out for updates!
@@ -7,7 +13,7 @@ This is a very simple and lightweight Jellyfin Discord bot for managing users. I
Fill out values in the .env and you're good to go!
# Features
## Features
- Automatic Account Cleanup
- Creating Accounts
@@ -16,31 +22,33 @@ Fill out values in the .env and you're good to go!
- Run Library Scanning
- Manual Account Linking (For previously made Jellyfin accounts)
- Change bot prefix live
- Checks for new releases
# Command Overview
## Command Overview
**Pinging the bot will show you the necessary commands to create your account.**
**PLEASE NOTE BEFORE USING. THIS BOT IS MEANT TO USE REQUIRED ROLES IN ORDER TO WHITELIST USERS FOR JELLYFIN. TAKING A USERS ROLE AWAY WILL DELETE THEIR JELLYFIN ACCOUNT WHEN THE BOT RUNS ITS CLEANUP (24 Hour Schedule or Admin Forced)**
![image](https://cdn.pengucc.com/images/projects/jellyfin-bot/ping.png)
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/ping.png)
**There are protections in place to stop users from creating an account where people can see. If a user sends the account creation or reset in a guild, the bot will delete it.**
![image](https://cdn.pengucc.com/images/projects/jellyfin-bot/account-deny.png)
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/account-deny.png)
**If a user already has a linked Jellyfin account, the bot will not allow them to create another account.**
![image](https://cdn.pengucc.com/images/projects/jellyfin-bot/account-limit.png)
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/account-limit.png)
**In order to create an account, you must have the required roles specified in the .env**
![image](https://cdn.pengucc.com/images/projects/jellyfin-bot/role-required.png)
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/role-required.png)
***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
***Admin Commands***
- `!cleanup` - Remove Jellyfin accounts from users without roles
@@ -52,4 +60,6 @@ Fill out values in the .env and you're good to go!
- `!unlink` @user - Manually unlink accounts
***Admin Bot Commands***
- `!setprefix` - Change the bots command prefix
- `!setprefix` - Change the bots command prefix
- `!updates` - Manually check for bot updates
- `!logging` - Enable/Disable Console Event Logging

573
app.py
View File

@@ -29,15 +29,29 @@ 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("/")
JELLYSEERR_API_KEY = os.getenv("JELLYSEERR_API_KEY", "")
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.0"
VERSION_URL = "https://raw.githubusercontent.com/PenguCCN/Jellyfin-Discord/main/version.txt"
RELEASES_URL = "https://github.com/PenguCCN/Jellyfin-Discord/releases"
BOT_VERSION = "1.0.3"
VERSION_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt"
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
# =====================
# EVENT LOGGING
# =====================
EVENT_LOGGING = os.getenv("EVENT_LOGGING", "false").lower() == "true"
def log_event(message: str):
if EVENT_LOGGING:
print(f"[EVENT] {datetime.datetime.utcnow().isoformat()} | {message}")
# =====================
# DISCORD SETUP
@@ -51,7 +65,7 @@ bot = commands.Bot(command_prefix=PREFIX, intents=intents, help_command=None)
# DATABASE SETUP
# =====================
def init_db():
# Existing DB creation
log_event(f"Initiating Database...")
conn = mysql.connector.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD
)
@@ -65,66 +79,133 @@ def init_db():
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
cur = conn.cursor()
# Normal accounts table
cur.execute("""
CREATE TABLE IF NOT EXISTS accounts (
discord_id BIGINT PRIMARY KEY,
jellyfin_username VARCHAR(255) NOT NULL
jellyfin_username VARCHAR(255) NOT NULL,
jellyfin_id VARCHAR(255) NOT NULL,
jellyseerr_id VARCHAR(255)
)
""")
# New table for metadata
# Ensure jellyfin_id exists
cur.execute("SHOW COLUMNS FROM accounts LIKE 'jellyfin_id'")
if cur.fetchone() is None:
cur.execute("ALTER TABLE accounts ADD COLUMN jellyfin_id VARCHAR(255) NOT NULL")
print("[DB] Added missing column 'jellyfin_id' to accounts table.")
# Ensure jellyseerr_id exists
cur.execute("SHOW COLUMNS FROM accounts LIKE 'jellyseerr_id'")
if cur.fetchone() is None:
cur.execute("ALTER TABLE accounts ADD COLUMN jellyseerr_id VARCHAR(255) DEFAULT NULL")
print("[DB] Added missing column 'jellyseerr_id' to accounts table.")
# 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,
value VARCHAR(255) NOT NULL
)
""")
# 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()
def add_account(discord_id, jellyfin_username):
def add_account(discord_id, username, jf_id, js_id=None):
conn = mysql.connector.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
cur = conn.cursor()
cur.execute("REPLACE INTO accounts (discord_id, jellyfin_username) VALUES (%s, %s)",
(discord_id, jellyfin_username))
cur.execute(
"REPLACE INTO accounts (discord_id, jellyfin_username, jellyfin_id, jellyseerr_id) VALUES (%s, %s, %s, %s)",
(discord_id, username, jf_id, js_id)
)
conn.commit()
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(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
cur = conn.cursor()
cur.execute("SELECT discord_id, jellyfin_username FROM accounts")
cur.execute("SELECT discord_id, jellyfin_username, jellyfin_id, jellyseerr_id FROM accounts")
rows = cur.fetchall()
cur.close()
conn.close()
return rows
def get_account_by_jellyfin(username):
conn = mysql.connector.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
cur = conn.cursor()
cur.execute("SELECT discord_id FROM accounts WHERE jellyfin_username=%s", (username,))
cur.execute("SELECT discord_id, jellyfin_id, jellyseerr_id FROM accounts WHERE jellyfin_username=%s", (username,))
row = cur.fetchone()
cur.close()
conn.close()
return row
return row # (discord_id, jf_id, js_id)
def get_account_by_discord(discord_id):
conn = mysql.connector.connect(
host=DB_HOST, user=DB_USER, password=DB_PASSWORD, database=DB_NAME
)
cur = conn.cursor()
cur.execute("SELECT jellyfin_username FROM accounts WHERE discord_id=%s", (discord_id,))
cur.execute(
"SELECT jellyfin_username, jellyfin_id, jellyseerr_id FROM accounts WHERE discord_id=%s",
(discord_id,)
)
row = cur.fetchone()
cur.close()
conn.close()
return row
return row # (jellyfin_username, jf_id, js_id)
def delete_account(discord_id):
conn = mysql.connector.connect(
@@ -171,6 +252,62 @@ def reset_jellyfin_password(username: str, new_password: str) -> bool:
response = requests.post(f"{JELLYFIN_URL}/Users/{user_id}/Password", headers=headers, json=data)
return response.status_code in (200, 204)
# =====================
# JELLYSEERR HELPERS
# =====================
def import_jellyseerr_user(jellyfin_user_id: str) -> str:
"""Import user into Jellyseerr. Returns the Jellyseerr user ID if successful, else None."""
if not JELLYSEERR_ENABLED:
return None
headers = {"X-Api-Key": JELLYSEERR_API_KEY, "Content-Type": "application/json"}
data = {"jellyfinUserIds": [jellyfin_user_id]}
try:
url = f"{JELLYSEERR_URL}/api/v1/user/import-from-jellyfin"
r = requests.post(url, headers=headers, json=data, timeout=15)
if r.status_code in (200, 201):
js_user = r.json()
if isinstance(js_user, list) and len(js_user) > 0 and "id" in js_user[0]:
js_id = js_user[0]["id"]
print(f"[Jellyseerr] User {jellyfin_user_id} imported successfully with Jellyseerr ID {js_id}.")
return js_id
print(f"[Jellyseerr] Import failed. Status: {r.status_code}, Response: {r.text}")
return None
except Exception as e:
print(f"[Jellyseerr] Failed to import user: {e}")
return None
def get_jellyseerr_id(jf_id: str) -> str | None:
"""Return the Jellyseerr user ID for a given Jellyfin user ID."""
if not JELLYSEERR_ENABLED:
return None
headers = {"X-Api-Key": JELLYSEERR_API_KEY}
try:
r = requests.get(f"{JELLYSEERR_URL}/api/v1/user", headers=headers, timeout=10)
if r.status_code != 200:
return None
users = r.json()
for user in users:
if "jellyfinUserIds" in user and jf_id in user["jellyfinUserIds"]:
return user["id"]
return None
except Exception as e:
print(f"[Jellyseerr] Failed to fetch user ID for Jellyfin ID {jf_id}: {e}")
return None
def delete_jellyseerr_user(js_id: str) -> bool:
if not JELLYSEERR_ENABLED or not js_id:
return True
headers = {"X-Api-Key": JELLYSEERR_API_KEY}
try:
dr = requests.delete(f"{JELLYSEERR_URL}/api/v1/user/{js_id}", headers=headers, timeout=10)
return dr.status_code in (200, 204)
except Exception as e:
print(f"[Jellyseerr] Failed to delete user {js_id}: {e}")
return False
# =====================
# DISCORD HELPERS
# =====================
@@ -207,6 +344,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
# =====================
@@ -231,83 +395,219 @@ async def on_message(message):
# =====================
# COMMANDS
# =====================
def command_usage(base: str, args: list[str]) -> str:
"""Return usage message for a command."""
return f"❌ Usage: `{base} {' '.join(args)}`"
def log_event(message: str):
"""Log events to console if enabled in .env."""
if os.getenv("EVENT_LOGGING", "false").lower() == "true":
print(f"[EVENT] {message}")
@bot.command()
async def createaccount(ctx, username: str, password: str):
async def createaccount(ctx, username: str = None, password: str = None):
log_event(f"createaccount invoked by {ctx.author}")
if username is None or password is None:
await ctx.send(command_usage(f"{PREFIX}createaccount", ["<username>", "<password>"]))
return
if not isinstance(ctx.channel, discord.DMChannel):
await ctx.message.delete()
await ctx.send(f"{ctx.author.mention} Please DM me to create your Jellyfin account.")
try: await ctx.message.delete()
except discord.Forbidden: pass
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)
member = guild.get_member(ctx.author.id) if guild else None
if not member or not has_required_role(member):
await ctx.send("You dont have the required role to create an account.")
await ctx.send(f"{ctx.author.mention}, you dont have the required role.")
return
if get_account_by_discord(ctx.author.id):
await ctx.send("You already have a Jellyfin account.")
await ctx.send(f"{ctx.author.mention}, you already have a Jellyfin account.")
return
if create_jellyfin_user(username, password):
add_account(ctx.author.id, username)
await ctx.send(f"✅ Account created! You can log in at {JELLYFIN_URL}")
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
js_id = None
if JELLYSEERR_ENABLED:
js_id = import_jellyseerr_user(jf_id)
add_account(ctx.author.id, username, jf_id, js_id)
if JELLYSEERR_ENABLED:
if js_id:
await ctx.send(f"✅ Jellyfin account **{username}** created and imported into Jellyseerr!\n🌐 Login here: {JELLYFIN_URL}")
else:
await ctx.send(f"⚠️ Jellyfin account **{username}** created, but Jellyseerr import failed.\n🌐 Login here: {JELLYFIN_URL}")
else:
await ctx.send(f"✅ Jellyfin account **{username}** created!\n🌐 Login here: {JELLYFIN_URL}")
else:
await ctx.send("❌ Failed to create account. Username may already exist.")
await ctx.send(f"❌ Failed to create Jellyfin account **{username}**. It may already exist.")
@bot.command()
async def recoveraccount(ctx, new_password: str):
"""DM-only: reset your Jellyfin password"""
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
guild = bot.get_guild(GUILD_ID)
member = guild.get_member(ctx.author.id) if guild else None
# Check required server role
if not member or not has_required_role(member):
await ctx.send(f"{ctx.author.mention}, you dont 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):
log_event(f"recoveraccount invoked by {ctx.author}")
if new_password is None:
await ctx.send(command_usage(f"{PREFIX}recoveraccount", ["<newpassword>"]))
return
if not isinstance(ctx.channel, discord.DMChannel):
await ctx.message.delete()
await ctx.send(f"{ctx.author.mention} Please DM me to reset your password.")
return
# Fetch the Jellyfin account linked to this Discord user
acc = get_account_by_discord(ctx.author.id)
if not acc:
await ctx.send("❌ You do not have a linked Jellyfin account.")
return
username = acc[0] # the Jellyfin username
# Reset the password
username = acc[0]
if reset_jellyfin_password(username, new_password):
await ctx.send(
f"✅ Your Jellyfin password for **{username}** has been reset!\n"
f"🌐 Login here: {JELLYFIN_URL}"
)
await ctx.send(f"✅ Your Jellyfin password for **{username}** has been reset!\n🌐 Login here: {JELLYFIN_URL}")
else:
await ctx.send(f"❌ Failed to reset password for **{username}**. Please contact an admin.")
@bot.command()
async def deleteaccount(ctx, username: str):
async def deleteaccount(ctx, username: str = None):
log_event(f"deleteaccount invoked by {ctx.author}")
if username is None:
await ctx.send(command_usage(f"{PREFIX}deleteaccount", ["<username>"]))
return
if not isinstance(ctx.channel, discord.DMChannel):
await ctx.message.delete()
await ctx.send(f"{ctx.author.mention} Please DM me to delete your Jellyfin account.")
try: await ctx.message.delete()
except discord.Forbidden: pass
await ctx.send(f"{ctx.author.mention} ❌ Please DM me to delete your Jellyfin account.")
return
acc = get_account_by_discord(ctx.author.id)
if not acc or acc[0].lower() != username.lower():
await ctx.send("That Jellyfin account is not linked to your Discord user.")
await ctx.send(f"{ctx.author.mention}, that Jellyfin account is not linked to you.")
return
jf_id, js_id = acc[1], acc[2] if len(acc) > 2 else None
if delete_jellyfin_user(username):
delete_account(ctx.author.id)
await ctx.send("✅ Account deleted.")
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}")
await ctx.send(f"✅ Jellyfin account **{username}** deleted successfully.")
else:
await ctx.send("❌ Failed to delete account.")
await ctx.send(f"❌ Failed to delete Jellyfin account **{username}**.")
@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():
for row in get_accounts():
discord_id = row[0]
jf_username = row[1]
jf_id = row[2] if len(row) > 2 else None
js_id = row[3] if len(row) > 3 else None
m = guild.get_member(discord_id)
if m is None or not has_required_role(m):
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)
@@ -316,8 +616,10 @@ async def cleanup(ctx):
await ctx.send("✅ Cleanup complete.")
@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(member):
await ctx.send("❌ You dont have permission to view the last cleanup.")
@@ -336,17 +638,14 @@ async def lastcleanup(ctx):
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"
f"⏳ Time until next cleanup: {hours}h {minutes}m {seconds}s"
)
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")
@bot.command()
async def searchaccount(ctx, username: str):
member = ctx.guild.get_member(ctx.author.id)
if not has_admin_role(member):
await ctx.send("❌ You dont have permission to use this command.")
async def searchaccount(ctx, username: str = None):
log_event(f"searchaccount invoked by {ctx.author}")
if username is None:
await ctx.send(command_usage(f"{PREFIX}searchaccount", ["<jellyfin_username>"]))
return
result = get_account_by_jellyfin(username)
@@ -357,11 +656,12 @@ async def searchaccount(ctx, username: str):
else:
await ctx.send("❌ No linked Discord user found for that Jellyfin account.")
@bot.command()
async def searchdiscord(ctx, user: discord.User):
member = ctx.guild.get_member(ctx.author.id)
if not has_admin_role(member):
await ctx.send("❌ You dont have permission to use this command.")
async def searchdiscord(ctx, user: discord.User = None):
log_event(f"searchdiscord invoked by {ctx.author}")
if user is None:
await ctx.send(command_usage(f"{PREFIX}searchdiscord", ["@user"]))
return
result = get_account_by_discord(user.id)
@@ -370,52 +670,69 @@ async def searchdiscord(ctx, user: discord.User):
else:
await ctx.send("❌ That Discord user does not have a linked Jellyfin account.")
@bot.command()
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(member):
await ctx.send("❌ You dont have permission to use this command.")
return
headers = {"X-Emby-Token": JELLYFIN_API_KEY}
response = requests.post(f"{JELLYFIN_URL}/Library/Refresh", headers=headers)
response = requests.post(f"{JELLYFIN_URL}/Library/Refresh", headers={"X-Emby-Token": JELLYFIN_API_KEY})
if response.status_code in (200, 204):
await ctx.send("✅ All Jellyfin libraries are being scanned.")
else:
await ctx.send(f"❌ Failed to start library scan. Status code: {response.status_code}")
@bot.command()
async def link(ctx, jellyfin_username: str, user: discord.User):
member = ctx.guild.get_member(ctx.author.id)
if not has_admin_role(member):
await ctx.send("❌ You dont have permission to use this 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}")
usage_args = ["<Jellyfin Account>", "<@user>"]
if JELLYSEERR_ENABLED: usage_args.append("<Jellyseerr ID>")
if jellyfin_username is None or user is None or (JELLYSEERR_ENABLED and js_id is None):
await ctx.send(command_usage(f"{PREFIX}link", usage_args))
return
add_account(user.id, jellyfin_username)
existing_acc = get_account_by_discord(user.id)
if existing_acc:
await ctx.send(f"❌ Discord user {user.mention} already has a linked account.")
return
jf_id = get_jellyfin_user(jellyfin_username)
if not jf_id:
await ctx.send(f"❌ Could not find Jellyfin account **{jellyfin_username}**. Make sure it exists.")
return
add_account(user.id, jellyfin_username, jf_id, js_id)
await ctx.send(f"✅ Linked Jellyfin account **{jellyfin_username}** to {user.mention}.")
@bot.command()
async def unlink(ctx, discord_user: discord.User):
"""Admin-only: unlink a Jellyfin account from a Discord user (without deleting the account)"""
guild = ctx.guild
member = guild.get_member(ctx.author.id) if guild else None
if not member or not has_admin_role(member):
await ctx.send(f"{ctx.author.mention}, you dont have permission to use this command.")
@bot.command()
async def unlink(ctx, discord_user: discord.User = None):
log_event(f"unlink invoked by {ctx.author}")
if discord_user is None:
await ctx.send(command_usage(f"{PREFIX}unlink", ["@user"]))
return
# Check if the Discord user has a linked Jellyfin account
account = get_account_by_discord(discord_user.id)
if not account:
await ctx.send(f"❌ Discord user {discord_user.mention} does not have a linked Jellyfin account.")
return
# Remove the database entry
delete_account(discord_user.id)
await ctx.send(f"✅ Unlinked Jellyfin account **{account[0]}** from Discord user {discord_user.mention}.")
@bot.command()
async def setprefix(ctx, new_prefix: str):
async def setprefix(ctx, new_prefix: str = None):
log_event(f"setprefix invoked by {ctx.author}")
if new_prefix is None:
await ctx.send(command_usage(f"{PREFIX}setprefix", ["<symbol>"]))
return
member = ctx.guild.get_member(ctx.author.id)
if not member or not has_admin_role(member):
await ctx.send("❌ You dont have permission to use this command.")
@@ -425,12 +742,9 @@ async def setprefix(ctx, new_prefix: str):
await ctx.send("❌ Prefix must be a single non-alphanumeric symbol (e.g. !, $, %, ?)")
return
# Update prefix
global PREFIX
PREFIX = new_prefix
bot.command_prefix = PREFIX
# Write to .env
lines = []
with open(".env", "r") as f:
for line in f:
@@ -438,13 +752,14 @@ async def setprefix(ctx, new_prefix: str):
lines.append(f"PREFIX={new_prefix}\n")
else:
lines.append(line)
with open(".env", "w") as f:
f.writelines(lines)
with open(".env", "w") as f: f.writelines(lines)
await ctx.send(f"✅ Command prefix updated to `{new_prefix}`")
@bot.command()
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(member):
await ctx.send("❌ You dont have permission to use this command.")
@@ -454,20 +769,49 @@ async def updates(ctx):
response = requests.get(VERSION_URL, timeout=10)
if response.status_code == 200:
latest_version = response.text.strip()
await ctx.send(
f"🤖 Bot version: `{BOT_VERSION}`\n"
f"🌍 Latest version: `{latest_version}`\n"
f"{'✅ Up to date!' if BOT_VERSION == latest_version else f'⚠️ Update available! Get it here: {RELEASES_URL}'}"
)
await ctx.send(f"🤖 Bot version: `{BOT_VERSION}`\n🌍 Latest version: `{latest_version}`\n{'✅ Up to date!' if BOT_VERSION == latest_version else f'⚠️ Update available! Get it here: {RELEASES_URL}'}")
else:
await ctx.send("❌ Failed to fetch latest version info.")
except Exception as e:
await ctx.send(f"❌ Error checking version: {e}")
@bot.command()
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(member):
await ctx.send("❌ You dont have permission to use this command.")
return
global EVENT_LOGGING
if state.lower() in ("on", "true", "1"):
EVENT_LOGGING = True
new_value = "true"
elif state.lower() in ("off", "false", "0"):
EVENT_LOGGING = False
new_value = "false"
else:
await ctx.send("❌ Invalid value. Use `on` or `off`.")
return
# Update .env
lines = []
with open(".env", "r") as f:
for line in f:
if line.startswith("EVENT_LOGGING="):
lines.append(f"EVENT_LOGGING={new_value}\n")
else:
lines.append(line)
with open(".env", "w") as f:
f.writelines(lines)
await ctx.send(f"✅ Event logging is now {'enabled' if EVENT_LOGGING else 'disabled'}.")
log_event(f"EVENT_LOGGING toggled to {new_value} by {ctx.author}")
@bot.command(name="help")
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(member)
@@ -477,12 +821,20 @@ async def help_command(ctx):
color=discord.Color.blue()
)
embed.add_field(name="User Commands", value=(
f"`{PREFIX}createaccount <username> <password>` - Create your Jellyfin account\n"
f"`{PREFIX}recoveraccount <newpassword>` - Reset your password\n"
f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account\n"
), inline=False)
# 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"
]
# 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:
embed.add_field(name="Admin Commands", value=(
f"`{PREFIX}cleanup` - Remove Jellyfin accounts from users without roles\n"
@@ -494,8 +846,9 @@ async def help_command(ctx):
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)
await ctx.send(embed=embed)
@@ -510,18 +863,49 @@ async def daily_check():
guild = bot.get_guild(GUILD_ID)
removed = []
for discord_id, jf_username in get_accounts():
# Normal accounts cleanup
for discord_id, jf_username, jf_id, js_id in get_accounts():
m = guild.get_member(discord_id)
if m is None or not has_required_role(m):
if delete_jellyfin_user(jf_username):
delete_account(discord_id)
removed.append(jf_username)
if removed:
print(f"Daily cleanup: removed {len(removed)} accounts: {removed}")
# Trial accounts cleanup
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()
for trial in trials:
created_at = trial["trial_created_at"]
if created_at and datetime.datetime.utcnow() > created_at + datetime.timedelta(hours=24):
# Delete from Jellyfin
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)")
cur.close()
conn.close()
# Record cleanup run
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.utcnow(),))
conn.commit()
cur.close()
conn.close()
if removed:
print(f"Cleanup removed {len(removed)} accounts: {removed}")
# Log last run timestamp
set_metadata("last_cleanup", datetime.datetime.utcnow().isoformat())
@tasks.loop(hours=1)
async def check_for_updates():
@@ -533,11 +917,12 @@ async def check_for_updates():
log_channel = bot.get_channel(SYNC_LOG_CHANNEL_ID)
if log_channel:
await log_channel.send(
f"⚠️ **Update available for Jellyfin Bot!**\n"
f"📌 Current version: `{BOT_VERSION}`\n"
f"⬆️ Latest version: `{latest_version}`\n\n"
f"🔗 Download/update here: {RELEASES_URL}"
f"⬆️ Latest version: `{latest_version}`\n"
f"⚠️ **Update available for Jellyfin Bot! Get it here:**\n\n"
f"{RELEASES_URL}"
)
log_event(f"Latest Version:'{latest_version}', Current Version: '{BOT_VERSION}'")
except Exception as e:
print(f"[Update Check] Failed: {e}")

1
version.json Normal file
View File

@@ -0,0 +1 @@
{ "version": "1.0.3" }

View File

@@ -1 +1 @@
1.0.0
1.0.3