10 Commits
1.0.8 ... main

Author SHA1 Message Date
f11e1997fd Partial File Logging 2025-12-12 03:36:43 -06:00
54099b85c1 Push Stats command 2025-12-12 03:21:41 -06:00
e4757805ae Push Basic Servarr Support 2025-12-12 02:30:14 -06:00
97dc1dcb98 Fix sending POST when feature disabled 2025-12-11 03:01:12 -06:00
61dc5db6d0 Added Statistic Tracking 2025-12-11 02:46:57 -06:00
Pengu
fdc6df373d Update ROADMAP.md 2025-10-26 11:32:50 -05:00
42b10a813f Added Restoring, Backups, and Updating 2025-09-26 21:46:18 -05:00
bae9a5b967 Reformatted Link Command 2025-09-25 16:21:21 -05:00
b1d050cb3c Configurable Trial Duration 2025-09-25 16:01:10 -05:00
ce076824a2 Update app.py 2025-09-21 23:00:29 -05:00
8 changed files with 843 additions and 34 deletions

View File

@@ -1,21 +1,24 @@
# Discord
# Jellycord version 1.1.0
# |Discord|
DISCORD_TOKEN=your_discord_bot_token
PREFIX=!
GUILD_ID=123456789012345678,123456789012345678
GUILD_IDS=123456789012345678,123456789012345678
ADMIN_ROLE_IDS=111111111111111111,222222222222222222
REQUIRED_ROLE_IDS=333333333333333333,444444444444444444
# Jellyfin
# |Jellyfin|
JELLYFIN_URL=http://127.0.0.1:8096
JELLYFIN_API_KEY=your_jellyfin_api_key
ENABLE_TRIAL_ACCOUNTS=false
TRIAL_TIME=24 # In hours
# Jellyseerr
# |Jellyseerr|
JELLYSEERR_ENABLED=false
JELLYSEERR_URL=http://localhost:5055
JELLYSEERR_API_KEY=your_api_key_here
# JFA-Go
# |JFA-Go|
ENABLE_JFA=false
JFA_URL=http://localhost:8056
JFA_USERNAME=yourusername
@@ -23,13 +26,22 @@ JFA_PASSWORD=yourpassword
JFA_API_KEY=your_api_key_here
JFA_TOKEN=
# QBittorrent
# |Servarr|
RADARR_URL=http://your-radarr-ip:7878
RADARR_API_KEY=yourradarrapikey
ENABLE_RADARR=false
SONARR_URL=http://your-sonarr-ip:8989
SONARR_API_KEY=yoursonarrapikey
ENABLE_SONARR=false
# |QBittorrent|
ENABLE_QBITTORRENT=false
QBIT_HOST=http://localhost:8080
QBIT_USERNAME=your_username
QBIT_PASSWORD=your_password
# Proxmox
# |Proxmox|
ENABLE_PROXMOX=false
PROXMOX_HOST=https://your-proxmox-server:8006
PROXMOX_TOKEN_NAME=root@pam!yourtokenname
@@ -40,15 +52,17 @@ PROXMOX_VM_ID=
# Proxmox type can be "qemu" for VM, "lxc" for container
PROXMOX_TYPE=
# MySQL
# |MySQL|
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=password
DB_NAME=jellyfin_bot
# Time Settings
# |General Settings|
TIMEZONE=America/Chicago
# Tracking only reports limited information about your instance for development reasons. (Tracking enabled Instance & Enabled Features)
TRACKING_ENABLED=true
# Logs
# |Logs|
SYNC_LOG_CHANNEL_ID=555555555555555555
EVENT_LOGGING=false

View File

@@ -1,3 +1,16 @@
# 1.1.0
- The bot now sends limited info stats to a public frontend for development insight. (Reports enabled features and bot instance only | Jellyseerr, Proxmox, JFA, qBittorrent)
- Radarr & Sonarr Support
# 1.0.9
- You can now configure trial account duration
- Reformatted link command
- Added Updating via command
- Added Backup command
- Added restore command
# 1.0.8
- Fixed update message

View File

