9 Commits
1.0.5 ... 1.0.7

Author SHA1 Message Date
c5d619d450 Redid what2watch 2025-09-21 18:13:47 -05:00
547ccbbb0a Create ROADMAP.md 2025-09-21 16:16:15 -05:00
f862f46e16 Proxmox Integration 2025-09-20 14:21:26 -05:00
c4a1ccf770 Jfa refresh fix 2025-09-15 15:18:40 -05:00
a050becc14 Fix qbit not disabling properly 2025-09-10 19:33:06 -05:00
e5e22b584a Added qBittorrent Support, reformatted help command 2025-09-10 19:26:23 -05:00
1f2c251e80 Jfa-Go Support 2025-09-10 18:29:02 -05:00
a0b4a2087a Added Progress bar to Active Streams 2025-09-10 17:21:30 -05:00
d62f6f6470 Fix help command 2025-09-10 17:08:45 -05:00
8 changed files with 832 additions and 73 deletions

20
.env
View File

@@ -15,6 +15,26 @@ 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
ENABLE_JFA=false
JFA_URL=http://localhost:8056
JFA_USERNAME=yourusername
JFA_PASSWORD=yourpassword
JFA_API_KEY=your_api_key_here
# QBittorrent
ENABLE_QBITTORRENT=false
QBIT_HOST=http://localhost:8080
QBIT_USERNAME=your_username
QBIT_PASSWORD=your_password
# Proxmox
ENABLE_PROXMOX=false
PROXMOX_HOST=https://your-proxmox-server:8006
PROXMOX_TOKEN_NAME=root@pam!yourtokenname
PROXMOX_TOKEN_VALUE=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
PROXMOX_VERIFY_SSL=false
# MySQL # MySQL
DB_HOST=localhost DB_HOST=localhost
DB_USER=root DB_USER=root

View File

@@ -1,3 +1,17 @@
# 1.0.7
- Fixed JFA-GO API keys expiring. The bot now schedules a key refresh
- Added Proxmox support for checking storage pool size
- !what2watch is now !movies2watch
- Added !shows2watch, now pulls 5 random shows
# 1.0.6
- Added Progress bar to Active Streams
- Added JFA-Go support for external invites
- Added qBittorrent Support
- Reformated Help Command
# 1.0.5 # 1.0.5
- Added Timezone support in .env - Added Timezone support in .env

View File

