Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f11e1997fd | |||
| 54099b85c1 | |||
| e4757805ae | |||
| 97dc1dcb98 | |||
| 61dc5db6d0 | |||
|
|
fdc6df373d | ||
| 42b10a813f | |||
| bae9a5b967 | |||
| b1d050cb3c | |||
| ce076824a2 |
@@ -1,21 +1,24 @@
|
|||||||
# Discord
|
# Jellycord version 1.1.0
|
||||||
|
|
||||||
|
# |Discord|
|
||||||
DISCORD_TOKEN=your_discord_bot_token
|
DISCORD_TOKEN=your_discord_bot_token
|
||||||
PREFIX=!
|
PREFIX=!
|
||||||
GUILD_ID=123456789012345678,123456789012345678
|
GUILD_IDS=123456789012345678,123456789012345678
|
||||||
ADMIN_ROLE_IDS=111111111111111111,222222222222222222
|
ADMIN_ROLE_IDS=111111111111111111,222222222222222222
|
||||||
REQUIRED_ROLE_IDS=333333333333333333,444444444444444444
|
REQUIRED_ROLE_IDS=333333333333333333,444444444444444444
|
||||||
|
|
||||||
# Jellyfin
|
# |Jellyfin|
|
||||||
JELLYFIN_URL=http://127.0.0.1:8096
|
JELLYFIN_URL=http://127.0.0.1:8096
|
||||||
JELLYFIN_API_KEY=your_jellyfin_api_key
|
JELLYFIN_API_KEY=your_jellyfin_api_key
|
||||||
ENABLE_TRIAL_ACCOUNTS=false
|
ENABLE_TRIAL_ACCOUNTS=false
|
||||||
|
TRIAL_TIME=24 # In hours
|
||||||
|
|
||||||
# Jellyseerr
|
# |Jellyseerr|
|
||||||
JELLYSEERR_ENABLED=false
|
JELLYSEERR_ENABLED=false
|
||||||
JELLYSEERR_URL=http://localhost:5055
|
JELLYSEERR_URL=http://localhost:5055
|
||||||
JELLYSEERR_API_KEY=your_api_key_here
|
JELLYSEERR_API_KEY=your_api_key_here
|
||||||
|
|
||||||
# JFA-Go
|
# |JFA-Go|
|
||||||
ENABLE_JFA=false
|
ENABLE_JFA=false
|
||||||
JFA_URL=http://localhost:8056
|
JFA_URL=http://localhost:8056
|
||||||
JFA_USERNAME=yourusername
|
JFA_USERNAME=yourusername
|
||||||
@@ -23,13 +26,22 @@ JFA_PASSWORD=yourpassword
|
|||||||
JFA_API_KEY=your_api_key_here
|
JFA_API_KEY=your_api_key_here
|
||||||
JFA_TOKEN=
|
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
|
ENABLE_QBITTORRENT=false
|
||||||
QBIT_HOST=http://localhost:8080
|
QBIT_HOST=http://localhost:8080
|
||||||
QBIT_USERNAME=your_username
|
QBIT_USERNAME=your_username
|
||||||
QBIT_PASSWORD=your_password
|
QBIT_PASSWORD=your_password
|
||||||
|
|
||||||
# Proxmox
|
# |Proxmox|
|
||||||
ENABLE_PROXMOX=false
|
ENABLE_PROXMOX=false
|
||||||
PROXMOX_HOST=https://your-proxmox-server:8006
|
PROXMOX_HOST=https://your-proxmox-server:8006
|
||||||
PROXMOX_TOKEN_NAME=root@pam!yourtokenname
|
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 can be "qemu" for VM, "lxc" for container
|
||||||
PROXMOX_TYPE=
|
PROXMOX_TYPE=
|
||||||
|
|
||||||
# MySQL
|
# |MySQL|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_USER=root
|
DB_USER=root
|
||||||
DB_PASSWORD=password
|
DB_PASSWORD=password
|
||||||
DB_NAME=jellyfin_bot
|
DB_NAME=jellyfin_bot
|
||||||
|
|
||||||
# Time Settings
|
# |General Settings|
|
||||||
TIMEZONE=America/Chicago
|
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
|
SYNC_LOG_CHANNEL_ID=555555555555555555
|
||||||
EVENT_LOGGING=false
|
EVENT_LOGGING=false
|
||||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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
|
# 1.0.8
|
||||||
|
|
||||||
- Fixed update message
|
- Fixed update message
|
||||||
|
|||||||
39
README.md
39
README.md
@@ -1,6 +1,13 @@
|
|||||||
# Jellycord
|
# Jellycord
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
[](https://discord.gg/EdPJAhrDq8)
|
[](https://discord.gg/EdPJAhrDq8)
|
||||||

|

|
||||||
@@ -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!
|
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
|
- Automatic Account Cleanup
|
||||||
- Creating Accounts
|
- Creating Accounts
|
||||||
@@ -24,7 +48,7 @@ Fill out values in the .env and you're good to go!
|
|||||||
- Change bot prefix live
|
- Change bot prefix live
|
||||||
- Checks for new releases
|
- Checks for new releases
|
||||||
|
|
||||||
## Command Overview
|
# Command Overview
|
||||||
|
|
||||||
**Pinging the bot will show you the necessary commands to create your account.**
|
**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***
|
***🛠️ Admin Commands***
|
||||||
|
|
||||||
- `!link` <jellyfin_username> @user - Manually link accounts
|
- `!link` @user <jellyfin_username> - Manually link accounts
|
||||||
- `!unlink` @user - Manually unlink 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
|
- `!cleanup` - Remove Jellyfin accounts from users without roles
|
||||||
- `!lastcleanup` - See Last cleanup time, and time remaining before next cleanup
|
- `!lastcleanup` - See Last cleanup time, and time remaining before next cleanup
|
||||||
- `!searchaccount` <jellyfin_username> - Find linked Discord user
|
- `!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***
|
***⚙️ Admin Bot Commands***
|
||||||
|
|
||||||
- `!setprefix` - Change the bots command prefix
|
- `!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
|
- `!changelog` - View changelog for current bot version
|
||||||
- `!logging` - Enable/Disable Console Event Logging
|
- `!logging` - Enable/Disable Console Event Logging
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
- **Future Features**
|
- **Future Features**
|
||||||
|
|
||||||
- Servarr Support
|
- Servarr Support
|
||||||
|
- Make Jellyfin admins exempt from account cleanup
|
||||||
|
|
||||||
- **To Do**
|
- **To Do**
|
||||||
|
|
||||||
|
|||||||
780
app.py
780
app.py
@@ -9,6 +9,19 @@ import pytz
|
|||||||
import random
|
import random
|
||||||
import qbittorrentapi
|
import qbittorrentapi
|
||||||
from proxmoxer import ProxmoxAPI
|
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
|
# 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_URL = get_env_var("JELLYFIN_URL")
|
||||||
JELLYFIN_API_KEY = get_env_var("JELLYFIN_API_KEY")
|
JELLYFIN_API_KEY = get_env_var("JELLYFIN_API_KEY")
|
||||||
ENABLE_TRIAL_ACCOUNTS = os.getenv("ENABLE_TRIAL_ACCOUNTS", "False").lower() == "true"
|
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_ENABLED = os.getenv("JELLYSEERR_ENABLED", "false").lower() == "true"
|
||||||
JELLYSEERR_URL = os.getenv("JELLYSEERR_URL", "").rstrip("/")
|
JELLYSEERR_URL = os.getenv("JELLYSEERR_URL", "").rstrip("/")
|
||||||
@@ -45,6 +59,14 @@ JFA_USERNAME = os.getenv("JFA_USERNAME")
|
|||||||
JFA_PASSWORD = os.getenv("JFA_PASSWORD")
|
JFA_PASSWORD = os.getenv("JFA_PASSWORD")
|
||||||
JFA_API_KEY = os.getenv("JFA_API_KEY")
|
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"
|
ENABLE_QBITTORRENT = os.getenv("ENABLE_QBITTORRENT", "False").lower() == "true"
|
||||||
QBIT_HOST = os.getenv("QBIT_HOST")
|
QBIT_HOST = os.getenv("QBIT_HOST")
|
||||||
QBIT_USERNAME = os.getenv("QBIT_USERNAME")
|
QBIT_USERNAME = os.getenv("QBIT_USERNAME")
|
||||||
@@ -65,12 +87,32 @@ DB_PASSWORD = get_env_var("DB_PASSWORD")
|
|||||||
DB_NAME = get_env_var("DB_NAME")
|
DB_NAME = get_env_var("DB_NAME")
|
||||||
|
|
||||||
LOCAL_TZ = pytz.timezone(get_env_var("LOCAL_TZ", str, required=False) or "America/Chicago")
|
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"
|
VERSION_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/main/version.txt"
|
||||||
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
|
RELEASES_URL = "https://github.com/PenguCCN/Jellycord/releases"
|
||||||
CHANGELOG_URL = "https://raw.githubusercontent.com/PenguCCN/Jellycord/refs/heads/main/CHANGELOG.md"
|
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
|
# EVENT LOGGING
|
||||||
# =====================
|
# =====================
|
||||||
@@ -218,6 +260,14 @@ def init_trial_accounts_table():
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.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():
|
def get_accounts():
|
||||||
conn = mysql.connector.connect(
|
conn = mysql.connector.connect(
|
||||||
@@ -271,6 +321,7 @@ def delete_account(discord_id):
|
|||||||
# =====================
|
# =====================
|
||||||
# JELLYFIN HELPERS
|
# JELLYFIN HELPERS
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
def create_jellyfin_user(username, password):
|
def create_jellyfin_user(username, password):
|
||||||
headers = {"X-Emby-Token": JELLYFIN_API_KEY}
|
headers = {"X-Emby-Token": JELLYFIN_API_KEY}
|
||||||
data = {"Name": username, "Password": password}
|
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}")
|
print(f"[Jellyseerr] Failed to delete user {js_id}: {e}")
|
||||||
return False
|
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
|
# QBITTORRENT HELPERS
|
||||||
# =====================
|
# =====================
|
||||||
@@ -573,6 +699,109 @@ def _update_env_key(key: str, value: str, env_path: str = ".env"):
|
|||||||
if not found:
|
if not found:
|
||||||
f.write(f"{key}={value}\n")
|
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
|
# EVENTS
|
||||||
@@ -919,7 +1148,7 @@ async def refreshjfakey(ctx):
|
|||||||
|
|
||||||
@bot.command()
|
@bot.command()
|
||||||
async def trialaccount(ctx, username: str = None, password: str = None):
|
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}")
|
log_event(f"trialaccount invoked by {ctx.author}")
|
||||||
|
|
||||||
# Ensure trial accounts are enabled
|
# Ensure trial accounts are enabled
|
||||||
@@ -987,7 +1216,7 @@ async def trialaccount(ctx, username: str = None, password: str = None):
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.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})")
|
log_event(f"Trial account created for {ctx.author} ({username})")
|
||||||
else:
|
else:
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -1223,7 +1452,7 @@ async def cleanup(ctx):
|
|||||||
await ctx.send("✅ Cleanup complete.")
|
await ctx.send("✅ Cleanup complete.")
|
||||||
|
|
||||||
@bot.command()
|
@bot.command()
|
||||||
async def listvalidusers(ctx):
|
async def validusers(ctx):
|
||||||
"""Admin-only: List how many registered users have a valid role."""
|
"""Admin-only: List how many registered users have a valid role."""
|
||||||
if not has_admin_role(ctx.author):
|
if not has_admin_role(ctx.author):
|
||||||
await ctx.send("❌ You don’t have permission to use this command.")
|
await ctx.send("❌ You don’t have permission to use this command.")
|
||||||
@@ -1415,6 +1644,100 @@ async def activestreams(ctx):
|
|||||||
await ctx.send(f"❌ Error fetching active streams: {e}")
|
await ctx.send(f"❌ Error fetching active streams: {e}")
|
||||||
print(f"[activestreams] Error: {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()
|
@bot.command()
|
||||||
async def qbview(ctx):
|
async def qbview(ctx):
|
||||||
"""Admin-only: View current qBittorrent downloads."""
|
"""Admin-only: View current qBittorrent downloads."""
|
||||||
@@ -1596,9 +1919,9 @@ async def storage(ctx):
|
|||||||
|
|
||||||
|
|
||||||
@bot.command()
|
@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}")
|
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 JELLYSEERR_ENABLED: usage_args.append("<Jellyseerr ID>")
|
||||||
|
|
||||||
if jellyfin_username is None or user is None or (JELLYSEERR_ENABLED and js_id is None):
|
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
|
return
|
||||||
|
|
||||||
add_account(user.id, jellyfin_username, jf_id, js_id)
|
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()
|
@bot.command()
|
||||||
@@ -1634,6 +1957,103 @@ async def unlink(ctx, discord_user: discord.User = None):
|
|||||||
delete_account(discord_user.id)
|
delete_account(discord_user.id)
|
||||||
await ctx.send(f"✅ Unlinked Jellyfin account **{account[0]}** from Discord user {discord_user.mention}.")
|
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()
|
@bot.command()
|
||||||
async def setprefix(ctx, new_prefix: str = None):
|
async def setprefix(ctx, new_prefix: str = None):
|
||||||
@@ -1667,7 +2087,247 @@ async def setprefix(ctx, new_prefix: str = None):
|
|||||||
|
|
||||||
|
|
||||||
@bot.command()
|
@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 don’t 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 don’t 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 don’t 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 don’t 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}")
|
log_event(f"updates invoked by {ctx.author}")
|
||||||
member = ctx.guild.get_member(ctx.author.id)
|
member = ctx.guild.get_member(ctx.author.id)
|
||||||
if not has_admin_role(ctx.author):
|
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}recoveraccount <newpassword>` - Reset your password",
|
||||||
f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account",
|
f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account",
|
||||||
f"`{PREFIX}movies2watch` - Lists 5 random movie suggestions from the Jellyfin Library",
|
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:
|
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)
|
embed.add_field(name="🎬 Jellyfin Commands", value="\n".join(user_cmds), inline=False)
|
||||||
|
|
||||||
@@ -1804,14 +2466,14 @@ async def help_command(ctx):
|
|||||||
# --- Admin Commands ---
|
# --- Admin Commands ---
|
||||||
if is_admin:
|
if is_admin:
|
||||||
# Admin Jellyfin commands
|
# 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:
|
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 = [
|
admin_cmds = [
|
||||||
link_command,
|
link_command,
|
||||||
f"`{PREFIX}unlink @user` - Manually unlink accounts",
|
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}cleanup` - Remove Jellyfin accounts from users without roles",
|
||||||
f"`{PREFIX}lastcleanup` - See last cleanup time and time remaining",
|
f"`{PREFIX}lastcleanup` - See last cleanup time and time remaining",
|
||||||
f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user",
|
f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user",
|
||||||
@@ -1848,8 +2510,13 @@ async def help_command(ctx):
|
|||||||
|
|
||||||
# Admin Bot commands
|
# Admin Bot commands
|
||||||
admin_bot_cmds = [
|
admin_bot_cmds = [
|
||||||
|
f"`{PREFIX}stats` - View Local and Global Jellycord Stats",
|
||||||
f"`{PREFIX}setprefix` - Change the bot's command prefix",
|
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}changelog` - View changelog for current bot version",
|
||||||
f"`{PREFIX}logging` - Enable/disable console event logging"
|
f"`{PREFIX}logging` - Enable/disable console event logging"
|
||||||
]
|
]
|
||||||
@@ -1938,7 +2605,7 @@ async def cleanup_task():
|
|||||||
else:
|
else:
|
||||||
created_at_local = created_at_utc.astimezone(LOCAL_TZ)
|
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
|
# Delete trial Jellyfin user
|
||||||
try:
|
try:
|
||||||
delete_jellyfin_user(trial.get("jellyfin_username"))
|
delete_jellyfin_user(trial.get("jellyfin_username"))
|
||||||
@@ -1995,6 +2662,83 @@ async def cleanup_task():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Cleanup] Failed to send removed message to sync channel: {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
|
# JFA-Go Scheduled Token Refresh
|
||||||
# =====================
|
# =====================
|
||||||
@@ -2071,6 +2815,12 @@ async def on_ready():
|
|||||||
if not check_for_updates.is_running():
|
if not check_for_updates.is_running():
|
||||||
check_for_updates.start()
|
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(
|
await bot.change_presence(
|
||||||
activity=discord.Activity(type=discord.ActivityType.watching, name=f"{PREFIX}help")
|
activity=discord.Activity(type=discord.ActivityType.watching, name=f"{PREFIX}help")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ pytz==2025.2
|
|||||||
apscheduler==3.11.0
|
apscheduler==3.11.0
|
||||||
qbittorrent-api==2025.7.0
|
qbittorrent-api==2025.7.0
|
||||||
proxmoxer==2.2.0
|
proxmoxer==2.2.0
|
||||||
|
pymysql==1.1.2
|
||||||
|
psutil==7.1.3
|
||||||
@@ -1 +1 @@
|
|||||||
{ "version": "1.0.8" }
|
{ "version": "1.0.9" }
|
||||||
@@ -1 +1 @@
|
|||||||
1.0.8
|
1.0.9
|
||||||
Reference in New Issue
Block a user