@@ -1,6 +1,13 @@
# Jellycord
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/BannerRound.png)
![Live Player Count](https://img.shields.io/badge/dynamic/json?query=$.data.result[0].value[1]&url=https%3A%2F%2Fprometheus.pengucc.com%2Fapi%2Fv1%2Fquery%3Fquery%3Dinstance&style=for-the-badge&logo=nodedotjs&logoColor=white&label=Bot%20Instances&color=9964c5)
![Live Player Count](https://img.shields.io/badge/dynamic/json?query=$.data.result[0].value[1]&url=https%3A%2F%2Fprometheus.pengucc.com%2Fapi%2Fv1%2Fquery%3Fquery%3Djellyseerr&style=for-the-badge&logo=jellyfin&logoColor=white&label=Jellyseer%20Enabled&color=9964c5)
![Live Player Count](https://img.shields.io/badge/dynamic/json?query=$.data.result[0].value[1]&url=https%3A%2F%2Fprometheus.pengucc.com%2Fapi%2Fv1%2Fquery%3Fquery%3Dproxmox&style=for-the-badge&logo=proxmox&logoColor=white&label=Proxmox%20Enabled&color=9964c5)
![Live Player Count](https://img.shields.io/badge/dynamic/json?query=$.data.result[0].value[1]&url=https%3A%2F%2Fprometheus.pengucc.com%2Fapi%2Fv1%2Fquery%3Fquery%3Djfa&style=for-the-badge&logo=go&logoColor=white&label=JFA-GO%20Enabled&color=9964c5)
![Live Player Count](https://img.shields.io/badge/dynamic/json?query=$.data.result[0].value[1]&url=https%3A%2F%2Fprometheus.pengucc.com%2Fapi%2Fv1%2Fquery%3Fquery%3Dqbittorrent&style=for-the-badge&logo=qbittorrent&logoColor=white&label=qBittorrent%20Enabled&color=9964c5)
![Live Player Count](https://img.shields.io/badge/dynamic/json?query=$.data.result[0].value[1]&url=https%3A%2F%2Fprometheus.pengucc.com%2Fapi%2Fv1%2Fquery%3Fquery%3Dradarr&style=for-the-badge&logo=radarr&logoColor=white&label=Radarr%20Enabled&color=9964c5)
![Live Player Count](https://img.shields.io/badge/dynamic/json?query=$.data.result[0].value[1]&url=https%3A%2F%2Fprometheus.pengucc.com%2Fapi%2Fv1%2Fquery%3Fquery%3Dsonarr&style=for-the-badge&logo=sonarr&logoColor=white&label=Sonarr%20Enabled&color=9964c5)
[![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)
@@ -13,7 +20,24 @@ 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
# 🛑 Disclaimer (Please Read) 🛑
This bot collects limited amounts of information, it is enabled by default and can be disabled in your environment file. Information Collected is limited to:
- Bot Instances
- Enabled Features
We do **not** and will **never** collect nor store:
- IP Address's.
- System Information.
- Information about Jellyfin or any related services.
## Why do we do this?
Adding these simply allows me to see what features users are interested in, allowing me to focus on improving/fixing highly used features.
# Features
- Automatic Account Cleanup
- Creating Accounts
@@ -24,7 +48,7 @@ Fill out values in the .env and you're good to go!
- 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.**
@@ -56,9 +80,9 @@ Fill out values in the .env and you're good to go!
***🛠️ Admin Commands***
- `!link` <jellyfin_username> @user - Manually link accounts
- `!link` @user <jellyfin_username> - Manually link accounts
- `!unlink` @user - Manually unlink accounts
- `!listvalidusers` - Show number of valid and invalid accounts
- `!validusers` - 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
@@ -85,6 +109,11 @@ Fill out values in the .env and you're good to go!
***⚙️ Admin Bot Commands***
- `!setprefix` - Change the bots command prefix
- `!updates` - Manually check for bot updates
- `!stats` - View Local and Global Jellycord Stats
- `!update` - Download latest bot version
- `!backup` - Create a backup of the bot and configurations
- `!backups` - List backups of the bot
- `!restore` - Restore a backup of the bot
- `!version` - Manually check for bot updates
- `!changelog` - View changelog for current bot version
- `!logging` - Enable/Disable Console Event Logging

View File

@@ -1,6 +1,7 @@
- **Future Features**
- Servarr Support
- Make Jellyfin admins exempt from account cleanup
- **To Do**

780
app.py
View File

@@ -9,6 +9,19 @@ import pytz
import random
import qbittorrentapi
from proxmoxer import ProxmoxAPI
import subprocess
import sys
import zipfile
import io
import time
from pathlib import Path
import tempfile
import shutil
import pymysql
import json
import psutil
import platform
import logging
# =====================
# ENV + VALIDATION
@@ -34,6 +47,7 @@ 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"
TRIAL_TIME = int(os.getenv("TRIAL_TIME", 24))
JELLYSEERR_ENABLED = os.getenv("JELLYSEERR_ENABLED", "false").lower() == "true"
JELLYSEERR_URL = os.getenv("JELLYSEERR_URL", "").rstrip("/")
@@ -45,6 +59,14 @@ JFA_USERNAME = os.getenv("JFA_USERNAME")
JFA_PASSWORD = os.getenv("JFA_PASSWORD")
JFA_API_KEY = os.getenv("JFA_API_KEY")
ENABLE_RADARR = os.getenv("ENABLE_RADARR", "false").lower() == "true"
RADARR_URL = os.getenv("RADARR_URL", "").rstrip("/")
RADARR_API_KEY = os.getenv("RADARR_API_KEY", "")
ENABLE_SONARR = os.getenv("ENABLE_SONARR", "false").lower() == "true"
SONARR_URL = os.getenv("SONARR_URL", "").rstrip("/")
SONARR_API_KEY = os.getenv("SONARR_API_KEY", "")
ENABLE_QBITTORRENT = os.getenv("ENABLE_QBITTORRENT", "False").lower() == "true"
QBIT_HOST = os.getenv("QBIT_HOST")
QBIT_USERNAME = os.getenv("QBIT_USERNAME")
@@ -65,12 +87,32 @@ DB_PASSWORD = get_env_var("DB_PASSWORD")
DB_NAME = get_env_var("DB_NAME")
LOCAL_TZ = pytz.timezone(get_env_var("LOCAL_TZ", str, required=False) or "America/Chicago")
ENV_FILE = ".env"
DEFAULT_ENV_FILE = ".env.example"
BACKUP_DIR = Path("backups")
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
LATEST_LOG = LOG_DIR / "latest.log"
ARCHIVE_DIR = LOG_DIR / "archives"
ARCHIVE_DIR.mkdir(exist_ok=True)
BOT_VERSION = "1.0.7"
BOT_VERSION = "1.0.9"
VERSION_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt"
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
CHANGELOG_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/refs/heads/main/CHANGELOG.md"
TRACKING_ENABLED = os.getenv("TRACKING_ENABLED", "False").lower() == "true"
PROMETHEUS_URL = "https://prometheus.pengucc.com/api/v1/query"
POST_ENDPOINTS = {
"botinstance": "https://jellycordstats.pengucc.com/api/instance",
"jellyseerr": "https://jellycordstats.pengucc.com/api/jellyseerr",
"proxmox": "https://jellycordstats.pengucc.com/api/proxmox",
"jfa": "https://jellycordstats.pengucc.com/api/jfa",
"qbittorrent": "https://jellycordstats.pengucc.com/api/qbittorrent",
"radarr": "https://jellycordstats.pengucc.com/api/radarr",
"sonarr": "https://jellycordstats.pengucc.com/api/sonarr"
}
# =====================
# EVENT LOGGING
# =====================
@@ -218,6 +260,14 @@ def init_trial_accounts_table():
cur.close()
conn.close()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler(LATEST_LOG, encoding="utf-8"),
logging.StreamHandler()
]
)
def get_accounts():
conn = mysql.connector.connect(
@@ -271,6 +321,7 @@ def delete_account(discord_id):
# =====================
# JELLYFIN HELPERS
# =====================
def create_jellyfin_user(username, password):
headers = {"X-Emby-Token": JELLYFIN_API_KEY}
data = {"Name": username, "Password": password}
@@ -385,6 +436,81 @@ def delete_jellyseerr_user(js_id: str) -> bool:
print(f"[Jellyseerr] Failed to delete user {js_id}: {e}")
return False
# =====================
# SERVARR HELPERS
# =====================
def radarr_get_movies():
"""Return a list of all movies Radarr is managing."""
if not ENABLE_RADARR:
return None
try:
response = requests.get(
f"{RADARR_URL}/api/v3/movie",
headers={"X-Api-Key": RADARR_API_KEY},
timeout=10
)
if response.status_code != 200:
print(f"[Radarr] Error fetching movies: {response.status_code} {response.text}")
return None
return response.json()
except Exception as e:
print(f"[Radarr] Exception: {e}")
return None
def radarr_get_latest_movies(count=5):
"""Return the latest added movies from Radarr."""
movies = radarr_get_movies()
if not movies:
return None
# Sort by 'added' field if available
sorted_movies = sorted(
movies,
key=lambda m: m.get("added", ""),
reverse=True
)
return sorted_movies[:count]
def sonarr_get_series():
"""Return a list of all series Sonarr is managing."""
if not ENABLE_SONARR:
return None
try:
response = requests.get(
f"{SONARR_URL}/api/v3/series",
headers={"X-Api-Key": SONARR_API_KEY},
timeout=10
)
if response.status_code != 200:
print(f"[Sonarr] Error fetching series: {response.status_code} {response.text}")
return None
return response.json()
except Exception as e:
print(f"[Sonarr] Exception: {e}")
return None
def sonarr_get_latest_series(count=5):
"""Return the latest added series from Sonarr."""
series = sonarr_get_series()
if not series:
return None
# Sonarr tracks `added` timestamps too
sorted_series = sorted(
series,
key=lambda s: s.get("added", ""),
reverse=True
)
return sorted_series[:count]
# =====================
# QBITTORRENT HELPERS
# =====================
@@ -573,6 +699,109 @@ def _update_env_key(key: str, value: str, env_path: str = ".env"):
if not found:
f.write(f"{key}={value}\n")
def export_mysql_db(dump_file):
try:
conn = mysql.connector.connect(
host=DB_HOST,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME
)
cursor = conn.cursor()
with open(dump_file, "w", encoding="utf-8") as f:
# Get tables
cursor.execute("SHOW TABLES")
tables = [row[0] for row in cursor.fetchall()]
for table in tables:
# Dump CREATE statement
cursor.execute(f"SHOW CREATE TABLE `{table}`")
create_stmt = cursor.fetchone()[1]
f.write(f"-- Table structure for `{table}`\n{create_stmt};\n\n")
# Dump rows
cursor.execute(f"SELECT * FROM `{table}`")
rows = cursor.fetchall()
if rows:
columns = [desc[0] for desc in cursor.description]
for row in rows:
values = ", ".join(
f"'{str(val).replace("'", "''")}'" if val is not None else "NULL"
for val in row
)
f.write(f"INSERT INTO `{table}` ({', '.join(columns)}) VALUES ({values});\n")
f.write("\n")
cursor.close()
conn.close()
return True
except Exception as e:
print(f"[Backup] Database export failed: {e}")
return False
def sync_env_file():
"""Ensure .env has all fields from .env.example, preserving existing values."""
if not os.path.exists(DEFAULT_ENV_FILE):
print("[updatebot] No .env.example found, skipping env sync")
return
# Load .env.example as baseline
with open(DEFAULT_ENV_FILE, "r") as f:
default_lines = [line.strip("\n") for line in f.readlines()]
# Load existing .env (create if missing)
existing = {}
if os.path.exists(ENV_FILE):
with open(ENV_FILE, "r") as f:
for line in f:
if "=" in line and not line.strip().startswith("#"):
key, val = line.split("=", 1)
existing[key.strip()] = val.strip()
# Build new env content
new_lines = []
for line in default_lines:
if "=" not in line: # comments or blank lines
new_lines.append(line)
continue
key, default_val = line.split("=", 1)
key = key.strip()
if key in existing:
new_lines.append(f"{key}={existing[key]}")
else:
new_lines.append(line) # use default if missing
# Write back updated .env
with open(ENV_FILE, "w") as f:
f.write("\n".join(new_lines) + "\n")
print("[updatebot] Synced .env file successfully")
def restart_bot():
"""Replace current process with a new one."""
os.execv(sys.executable, [sys.executable] + sys.argv)
def build_payload(enabled: bool):
return {"value": 1 if enabled else 0}
def promql(query: str):
"""Run a PromQL query and return results."""
try:
response = requests.get(
PROMETHEUS_URL,
params={"query": query},
timeout=10
)
data = response.json()
result = data.get("data", {}).get("result", [])
return result
except Exception as e:
print(f"[Prometheus] Error: {e}")
return None
# =====================
# EVENTS
@@ -919,7 +1148,7 @@ async def refreshjfakey(ctx):
@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."""
f"""Create a {TRIAL_TIME}-hour trial Jellyfin account. DM-only, one-time per user."""
log_event(f"trialaccount invoked by {ctx.author}")
# Ensure trial accounts are enabled
@@ -987,7 +1216,7 @@ async def trialaccount(ctx, username: str = None, password: str = None):
cur.close()
conn.close()
await ctx.send(f"✅ Trial Jellyfin account **{username}** created! It will expire in 24 hours.\n🌐 Login here: {JELLYFIN_URL}")
await ctx.send(f"✅ Trial Jellyfin account **{username}** created! It will expire in {TRIAL_TIME} hours.\n🌐 Login here: {JELLYFIN_URL}")
log_event(f"Trial account created for {ctx.author} ({username})")
else:
cur.close()
@@ -1223,7 +1452,7 @@ async def cleanup(ctx):
await ctx.send("✅ Cleanup complete.")
@bot.command()
async def listvalidusers(ctx):
async def validusers(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.")
@@ -1415,6 +1644,100 @@ async def activestreams(ctx):
await ctx.send(f"❌ Error fetching active streams: {e}")
print(f"[activestreams] Error: {e}")
@bot.command()
async def moviestats(ctx):
"""Show Radarr's latest 5 added movies with total count."""
if not ENABLE_RADARR:
await ctx.send("⚠️ Radarr support is not enabled.")
return
movies = radarr_get_movies()
if movies is None:
await ctx.send("❌ Failed to connect to Radarr.")
return
total_count = len(movies)
# Sort by newest "added"
latest = sorted(
movies,
key=lambda m: m.get("added", ""),
reverse=True
)[:5]
embed = discord.Embed(
title="🎞️ Latest Radarr Additions",
color=discord.Color.orange()
)
for movie in latest:
title = movie.get("title", "Unknown")
year = movie.get("year", "Unknown")
added = movie.get("added", "Unknown")
tmdb_id = movie.get("tmdbId")
tmdb_link = (
f"https://www.themoviedb.org/movie/{tmdb_id}"
if tmdb_id else "No TMDB ID"
)
embed.add_field(
name=f"{title} ({year})",
value=f"📅 Added: `{added}`\n🔗 {tmdb_link}",
inline=False
)
embed.set_footer(text=f"Total movies managed by Radarr: {total_count}")
await ctx.send(embed=embed)
@bot.command()
async def showstats(ctx):
"""Show Sonarr's latest 5 added series with total count."""
if not ENABLE_SONARR:
await ctx.send("⚠️ Sonarr support is not enabled.")
return
series = sonarr_get_series()
if series is None:
await ctx.send("❌ Failed to connect to Sonarr.")
return
total_count = len(series)
# Newest first
latest = sorted(
series,
key=lambda s: s.get("added", ""),
reverse=True
)[:5]
embed = discord.Embed(
title="📺 Latest Sonarr Additions",
color=discord.Color.blue()
)
for show in latest:
title = show.get("title", "Unknown")
year = show.get("year", "Unknown")
added = show.get("added", "Unknown")
tvdb_id = show.get("tvdbId")
tvdb_link = (
f"https://thetvdb.com/?id={tvdb_id}&tab=series"
if tvdb_id else "No TVDB ID"
)
embed.add_field(
name=f"{title} ({year})",
value=f"📅 Added: `{added}`\n🔗 {tvdb_link}",
inline=False
)
embed.set_footer(text=f"Total series managed by Sonarr: {total_count}")
await ctx.send(embed=embed)
@bot.command()
async def qbview(ctx):
"""Admin-only: View current qBittorrent downloads."""
@@ -1596,9 +1919,9 @@ async def storage(ctx):
@bot.command()
async def link(ctx, jellyfin_username: str = None, user: discord.User = None, js_id: str = None):
async def link(ctx, user: discord.User = None, jellyfin_username: str = None, js_id: str = None):
log_event(f"link invoked by {ctx.author}")
usage_args = ["<Jellyfin Account>", "<@user>"]
usage_args = ["<@user>", "<Jellyfin Account>"]
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):
@@ -1616,7 +1939,7 @@ async def link(ctx, jellyfin_username: str = None, user: discord.User = None, js
return
add_account(user.id, jellyfin_username, jf_id, js_id)
await ctx.send(f"✅ Linked Jellyfin account **{jellyfin_username}** to {user.mention}.")
await ctx.send(f"✅ Linked {user.mention} to Jellyfin account **{jellyfin_username}**.")
@bot.command()
@@ -1634,6 +1957,103 @@ async def unlink(ctx, discord_user: discord.User = None):
delete_account(discord_user.id)
await ctx.send(f"✅ Unlinked Jellyfin account **{account[0]}** from Discord user {discord_user.mention}.")
@bot.command()
async def stats(ctx):
"""Show unified system and Prometheus metrics in one compact embed."""
# -------------------
# Local System Stats
# -------------------
cpu_usage = psutil.cpu_percent(interval=1)
mem = psutil.virtual_memory()
mem_str = f"{round(mem.used/1024**3,2)} / {round(mem.total/1024**3,2)} GB ({mem.percent}%)"
disk = psutil.disk_usage('/')
disk_str = f"{round(disk.used/1024**3,2)} / {round(disk.total/1024**3,2)} GB ({disk.percent}%)"
boot_time = datetime.datetime.fromtimestamp(psutil.boot_time())
uptime = datetime.datetime.now() - boot_time
uptime_str = str(uptime).split('.')[0]
python_version = platform.python_version()
bot_ver = BOT_VERSION if "BOT_VERSION" in globals() else "Unknown"
# -------------------
# Prometheus Stats (Last 5 Minutes)
# -------------------
prometheus_fields = []
if PROMETHEUS_URL:
metrics = {
"Instances":
"max_over_time(instance[5m])",
"Jellyseerr Enabled":
"max_over_time(jellyseerr[5m])",
"JFA Enabled":
"max_over_time(jfa[5m])",
"qBittorrent Enabled":
"max_over_time(qbittorrent[5m])",
"Radarr Enabled":
"max_over_time(radarr[5m])",
"Sonarr Enabled":
"max_over_time(sonarr[5m])"
}
for label, query in metrics.items():
result = promql(query)
if result and isinstance(result, list) and len(result) > 0:
try:
value = result[0]["value"][1]
except:
value = "N/A"
else:
value = "N/A"
prometheus_fields.append((label, value))
# -------------------
# Build Embed
# -------------------
embed = discord.Embed(
title="📊 Jellycord System & Tracking Statistics",
color=discord.Color.blurple()
)
# Local stats
embed.add_field(name="🧠 CPU", value=f"{cpu_usage}%", inline=True)
embed.add_field(name="💾 Memory", value=mem_str, inline=True)
embed.add_field(name="📀 Disk", value=disk_str, inline=True)
embed.add_field(name="⏱️ Uptime", value=uptime_str, inline=True)
embed.add_field(name="🐍 Python", value=python_version, inline=True)
embed.add_field(name="🤖 Bot Version", value=bot_ver, inline=True)
# Prometheus stats
if prometheus_fields:
embed.add_field(name="📡 Tracking (Last 5 Minutes)", value="\u200b", inline=False)
for label, val in prometheus_fields:
embed.add_field(name=f"{label}", value=f"`{val}`", inline=True)
else:
embed.add_field(
name="📡 Tracking",
value="Prometheus disabled or unreachable",
inline=False
)
embed.set_footer(text=f"Generated • {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
await ctx.send(embed=embed)
@bot.command()
async def setprefix(ctx, new_prefix: str = None):
@@ -1667,7 +2087,247 @@ async def setprefix(ctx, new_prefix: str = None):
@bot.command()
async def updates(ctx):
async def update(ctx):
"""Admin-only: Check GitHub version, sync .env, and pull latest bot code."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
# Fetch latest version
version_url = "https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt"
r = requests.get(version_url, timeout=10)
if r.status_code != 200:
await ctx.send("❌ Failed to fetch latest version info.")
return
latest_version = r.text.strip()
if latest_version == BOT_VERSION:
await ctx.send(f"✅ Bot is already up-to-date (`{BOT_VERSION}`).")
return
await ctx.send(f"⬆️ Update found: `{BOT_VERSION}` → `{latest_version}`")
# Download release zip
releases_url = "https://github.com/PenguCCN/Jellycord/releases/latest/download/Jellycord.zip"
r = requests.get(releases_url, timeout=30)
if r.status_code != 200:
await ctx.send("❌ Failed to download latest release zip.")
return
with zipfile.ZipFile(io.BytesIO(r.content)) as z:
z.extractall("update_tmp")
# Merge .env with .env.example
env_path = ".env"
example_path = os.path.join("update_tmp", ".env.example")
if os.path.exists(example_path):
# Load current env into dict
current_env = {}
if os.path.exists(env_path):
with open(env_path, "r") as f:
for line in f:
if "=" in line and not line.strip().startswith("#"):
key, val = line.split("=", 1)
current_env[key.strip()] = val.strip()
merged_lines = []
with open(example_path, "r") as f:
for line in f:
if line.strip().startswith("#") or "=" not in line:
# Keep comments & blank lines exactly as they are
merged_lines.append(line.rstrip("\n"))
else:
key, default_val = line.split("=", 1)
key = key.strip()
if key in current_env:
merged_lines.append(f"{key}={current_env[key]}")
else:
merged_lines.append(line.rstrip("\n"))
with open(env_path, "w") as f:
f.write("\n".join(merged_lines) + "\n")
# Overwrite all other bot files
for root, dirs, files in os.walk("update_tmp"):
for file in files:
if file == ".env.example":
continue
src = os.path.join(root, file)
dst = os.path.relpath(src, "update_tmp")
os.replace(src, dst)
await ctx.send(f"✅ Update applied! Now running version `{latest_version}`.\n⚠️ Restart the bot to load changes.")
restart_bot()
except Exception as e:
await ctx.send(f"❌ Update failed: {e}")
print(f"[updatebot] Error: {e}")
@bot.command()
async def backup(ctx):
"""Create a backup of the bot (files + DB)."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
await ctx.send("📦 Starting backup process...")
try:
BACKUP_DIR.mkdir(exist_ok=True)
# Backup filename
today = datetime.datetime.now().strftime("%m-%d-%Y")
backup_name = f"{today}-{BOT_VERSION}.zip"
backup_path = BACKUP_DIR / backup_name
# Temporary SQL dump file
dump_file = BACKUP_DIR / f"{DB_NAME}.sql"
if not export_mysql_db(dump_file):
await ctx.send("⚠️ Database export failed, continuing without DB dump...")
with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as backup_zip:
# Add all files in current directory (skip backups themselves)
for root, _, files in os.walk("."):
if root.startswith("./backups"):
continue
for file in files:
file_path = Path(root) / file
backup_zip.write(file_path, arcname=file_path.relative_to("."))
# Add DB dump if created
if dump_file.exists():
backup_zip.write(dump_file, arcname=f"{DB_NAME}.sql")
dump_file.unlink() # remove temporary dump file
await ctx.send(f"✅ Backup created: `{backup_name}`")
log_event(f"Backup created: {backup_name}")
except Exception as e:
await ctx.send(f"❌ Backup failed: {e}")
print(f"[Backup] Error: {e}")
@bot.command()
async def restore(ctx, backup_file: str):
"""Restore a backup (files + database) from a zip. Admin only."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
backup_path = os.path.join("backups", backup_file)
if not os.path.exists(backup_path):
await ctx.send(f"❌ Backup `{backup_file}` not found.")
return
await ctx.send(f"♻️ Starting restore from `{backup_file}`. This may take a while...")
temp_dir = os.path.join("backups", "restore_temp")
os.makedirs(temp_dir, exist_ok=True)
try:
# --- Extract zip to local restore_temp folder ---
with zipfile.ZipFile(backup_path, "r") as zip_ref:
zip_ref.extractall(temp_dir)
# --- Database Restore ---
sql_files = [f for f in os.listdir(temp_dir) if f.endswith(".sql")]
if sql_files:
sql_file_path = os.path.join(temp_dir, sql_files[0])
with open(sql_file_path, "r", encoding="utf-8") as f:
sql_content = f.read()
conn = pymysql.connect(
host=os.getenv("DB_HOST", "localhost"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
database=os.getenv("DB_NAME"),
autocommit=True
)
with conn.cursor() as cursor:
cursor.execute("SET FOREIGN_KEY_CHECKS = 0;")
cursor.execute("SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE();")
tables = cursor.fetchall()
for (table_name,) in tables:
cursor.execute(f"DROP TABLE IF EXISTS `{table_name}`;")
cursor.execute("SET FOREIGN_KEY_CHECKS = 1;")
for statement in sql_content.split(";"):
stmt = statement.strip()
if stmt:
cursor.execute(stmt)
conn.close()
await ctx.send("✅ Database restored successfully!")
else:
await ctx.send("⚠️ No SQL backup found in this zip file.")
# --- Copy files to working directory ---
for item in os.listdir(temp_dir):
src_path = os.path.join(temp_dir, item)
dest_path = os.path.join(".", item)
if os.path.isdir(src_path):
if os.path.exists(dest_path):
shutil.rmtree(dest_path)
shutil.copytree(src_path, dest_path)
else:
shutil.copy2(src_path, dest_path)
await ctx.send("✅ Files restored successfully!")
except Exception as e:
await ctx.send(f"❌ Restore failed: {e}")
return
finally:
# --- Clean up restore_temp folder ---
shutil.rmtree(temp_dir, ignore_errors=True)
await ctx.send("🔃 Restarting bot to apply changes...")
restart_bot()
@bot.command()
async def backups(ctx):
"""List all available backups in the backups directory (newest to oldest)."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
backup_folder = Path("backups")
if not backup_folder.exists():
await ctx.send("⚠️ No backups folder found.")
return
# Collect all zip files in backups dir
backups = list(backup_folder.glob("*.zip"))
if not backups:
await ctx.send("⚠️ No backups found.")
return
# Sort by modification time, newest first
backups.sort(key=lambda f: f.stat().st_mtime, reverse=True)
embed = discord.Embed(
title="📂 Available Backups",
description="Newest to oldest backups:",
color=discord.Color.green()
)
for backup in backups:
mtime = backup.stat().st_mtime
formatted_time = f"<t:{int(mtime)}:f>" # Discord timestamp formatting
embed.add_field(
name=backup.name,
value=f"Created: {formatted_time}",
inline=False
)
await ctx.send(embed=embed)
@bot.command()
async def version(ctx):
log_event(f"updates invoked by {ctx.author}")
member = ctx.guild.get_member(ctx.author.id)
if not has_admin_role(ctx.author):
@@ -1788,10 +2448,12 @@ async def help_command(ctx):
f"`{PREFIX}recoveraccount <newpassword>` - Reset your password",
f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account",
f"`{PREFIX}movies2watch` - Lists 5 random movie suggestions from the Jellyfin Library",
f"`{PREFIX}shows2watch` - Lists 5 random show suggestions from the Jellyfin Library"
f"`{PREFIX}shows2watch` - Lists 5 random show suggestions from the Jellyfin Library",
f"`{PREFIX}moviestats` - Lists latest 5 movies added, also shows total movie library size",
f"`{PREFIX}showstats` - Lists latest 5 movies added, also shows total series library size"
]
if ENABLE_TRIAL_ACCOUNTS:
user_cmds.append(f"`{PREFIX}trialaccount <username> <password>` - Create a 24-hour trial Jellyfin account")
user_cmds.append(f"`{PREFIX}trialaccount <username> <password>` - Create a {TRIAL_TIME}-hour trial Jellyfin account")
embed.add_field(name="🎬 Jellyfin Commands", value="\n".join(user_cmds), inline=False)
@@ -1804,14 +2466,14 @@ async def help_command(ctx):
# --- Admin Commands ---
if is_admin:
# Admin Jellyfin commands
link_command = f"`{PREFIX}link <jellyfin_username> @user` - Manually link accounts"
link_command = f"`{PREFIX}link @user <jellyfin_username>` - Manually link accounts"
if JELLYSEERR_ENABLED:
link_command = f"`{PREFIX}link <jellyfin_username> @user <Jellyseerr ID>` - Link accounts with Jellyseerr"
link_command = f"`{PREFIX}link @user <jellyfin_username> <Jellyseerr ID>` - Link accounts with Jellyseerr"
admin_cmds = [
link_command,
f"`{PREFIX}unlink @user` - Manually unlink accounts",
f"`{PREFIX}listvalidusers` - Show number of valid and invalid accounts",
f"`{PREFIX}validusers` - 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",
@@ -1848,8 +2510,13 @@ async def help_command(ctx):
# Admin Bot commands
admin_bot_cmds = [
f"`{PREFIX}stats` - View Local and Global Jellycord Stats",
f"`{PREFIX}setprefix` - Change the bot's command prefix",
f"`{PREFIX}updates` - Manually check for bot updates",
f"`{PREFIX}update` - Download latest bot version",
f"`{PREFIX}backup` - Create a backup of the bot, its database and configurations",
f"`{PREFIX}backups` - List backups of the bot",
f"`{PREFIX}restore` - Restore a backup of the bot",
f"`{PREFIX}version` - Manually check for bot updates",
f"`{PREFIX}changelog` - View changelog for current bot version",
f"`{PREFIX}logging` - Enable/disable console event logging"
]
@@ -1938,7 +2605,7 @@ async def cleanup_task():
else:
created_at_local = created_at_utc.astimezone(LOCAL_TZ)
if now_local > created_at_local + datetime.timedelta(hours=24):
if now_local > created_at_local + datetime.timedelta(hours=TRIAL_TIME):
# Delete trial Jellyfin user
try:
delete_jellyfin_user(trial.get("jellyfin_username"))
@@ -1995,6 +2662,83 @@ async def cleanup_task():
except Exception as e:
print(f"[Cleanup] Failed to send removed message to sync channel: {e}")
@tasks.loop(hours=24)
async def rotate_logs():
try:
now = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
# =====================
# 1. Ensure latest.log exists
# =====================
if not LATEST_LOG.exists():
LATEST_LOG.touch()
# =====================
# 2. Archive the latest.log
# =====================
archive_name = ARCHIVE_DIR / f"log_{now}.zip"
with zipfile.ZipFile(archive_name, "w", zipfile.ZIP_DEFLATED) as zipf:
zipf.write(LATEST_LOG, arcname="latest.log")
print(f"[LOG ROTATE] Archived log to: {archive_name}")
# =====================
# 3. Delete oldest archives if more than 4 exist
# =====================
archives = sorted(ARCHIVE_DIR.glob("*.zip"), key=os.path.getmtime)
if len(archives) > 4:
to_delete = archives[: len(archives) - 4]
for old in to_delete:
old.unlink()
print(f"[LOG ROTATE] Deleted old archive: {old}")
# =====================
# 4. Clear latest.log for new logs
# =====================
with open(LATEST_LOG, "w", encoding="utf-8"):
pass
print("[LOG ROTATE] Reset latest.log")
except Exception as e:
print(f"[ERROR] Log rotation failed: {e}")
@tasks.loop(seconds=15)
async def periodic_post_task():
if not TRACKING_ENABLED:
return
features = {
"botinstance": TRACKING_ENABLED,
"jellyseerr": JELLYSEERR_ENABLED,
"proxmox": ENABLE_PROXMOX,
"jfa": ENABLE_JFA,
"qbittorrent": ENABLE_QBITTORRENT,
"radarr": ENABLE_RADARR,
"sonarr": ENABLE_SONARR
}
for feature, enabled in features.items():
url = POST_ENDPOINTS.get(feature)
if not url:
print(f"[POST LOOP] No endpoint for: {feature}")
continue
# Skip POST if the feature is disabled (0)
if not enabled:
print(f"[POST LOOP] Skipping {feature} because it's disabled.")
continue
payload = build_payload(enabled)
try:
response = requests.post(url, json=payload, timeout=10)
print(f"[POST LOOP] Sent {feature}{response.status_code} | Payload: {payload}")
except Exception as e:
print(f"[POST LOOP] Error sending POST for {feature}: {e}")
# =====================
# JFA-Go Scheduled Token Refresh
# =====================
@@ -2071,6 +2815,12 @@ async def on_ready():
if not check_for_updates.is_running():
check_for_updates.start()
if TRACKING_ENABLED:
print("Tracking enabled — starting.")
periodic_post_task.start()
else:
print("Tracking disabled via .env")
await bot.change_presence(
activity=discord.Activity(type=discord.ActivityType.watching, name=f"{PREFIX}help")
)

View File

@@ -6,3 +6,5 @@ pytz==2025.2
apscheduler==3.11.0
qbittorrent-api==2025.7.0
proxmoxer==2.2.0
pymysql==1.1.2
psutil==7.1.3

View File

@@ -1 +1 @@
{ "version": "1.0.8" }
{ "version": "1.0.9" }

View File

@@ -1 +1 @@
1.0.8
1.0.9