@@ -44,24 +44,40 @@ Fill out values in the .env and you're good to go!
![image](https://cdn.pengucc.com/images/projects/jellycord/readme/role-required.png) ![image](https://cdn.pengucc.com/images/projects/jellycord/readme/role-required.png)
***User Commands*** ***🎬 User Commands***
- `!createaccount` <username> <password> - Create your Jellyfin account - `!createaccount` <username> <password> - Create your Jellyfin account
- `!recoveraccount` <username> <newpassword> - Reset your password - `!recoveraccount` <username> <newpassword> - Reset your password
- `!deleteaccount` <username> - Delete your Jellyfin account - `!deleteaccount` <username> - Delete your Jellyfin account
- `!trialaccount` <username> <password> - Create a 24-hour trial Jellyfin account. Only if ENABLE_TRIAL_ACCOUNTS=True - `!trialaccount` <username> <password> - Create a 24-hour trial Jellyfin account. Only if ENABLE_TRIAL_ACCOUNTS=True
- `!what2watch` - Lists 5 random movie suggestions from the Jellyfin Library - `!movies2watch` - Lists 5 random movie suggestions from the Jellyfin Library
- `!shows2watch` - Lists 5 random show suggestions from the Jellyfin Library
- `!help` - Displays help command
***Admin Commands*** ***🛠️ Admin Commands***
- `!link` <jellyfin_username> @user - Manually link accounts
- `!unlink` @user - Manually unlink accounts
- `!listvalidusers` - Show number of valid and invalid accounts
- `!cleanup` - Remove Jellyfin accounts from users without roles - `!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
- `!searchdiscord` @user - Find linked Jellyfin account - `!searchdiscord` @user - Find linked Jellyfin account
- `!scanlibraries` - Scan all Jellyfin libraries - `!scanlibraries` - Scan all Jellyfin libraries
- `!activestreams` - View all Active Jellyfin streams - `!activestreams` - View all Active Jellyfin streams
- `!link` <jellyfin_username> @user - Manually link accounts
- `!unlink` @user - Manually unlink accounts
***Admin Bot Commands*** ***💾 qBittorrent Commands***
- `!qbview` - View current qBittorrent downloads
***🗳️ Proxmox Commands***
- `!storage` - Show available storage pools and free space
***🔑 JFA Commands***
- `!createinvite` - Create a new JFA invite link
- `!listinvites` - List all active JFA invite links
- `!deleteinvite <code>` - Delete a specific JFA Invite
- `!refreshjfakey` - Refreshes the JFA API Key Forcefully
***⚙️ Admin Bot Commands***
- `!setprefix` - Change the bots command prefix - `!setprefix` - Change the bots command prefix
- `!updates` - Manually check for bot updates - `!updates` - Manually check for bot updates
- `!logging` - Enable/Disable Console Event Logging - `!logging` - Enable/Disable Console Event Logging

7
ROADMAP.md Normal file
View File

@@ -0,0 +1,7 @@
- **Future Features**
- Servarr Support
- **To Do**
- Music/Podcast Support (Listening in VC from Jellyfin Library)

820
app.py
View File

@@ -7,6 +7,8 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
import pytz import pytz
import random import random
import qbittorrentapi
from proxmoxer import ProxmoxAPI
# ===================== # =====================
# ENV + VALIDATION # ENV + VALIDATION
@@ -37,6 +39,23 @@ JELLYSEERR_ENABLED = os.getenv("JELLYSEERR_ENABLED", "false").lower() == "true"
JELLYSEERR_URL = os.getenv("JELLYSEERR_URL", "").rstrip("/") JELLYSEERR_URL = os.getenv("JELLYSEERR_URL", "").rstrip("/")
JELLYSEERR_API_KEY = os.getenv("JELLYSEERR_API_KEY", "") JELLYSEERR_API_KEY = os.getenv("JELLYSEERR_API_KEY", "")
ENABLE_JFA = os.getenv("ENABLE_JFA", "False").lower() == "true"
JFA_URL = os.getenv("JFA_URL")
JFA_USERNAME = os.getenv("JFA_USERNAME")
JFA_PASSWORD = os.getenv("JFA_PASSWORD")
JFA_API_KEY = os.getenv("JFA_API_KEY")
ENABLE_QBITTORRENT = os.getenv("ENABLE_QBITTORRENT", "False").lower() == "true"
QBIT_HOST = os.getenv("QBIT_HOST")
QBIT_USERNAME = os.getenv("QBIT_USERNAME")
QBIT_PASSWORD = os.getenv("QBIT_PASSWORD")
ENABLE_PROXMOX = os.getenv("ENABLE_PROXMOX", "False").lower() == "true"
PROXMOX_HOST = os.getenv("PROXMOX_HOST")
PROXMOX_TOKEN_NAME = os.getenv("PROXMOX_TOKEN_NAME")
PROXMOX_TOKEN_VALUE = os.getenv("PROXMOX_TOKEN_VALUE")
PROXMOX_VERIFY_SSL = os.getenv("PROXMOX_VERIFY_SSL", "False").lower() == "true"
DB_HOST = get_env_var("DB_HOST") DB_HOST = get_env_var("DB_HOST")
DB_USER = get_env_var("DB_USER") DB_USER = get_env_var("DB_USER")
DB_PASSWORD = get_env_var("DB_PASSWORD") DB_PASSWORD = get_env_var("DB_PASSWORD")
@@ -44,7 +63,7 @@ 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")
BOT_VERSION = "1.0.5" BOT_VERSION = "1.0.7"
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"
@@ -67,6 +86,28 @@ intents.members = True
intents.message_content = True intents.message_content = True
bot = commands.Bot(command_prefix=PREFIX, intents=intents, help_command=None) bot = commands.Bot(command_prefix=PREFIX, intents=intents, help_command=None)
# =====================
# QBITTORRENT SETUP
# =====================
if ENABLE_QBITTORRENT:
qb = qbittorrentapi.Client(
host=QBIT_HOST,
username=QBIT_USERNAME,
password=QBIT_PASSWORD
)
try:
qb.auth_log_in()
print("✅ Logged in to qBittorrent")
except qbittorrentapi.LoginFailed:
print("❌ Failed to log in to qBittorrent API")
qb = None
else:
qb = None # qBittorrent disabled
# ===================== # =====================
# DATABASE SETUP # DATABASE SETUP
# ===================== # =====================
@@ -258,6 +299,32 @@ def reset_jellyfin_password(username: str, new_password: str) -> bool:
response = requests.post(f"{JELLYFIN_URL}/Users/{user_id}/Password", headers=headers, json=data) response = requests.post(f"{JELLYFIN_URL}/Users/{user_id}/Password", headers=headers, json=data)
return response.status_code in (200, 204) return response.status_code in (200, 204)
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
# ===================== # =====================
# JELLYSEERR HELPERS # JELLYSEERR HELPERS
# ===================== # =====================
@@ -314,6 +381,119 @@ 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
# =====================
# QBITTORRENT HELPERS
# =====================
def progress_bar(progress: float, length: int = 20) -> str:
"""Return a textual progress bar for the embed."""
filled_length = int(length * progress)
bar = '' * filled_length + '' * (length - filled_length)
return f"[{bar}] {progress*100:.2f}%"
# =====================
# PROXMOX HELPERS
# =====================
def get_proxmox_client():
"""Create and return a Proxmox client using API token auth."""
if not (PROXMOX_HOST and PROXMOX_TOKEN_NAME and PROXMOX_TOKEN_VALUE):
raise ValueError("Proxmox API credentials are not fully configured in .env")
# Parse host + port safely
base_host = PROXMOX_HOST.replace("https://", "").replace("http://", "")
if ":" in base_host:
host, port = base_host.split(":")
else:
host, port = base_host, 8006 # default Proxmox port
# Split token into user + token name
try:
user, token_name = PROXMOX_TOKEN_NAME.split("!")
except ValueError:
raise ValueError(
"❌ PROXMOX_TOKEN_NAME must be in the format 'user@realm!tokenid'"
)
log_event(f"[Proxmox] Connecting to {host}:{port} as {user} with token '{token_name}'")
return ProxmoxAPI(
host,
port=int(port),
user=user,
token_name=token_name,
token_value=PROXMOX_TOKEN_VALUE,
verify_ssl=PROXMOX_VERIFY_SSL
)
# =====================
# JFA-GO HELPERS
# =====================
def refresh_jfa_token() -> bool:
"""
Authenticate to JFA-Go with username/password (Basic auth) against /token/login,
write the returned token to .env (JFA_TOKEN and JFA_API_KEY), and reload env.
Returns True on success.
"""
global JFA_TOKEN, JFA_API_KEY
if not (JFA_URL and JFA_USERNAME and JFA_PASSWORD):
print("[JFA] Missing JFA_URL/JFA_USERNAME/JFA_PASSWORD in environment.")
return False
url = JFA_URL.rstrip("/") + "/token/login"
headers = {"accept": "application/json"}
try:
# Option A: let requests build the Basic header
r = requests.get(url, auth=(JFA_USERNAME, JFA_PASSWORD), headers=headers, timeout=10)
# If you prefer to build the header manually (exactly like your curl), use:
# creds = f"{JFA_USERNAME}:{JFA_PASSWORD}".encode()
# b64 = base64.b64encode(creds).decode()
# headers["Authorization"] = f"Basic {b64}"
# r = requests.get(url, headers=headers, timeout=10)
if r.status_code != 200:
print(f"[JFA] token login failed: {r.status_code} - {r.text}")
return False
data = r.json() if r.text else {}
# try common token fields
token = (
data.get("token")
or data.get("access_token")
or data.get("jwt")
or data.get("api_key")
or data.get("data") # sometimes nested
)
# If API returns {"token": "<token>"} -> good. If it returns a wrapped structure,
# try to handle a couple of other shapes:
if not token:
# if response is {'success': True} or {'invites':...} then no token present
# print for debugging
print("[JFA] token not found in response JSON:", data)
return False
# Persist token to .env under both names (compatibility)
_update_env_key("JFA_TOKEN", token)
_update_env_key("JFA_API_KEY", token)
# Update in-memory values and reload env
JFA_TOKEN = token
JFA_API_KEY = token
load_dotenv(override=True)
print("[JFA] Successfully refreshed token and updated .env")
return True
except Exception as e:
print(f"[JFA] Exception while refreshing token: {e}", exc_info=True)
return False
# ===================== # =====================
# DISCORD HELPERS # DISCORD HELPERS
# ===================== # =====================
@@ -369,31 +549,25 @@ def get_metadata(key):
conn.close() conn.close()
return row[0] if row else None return row[0] if row else None
def create_trial_jellyfin_user(username, password): def _update_env_key(key: str, value: str, env_path: str = ".env"):
payload = { """Update or append key=value in .env (keeps file order)."""
"Name": username, lines = []
"Password": password, found = False
"Policy": { try:
"EnableDownloads": False, with open(env_path, "r", encoding="utf-8") as f:
"EnableSyncTranscoding": False, lines = f.readlines()
"EnableRemoteControlOfOtherUsers": False, except FileNotFoundError:
"EnableLiveTvAccess": False, lines = []
"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: with open(env_path, "w", encoding="utf-8") as f:
return response.json().get("Id") for line in lines:
if line.strip().startswith(f"{key}="):
f.write(f"{key}={value}\n")
found = True
else: else:
print(f"[Jellyfin] Trial user creation failed. Status: {response.status_code}, Response: {response.text}") f.write(line)
return None if not found:
f.write(f"{key}={value}\n")
# ===================== # =====================
@@ -481,6 +655,264 @@ async def createaccount(ctx, username: str = None, password: str = None):
else: else:
await ctx.send(f"❌ Failed to create Jellyfin account **{username}**. It may already exist.") await ctx.send(f"❌ Failed to create Jellyfin account **{username}**. It may already exist.")
@bot.command()
async def createinvite(ctx):
"""Admin-only: Create a new JFA-Go invite link (create -> fetch latest invite)."""
if not ENABLE_JFA:
await ctx.send("❌ JFA support is not enabled in the bot configuration.")
return
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
payload = {"days": 7, "max_uses": 1}
base = JFA_URL.rstrip("/")
# Try Bearer, fallback to X-Api-Key
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
r = requests.post(f"{base}/invites", headers=headers, json=payload, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.post(f"{base}/invites", headers=headers, json=payload, timeout=10)
if r.status_code not in (200, 201):
await ctx.send(f"❌ Failed to create invite. Status code: {r.status_code}\nResponse: {r.text}")
return
# Fetch invites list (some JFA builds only return success on POST)
r2 = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r2.status_code not in (200, 201):
await ctx.send(f"❌ Failed to fetch invite list. Status code: {r2.status_code}\nResponse: {r2.text}")
return
invites_resp = r2.json()
# Normalize different shapes: either {'invites': [...]} or a list
if isinstance(invites_resp, dict) and "invites" in invites_resp:
invites_list = invites_resp["invites"]
elif isinstance(invites_resp, list):
invites_list = invites_resp
else:
# unexpected shape
print(f"[createinvite] Unexpected invites response shape: {invites_resp}")
await ctx.send("❌ Unexpected response from JFA when fetching invites. Check bot logs.")
return
if not invites_list:
await ctx.send("❌ No invites found after creation.")
return
latest = invites_list[-1] # assume newest is last; adjust if your JFA sorts differently
print(f"[createinvite] Latest invite object: {latest}") # debug log
code = latest.get("code") or latest.get("id") or latest.get("token")
url = latest.get("url") or latest.get("link")
if not url and code:
# Common invite URL pattern; adjust if your instance is different
url = f"{base}/invite/{code}"
# created: JFA gives epoch seconds in 'created'
created_local_str = None
created_ts = latest.get("created")
if created_ts:
try:
created_dt = datetime.datetime.utcfromtimestamp(int(created_ts)).replace(tzinfo=datetime.timezone.utc)
created_local = created_dt.astimezone(LOCAL_TZ)
created_local_str = created_local.strftime("%Y-%m-%d %H:%M:%S %Z")
except Exception:
created_local_str = None
remaining = latest.get("remaining-uses", "N/A")
embed = discord.Embed(
title="🎟️ New Jellyfin Invite Created",
color=discord.Color.blue()
)
embed.add_field(name="Code", value=f"`{code}`" if code else "N/A", inline=True)
embed.add_field(name="Link", value=f"[Click here]({url})" if url else "N/A", inline=True)
footer_parts = []
if created_local_str:
footer_parts.append(f"Created: {created_local_str}")
footer_parts.append(f"Remaining uses: {remaining}")
embed.set_footer(text="".join(footer_parts))
embed.set_author(name=f"Created by {ctx.author.display_name}", icon_url=ctx.author.avatar.url if ctx.author.avatar else None)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error creating invite: {e}")
print(f"[createinvite] Error: {e}", exc_info=True)
@bot.command()
async def listinvites(ctx):
"""Admin-only: List all active JFA-Go invites."""
if not ENABLE_JFA:
await ctx.send("❌ JFA support is not enabled in the bot configuration.")
return
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
base = JFA_URL.rstrip("/")
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code not in (200, 201):
await ctx.send(f"❌ Failed to fetch invites. Status code: {r.status_code}\nResponse: {r.text}")
return
invites_resp = r.json()
print(f"[listinvites] Raw response: {invites_resp}") # Debug
# Normalize to a list of invite dicts
if isinstance(invites_resp, dict) and "invites" in invites_resp:
invites_list = invites_resp["invites"]
elif isinstance(invites_resp, list):
invites_list = invites_resp
else:
await ctx.send("❌ Unexpected invite response format. Check logs.")
return
if not invites_list:
await ctx.send(" No active invites found.")
return
embed = discord.Embed(
title="📋 Active Jellyfin Invites",
color=discord.Color.green()
)
for invite in invites_list:
code = invite.get("code")
url = f"{base}/invite/{code}" if code else None
remaining = invite.get("remaining-uses", "N/A")
created_str = None
created_ts = invite.get("created")
if created_ts:
try:
created_dt = datetime.datetime.utcfromtimestamp(int(created_ts)).replace(tzinfo=datetime.timezone.utc)
created_local = created_dt.astimezone(LOCAL_TZ)
created_str = created_local.strftime("%Y-%m-%d %H:%M:%S %Z")
except Exception:
created_str = None
value = f"Uses left: {remaining}"
if url:
value += f"\n[Invite Link]({url})"
if created_str:
value += f"\nCreated: {created_str}"
embed.add_field(
name=f"🔑 {code}",
value=value,
inline=False
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error fetching invites: {e}")
print(f"[listinvites] Error: {e}", exc_info=True)
@bot.command()
async def deleteinvite(ctx, code: str):
"""Admin-only: Delete a specific JFA-Go invite by code."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
base = JFA_URL.rstrip("/")
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
# Try DELETE with body (legacy API)
r = requests.delete(f"{base}/invites", headers=headers, json={"code": code}, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.delete(f"{base}/invites", headers=headers, json={"code": code}, timeout=10)
if r.status_code in (200, 204):
await ctx.send(f"✅ Invite `{code}` has been deleted.")
else:
await ctx.send(f"❌ Failed to delete invite `{code}`. Status code: {r.status_code}\nResponse: {r.text}")
except Exception as e:
await ctx.send(f"❌ Error deleting invite: {e}")
print(f"[deleteinvite] Error: {e}", exc_info=True)
@bot.command()
async def clearinvites(ctx):
"""Admin-only: Delete ALL JFA-Go invites (use with caution!)."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
base = JFA_URL.rstrip("/")
headers = {"Authorization": f"Bearer {JFA_API_KEY}"}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code == 401:
headers = {"X-Api-Key": JFA_API_KEY}
r = requests.get(f"{base}/invites", headers=headers, timeout=10)
if r.status_code not in (200, 201):
await ctx.send(f"❌ Failed to fetch invites. Status code: {r.status_code}\nResponse: {r.text}")
return
invites_resp = r.json()
invites_list = invites_resp["invites"] if isinstance(invites_resp, dict) and "invites" in invites_resp else invites_resp
if not invites_list:
await ctx.send(" No invites to delete.")
return
deleted = 0
for invite in invites_list:
code = invite.get("code")
if not code:
continue
dr = requests.delete(f"{base}/invites", headers=headers, json={"code": code}, timeout=10)
if dr.status_code in (200, 204):
deleted += 1
await ctx.send(f"✅ Deleted {deleted} invites.")
except Exception as e:
await ctx.send(f"❌ Error clearing invites: {e}")
print(f"[clearinvites] Error: {e}", exc_info=True)
@bot.command()
async def refreshjfakey(ctx):
"""Admin-only: Force refresh the JFA-Go API key using username/password auth."""
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
if not ENABLE_JFA:
await ctx.send("⚠️ JFA-Go integration is disabled in the configuration.")
return
await ctx.send("🔁 Attempting to refresh JFA token...")
success = refresh_jfa_token()
if success:
await ctx.send("✅ Successfully refreshed the JFA-Go API token and updated `.env`")
log_event(f"Admin {ctx.author} forced a JFA API Token refresh")
else:
await ctx.send("❌ Failed to refresh the JFA-Go API token. Check bot logs for details.")
log_event(f"Admin {ctx.author} attempted JFA API Token refresh but failed")
@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.""" """Create a 24-hour trial Jellyfin account. DM-only, one-time per user."""
@@ -617,8 +1049,9 @@ async def deleteaccount(ctx, username: str = None):
await ctx.send(f"❌ Failed to delete Jellyfin account **{username}**.") await ctx.send(f"❌ Failed to delete Jellyfin account **{username}**.")
@bot.command() @bot.command()
async def what2watch(ctx): async def movies2watch(ctx):
"""Pick 5 random movies from the Jellyfin library with embeds and posters.""" log_event(f"movies2watch invoked by {ctx.author}")
"""Pick 5 random movies from the Jellyfin library with embeds, and IMDb links."""
member = ctx.guild.get_member(ctx.author.id) if ctx.guild else None member = ctx.guild.get_member(ctx.author.id) if ctx.guild else None
if not member or not has_required_role(member): if not member or not has_required_role(member):
await ctx.send(f"{ctx.author.mention}, you dont have the required role to use this command.") await ctx.send(f"{ctx.author.mention}, you dont have the required role to use this command.")
@@ -626,8 +1059,17 @@ async def what2watch(ctx):
headers = {"X-Emby-Token": JELLYFIN_API_KEY} headers = {"X-Emby-Token": JELLYFIN_API_KEY}
try: try:
# Fetch all movies # Fetch all movies (include ProviderIds explicitly!)
r = requests.get(f"{JELLYFIN_URL}/Items?IncludeItemTypes=Movie&Recursive=true", headers=headers, timeout=10) r = requests.get(
f"{JELLYFIN_URL}/Items",
headers=headers,
params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "ProviderIds"
},
timeout=10
)
if r.status_code != 200: if r.status_code != 200:
await ctx.send(f"❌ Failed to fetch movies. Status code: {r.status_code}") await ctx.send(f"❌ Failed to fetch movies. Status code: {r.status_code}")
return return
@@ -647,7 +1089,7 @@ async def what2watch(ctx):
) )
for movie in selection: for movie in selection:
name = movie.get("Name") name = movie.get("Name", "Unknown Title")
year = movie.get("ProductionYear", "N/A") year = movie.get("ProductionYear", "N/A")
runtime = movie.get("RunTimeTicks", None) runtime = movie.get("RunTimeTicks", None)
runtime_min = int(runtime / 10_000_000 / 60) if runtime else "N/A" runtime_min = int(runtime / 10_000_000 / 60) if runtime else "N/A"
@@ -657,11 +1099,16 @@ async def what2watch(ctx):
if "PrimaryImageTag" in movie and movie["PrimaryImageTag"]: if "PrimaryImageTag" in movie and movie["PrimaryImageTag"]:
poster_url = f"{JELLYFIN_URL}/Items/{movie['Id']}/Images/Primary?tag={movie['PrimaryImageTag']}&quality=90" poster_url = f"{JELLYFIN_URL}/Items/{movie['Id']}/Images/Primary?tag={movie['PrimaryImageTag']}&quality=90"
field_value = f"Year: {year}\nRuntime: {runtime_min} min" # IMDb link if available
imdb_id = movie.get("ProviderIds", {}).get("Imdb")
imdb_link = f"[IMDb Link](https://www.imdb.com/title/{imdb_id})" if imdb_id else "No IMDb ID available"
# Field content
field_value = f"Year: {year}\nRuntime: {runtime_min} min\n{imdb_link}"
embed.add_field(name=name, value=field_value, inline=False) embed.add_field(name=name, value=field_value, inline=False)
if poster_url: if poster_url:
embed.set_image(url=poster_url) # Only last movie's poster will appear as main embed image embed.set_image(url=poster_url) # Only the last poster appears as main embed image
await ctx.send(embed=embed) await ctx.send(embed=embed)
@@ -669,6 +1116,72 @@ async def what2watch(ctx):
await ctx.send(f"❌ Error fetching movies: {e}") await ctx.send(f"❌ Error fetching movies: {e}")
print(f"[what2watch] Error: {e}") print(f"[what2watch] Error: {e}")
@bot.command()
async def shows2watch(ctx):
log_event(f"shows2watch invoked by {ctx.author}")
"""Pick 5 random TV shows from the Jellyfin library with embeds, and IMDb links."""
member = ctx.guild.get_member(ctx.author.id) if ctx.guild else None
if not member or not has_required_role(member):
await ctx.send(f"{ctx.author.mention}, you dont have the required role to use this command.")
return
headers = {"X-Emby-Token": JELLYFIN_API_KEY}
try:
# Fetch all shows (include ProviderIds explicitly!)
r = requests.get(
f"{JELLYFIN_URL}/Items",
headers=headers,
params={
"IncludeItemTypes": "Series",
"Recursive": "true",
"Fields": "ProviderIds"
},
timeout=10
)
if r.status_code != 200:
await ctx.send(f"❌ Failed to fetch shows. Status code: {r.status_code}")
return
shows = r.json().get("Items", [])
if not shows:
await ctx.send("⚠️ No shows found in the library.")
return
# Pick 5 random shows
selection = random.sample(shows, min(5, len(shows)))
embed = discord.Embed(
title="📺 Shows to Watch",
description="Here are 5 random TV show suggestions from the library:",
color=discord.Color.green()
)
for show in selection:
name = show.get("Name", "Unknown Title")
year = show.get("ProductionYear", "N/A")
# Poster URL if available
poster_url = None
if "PrimaryImageTag" in show and show["PrimaryImageTag"]:
poster_url = f"{JELLYFIN_URL}/Items/{show['Id']}/Images/Primary?tag={show['PrimaryImageTag']}&quality=90"
# IMDb link if available
imdb_id = show.get("ProviderIds", {}).get("Imdb")
imdb_link = f"[IMDb Link](https://www.imdb.com/title/{imdb_id})" if imdb_id else "No IMDb ID available"
# Field content
field_value = f"Year: {year}\n{imdb_link}"
embed.add_field(name=name, value=field_value, inline=False)
if poster_url:
embed.set_image(url=poster_url) # Only the last show's poster will appear as the embed image
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error fetching shows: {e}")
print(f"[shows2watch] Error: {e}")
@bot.command() @bot.command()
async def cleanup(ctx): async def cleanup(ctx):
@@ -830,7 +1343,7 @@ async def scanlibraries(ctx):
@bot.command() @bot.command()
async def activestreams(ctx): async def activestreams(ctx):
"""Admin-only: Show currently active Jellyfin user streams (movies/episodes only) with progress.""" """Admin-only: Show currently active Jellyfin user streams (movies/episodes only) with progress bar."""
if not has_admin_role(ctx.author): if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.") await ctx.send("❌ You dont have permission to use this command.")
return return
@@ -869,13 +1382,20 @@ async def activestreams(ctx):
# Get progress # Get progress
try: try:
position_ticks = session.get("PlayState", {}).get("PositionTicks", 0) position_ticks = session.get("PlayState", {}).get("PositionTicks", 0)
runtime_ticks = media.get("RunTimeTicks", 1) # fallback to avoid division by zero runtime_ticks = media.get("RunTimeTicks", 1) # avoid div by zero
# Convert ticks to seconds (1 tick = 100 ns)
position_seconds = position_ticks / 10_000_000 position_seconds = position_ticks / 10_000_000
runtime_seconds = runtime_ticks / 10_000_000 runtime_seconds = runtime_ticks / 10_000_000
position_str = str(datetime.timedelta(seconds=int(position_seconds))) position_str = str(datetime.timedelta(seconds=int(position_seconds)))
runtime_str = str(datetime.timedelta(seconds=int(runtime_seconds))) runtime_str = str(datetime.timedelta(seconds=int(runtime_seconds)))
progress_str = f"[{position_str} / {runtime_str}]"
# Progress bar
percent = position_seconds / runtime_seconds if runtime_seconds > 0 else 0
bar_length = 10
filled_length = int(round(bar_length * percent))
bar = "" * filled_length + "" * (bar_length - filled_length)
progress_str = f"{bar} {int(percent*100)}%\n[{position_str} / {runtime_str}]"
except Exception: except Exception:
progress_str = "Unknown" progress_str = "Unknown"
@@ -891,6 +1411,126 @@ 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 qbview(ctx):
"""Admin-only: View current qBittorrent downloads."""
if not ENABLE_QBITTORRENT:
await ctx.send("❌ qBittorrent support is not enabled in the bot configuration.")
return
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
torrents = qb.torrents_info()
embed = discord.Embed(title="qBittorrent Downloads", color=0x00ff00)
if not torrents:
embed.description = "No torrents found."
await ctx.send(embed=embed)
return
# Group torrents by state
state_groups = {
"Downloading / Uploading": [],
"Finished": [],
"Stalled": [],
"Checking / Metadata": [],
"Other": []
}
for t in torrents:
if t.state in ("downloading", "uploading"):
state_groups["Downloading / Uploading"].append(t)
elif t.state in ("completed", "pausedUP", "pausedDL"):
state_groups["Finished"].append(t)
elif t.state in ("stalledUP", "stalledDL"):
state_groups["Stalled"].append(t)
elif t.state in ("checkingUP", "checkingDL", "checking", "metaDL"):
state_groups["Checking / Metadata"].append(t)
else:
state_groups["Other"].append(t)
# Add torrents to embed
for group_name, torrents_list in state_groups.items():
if torrents_list:
value_text = ""
for torrent in torrents_list:
value_text += (
f"{torrent.name}\n"
f"{progress_bar(torrent.progress)}\n"
f"Peers: {torrent.num_leechs} | Seeders: {torrent.num_seeds}\n"
f"Status: {torrent.state}\n\n"
)
embed.add_field(name=group_name, value=value_text, inline=False)
await ctx.send(embed=embed)
@bot.command()
async def storage(ctx):
"""Check Proxmox storage pools and ZFS pools."""
if not ENABLE_PROXMOX:
await ctx.send("⚠️ Proxmox integration is disabled in the configuration.")
return
if not has_admin_role(ctx.author):
await ctx.send("❌ You dont have permission to use this command.")
return
try:
proxmox = get_proxmox_client()
embed = discord.Embed(
title="📦 Proxmox Storage",
description="Storage pool usage and ZFS pools",
color=discord.Color.green()
)
for node in proxmox.nodes.get():
node_name = node["node"]
# ---- ZFS ----
try:
zfs_pools = proxmox.nodes(node_name).disks.zfs.get()
if zfs_pools:
zfs_info = [
f"**{p['name']}**: {p['alloc']/1024**3:.2f} GiB / "
f"{p['size']/1024**3:.2f} GiB ({(p['alloc']/p['size']*100):.1f}%)"
for p in zfs_pools
]
else:
zfs_info = ["No ZFS pools found"]
except Exception as e:
zfs_info = [f"⚠️ Failed to fetch ZFS pools ({e})"]
embed.add_field(
name=f"🖥️ {node_name} - ZFS Pools",
value="\n".join(zfs_info),
inline=False
)
# ---- Normal storage (skip ZFS) ----
try:
storage_info = proxmox.nodes(node_name).storage.get()
normal_lines = [
f"**{s['storage']}**: {s['used']/1024**3:.2f} GiB / "
f"{s['total']/1024**3:.2f} GiB ({(s['used']/s['total']*100):.1f}%)"
for s in storage_info
if s.get("type", "").lower() not in ("zfspool", "zfs")
]
except Exception as e:
normal_lines = [f"⚠️ Failed to fetch normal storage ({e})"]
embed.add_field(
name=f"🗳️ {node_name} - Normal Storage",
value="\n".join(normal_lines) or "No nonZFS storage found",
inline=False
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"⚠️ Unexpected error: {e}")
@bot.command() @bot.command()
async def link(ctx, jellyfin_username: str = None, user: discord.User = None, js_id: str = None): async def link(ctx, jellyfin_username: str = None, user: discord.User = None, js_id: str = None):
@@ -1027,47 +1667,80 @@ async def help_command(ctx):
color=discord.Color.blue() color=discord.Color.blue()
) )
# User commands # --- Jellyfin User Commands ---
user_cmds = [ user_cmds = [
f"`{PREFIX}createaccount <username> <password>` - Create your Jellyfin account", f"`{PREFIX}createaccount <username> <password>` - Create your Jellyfin account",
f"`{PREFIX}recoveraccount <newpassword>` - Reset your password", f"`{PREFIX}recoveraccount <newpassword>` - Reset your password",
f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account" f"`{PREFIX}deleteaccount <username>` - Delete your Jellyfin account",
f"`{PREFIX}what2watch` - Lists 5 random movie suggestions from the Jellyfin Library" f"`{PREFIX}movies2watch` - Lists 5 random movie suggestions from the Jellyfin Library",
f"`{PREFIX}shows2watch` - Lists 5 random show suggestions from the Jellyfin Library"
] ]
# Only show trialaccount if enabled
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 24-hour trial Jellyfin account")
embed.add_field(name="User Commands", value="\n".join(user_cmds), inline=False) embed.add_field(name="🎬 Jellyfin Commands", value="\n".join(user_cmds), inline=False)
# Admin commands # --- Bot Commands ---
bot_cmds = [
f"`{PREFIX}help` - Show this help message"
]
embed.add_field(name="🤖 Bot Commands", value="\n".join(bot_cmds), inline=False)
# --- Admin Commands ---
if is_admin: if is_admin:
# Dynamic link command line # Admin Jellyfin commands
link_command = f"`{PREFIX}link <jellyfin_username> @user` - Manually link accounts" link_command = f"`{PREFIX}link <jellyfin_username> @user` - Manually link accounts"
if JELLYSEERR_ENABLED: if JELLYSEERR_ENABLED:
link_command = f"`{PREFIX}link <jellyfin_username> @user <Jellyseerr ID>` - Manually link accounts with Jellyseerr" link_command = f"`{PREFIX}link <jellyfin_username> @user <Jellyseerr ID>` - Link accounts with Jellyseerr"
embed.add_field(name="Admin Commands", value=( admin_cmds = [
f"`{PREFIX}cleanup` - Remove Jellyfin accounts from users without roles\n" link_command,
f"`{PREFIX}listvalidusers` - Show number of valid and invalid accounts\n" f"`{PREFIX}unlink @user` - Manually unlink accounts",
f"`{PREFIX}lastcleanup` - See Last cleanup time, and time remaining before next cleanup\n" f"`{PREFIX}listvalidusers` - Show number of valid and invalid accounts",
f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user\n" f"`{PREFIX}cleanup` - Remove Jellyfin accounts from users without roles",
f"`{PREFIX}searchdiscord @user` - Find linked Jellyfin account\n" f"`{PREFIX}lastcleanup` - See last cleanup time and time remaining",
f"`{PREFIX}scanlibraries` - Scan all Jellyfin libraries\n" f"`{PREFIX}searchaccount <jellyfin_username>` - Find linked Discord user",
f"`{PREFIX}activestreams` - View all Active Jellyfin streams\n" f"`{PREFIX}searchdiscord @user` - Find linked Jellyfin account",
f"{link_command}\n" f"`{PREFIX}scanlibraries` - Scan all Jellyfin libraries",
f"`{PREFIX}unlink @user` - Manually unlink accounts\n" f"`{PREFIX}activestreams` - View all active Jellyfin streams"
), inline=False) ]
embed.add_field(name="🛠️ Admin Commands", value="\n".join(admin_cmds), inline=False)
embed.add_field(name="Admin Bot Commands", value=( # --- qBittorrent Commands ---
f"`{PREFIX}setprefix` - Change the bot's command prefix\n" if ENABLE_QBITTORRENT:
f"`{PREFIX}updates` - Manually check for bot updates\n" qb_cmds = [
f"`{PREFIX}logging` - Enable/Disable Console Event Logging\n" f"`{PREFIX}qbview` - Show current qBittorrent downloads with progress, peers, and seeders",
), inline=False) ]
embed.add_field(name="💾 qBittorrent Commands", value="\n".join(qb_cmds), inline=False)
# --- Proxmox Commands ---
if ENABLE_PROXMOX:
qb_cmds = [
f"`{PREFIX}storage` - Show available storage pools and free space",
]
embed.add_field(name="🗳️ Proxmox Commands", value="\n".join(qb_cmds), inline=False)
# --- JFA Commands ---
if ENABLE_JFA:
jfa_cmds = [
f"`{PREFIX}createinvite` - Create a new JFA invite link",
f"`{PREFIX}listinvites` - List all active JFA invite links",
f"`{PREFIX}deleteinvite <code>` - Delete a specific JFA invite",
f"`{PREFIX}refreshjfakey` - Refreshes the JFA API Key Forcefully"
]
embed.add_field(name="🔑 JFA Commands", value="\n".join(jfa_cmds), inline=False)
# Admin Bot commands
admin_bot_cmds = [
f"`{PREFIX}setprefix` - Change the bot's command prefix",
f"`{PREFIX}updates` - Manually check for bot updates",
f"`{PREFIX}logging` - Enable/disable console event logging"
]
embed.add_field(name="⚙️ Admin Bot Commands", value="\n".join(admin_bot_cmds), inline=False)
await ctx.send(embed=embed) await ctx.send(embed=embed)
# ===================== # =====================
# TASKS # TASKS
# ===================== # =====================
@@ -1205,6 +1878,30 @@ 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}")
# =====================
# JFA-Go Scheduled Token Refresh
# =====================
if ENABLE_JFA:
@tasks.loop(hours=1)
async def refresh_jfa_loop():
success = refresh_jfa_token()
if success:
log_event("[JFA] Successfully refreshed token (scheduled loop).")
else:
log_event("[JFA] Failed to refresh token (scheduled loop).")
@refresh_jfa_loop.before_loop
async def before_refresh_jfa_loop():
await bot.wait_until_ready()
log_event("[JFA] Token refresh loop waiting until bot is ready.")
# Start the loop inside on_ready to ensure event loop exists
@bot.event
async def on_ready():
if not refresh_jfa_loop.is_running():
refresh_jfa_loop.start()
log_event(f"Bot is ready. Logged in as {bot.user}")
@tasks.loop(hours=1) @tasks.loop(hours=1)
async def check_for_updates(): async def check_for_updates():
@@ -1250,6 +1947,9 @@ async def on_ready():
if not cleanup_task.is_running(): if not cleanup_task.is_running():
cleanup_task.start() cleanup_task.start()
if not refresh_jfa_loop.is_running():
refresh_jfa_loop.start()
if not check_for_updates.is_running(): if not check_for_updates.is_running():
check_for_updates.start() check_for_updates.start()

View File

@@ -4,3 +4,5 @@ mysql-connector-python==9.0.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pytz==2025.2 pytz==2025.2
apscheduler==3.11.0 apscheduler==3.11.0
qbittorrent-api==2025.7.0
proxmoxer==2.2.0

View File

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

View File

@@ -1 +1 @@
1.0.5 1.0.7