Initial commit

This commit is contained in:
2025-11-25 10:44:29 +04:00
commit 2a41d6488f
3 changed files with 352 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
COPY main.py .
RUN pip install --no-cache-dir -r requirements.txt
ENTRYPOINT ["python", "main.py"]
+337
View File
@@ -0,0 +1,337 @@
import qbittorrentapi
import qbittorrentapi.exceptions as qexceptions
import subprocess
import humanize
import requests
import asyncio
import aiohttp
import enum
from telebot.async_telebot import AsyncTeleBot
from telebot.formatting import escape_markdown
import jellyfin_api_client as jellyfin
import telebot.types as types
class States(enum.Enum):
AWAITING_FOR_TORRENT = 0
AWAITING_FOR_CATEGORY = 1
AWAITING_FOR_TORRENT_NUMBER = 2
NONE = 3
conn_info = dict(
host="qbittorrent",
port=8080,
username="family",
password="123456qBittorrent",
)
qbt_client = qbittorrentapi.Client(**conn_info)
qbt_client.auth_log_in()
print(f"qBittorrent: {qbt_client.app.version}")
print(f"qBittorrent Web API: {qbt_client.app.web_api_version}")
for k, v in qbt_client.app.build_info.items():
print(f"{k}: {v}")
bot = AsyncTeleBot('8259146079:AAHpPFoDYK5nw3idJlJvSx6pck8xP5vQG8M')
status_cache = {}
awaiting_torrents = {}
main_markup = types.ReplyKeyboardMarkup(row_width=2, resize_keyboard=True, one_time_keyboard=True)
main_markup.row(types.KeyboardButton("qBittorrent"), types.KeyboardButton("Jellyfin"))
main_markup.add(types.KeyboardButton("Management"))
def format_seconds(seconds):
if seconds < 60:
return f"{seconds}s"
elif seconds < 3600:
return f"{seconds // 60}m {seconds % 60}s"
elif seconds < 86400:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
return f"{hours}h {minutes}m"
else:
days = seconds // 86400
hours = (seconds % 86400) // 3600
return f"{days}d {hours}h"
def human_speed(speed_bytes):
if speed_bytes < 1024:
return f"{speed_bytes} B/s"
return humanize.naturalsize(speed_bytes, binary=True, format='%.1f') + "/s"
def ping_host(ip: str, count: int = 2) -> bool:
cmd = ["ping", "-c", str(count), ip] # use "-n" instead of "-c" on Windows
try:
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return True
except subprocess.CalledProcessError:
return False
@bot.message_handler(commands=['help', 'start'])
async def send_welcome(message):
await bot.send_message(
message.chat.id,
"Hello! Choose an option below:",
reply_markup=main_markup
)
@bot.message_handler(func=lambda message: message.text == "Go back")
async def go_back(message):
await send_welcome(message)
@bot.message_handler(func=lambda message: message.text == "qBittorrent")
async def update_menus(message):
markup = types.ReplyKeyboardMarkup(row_width=2, resize_keyboard=True, one_time_keyboard=True)
markup.row(types.KeyboardButton("Add torrent"), types.KeyboardButton("Delete torrent"))
markup.add(types.KeyboardButton("Get list of active torrents"))
markup.row(types.KeyboardButton("Get total stats"), types.KeyboardButton("Go back"))
await bot.send_message(
message.chat.id,
"Select action.",
reply_markup=markup
)
@bot.message_handler(func=lambda message: message.text == "Jellyfin")
async def update_jf_menus(message):
markup = types.ReplyKeyboardMarkup(row_width=2, resize_keyboard=True, one_time_keyboard=True)
markup.add(types.KeyboardButton("Refresh Jellyfin"))
await bot.send_message(
message.chat.id,
"Select action.",
reply_markup=markup
)
@bot.message_handler(func=lambda message: message.text == "Management")
async def update_mgmt_menus(message):
markup = types.ReplyKeyboardMarkup(row_width=2, resize_keyboard=True, one_time_keyboard=True)
markup.add(types.KeyboardButton("Ping"))
await bot.send_message(
message.chat.id,
"Select action.",
reply_markup=markup
)
@bot.message_handler(func=lambda message: message.text == "Ping")
async def check_reachability(message):
await bot.send_message(
message.chat.id,
"Testing..."
)
response = "<b> Public endpoint reachability </b>\n"
try:
resp = requests.get("https://headscale.hyperwin-homeserver.duckdns.org/health", timeout=1)
if resp.status_code == 200:
response += "🟢 Headscale endpoint\n"
except:
response += "🔴 Headscale endpoint\n"
try:
resp = requests.get("https://immich.hyperwin-homeserver.duckdns.org", timeout=1)
if resp.status_code == 200:
response += "🟢 Immich endpoint\n"
except:
response += "🔴 Immich endpoint\n"
if (ping_host("192.168.0.235")):
response += "🟢 Raspberry Pi 4\n"
else:
response += "🔴 Raspberry Pi 4\n"
if (ping_host("45.81.35.5")):
response += "🟢 VPS\n"
else:
response += "🔴 VPS\n"
await bot.send_message(
message.chat.id,
response,
parse_mode='HTML',
reply_markup=main_markup
)
@bot.message_handler(func=lambda message: message.text == "Add torrent")
async def handle_torrent(message):
status_cache[message.chat.id] = States.AWAITING_FOR_TORRENT
await bot.reply_to(message, "Please send a magnet link or a torrent file")
@bot.message_handler(func=lambda message: message.text == "Get list of active torrents")
async def get_active_torrents(message):
torrents = qbt_client.torrents_info(status_filter="downloading")
torrents.sort(key=lambda x: x.name.lower())
msg = ""
for i, torrent in enumerate(torrents, start=1):
msg += f"- {torrent.name}, ETA: {format_seconds(int(torrent.eta))}"
if (msg == ""):
msg = "No torrents are being downloaded at the moment."
await bot.send_message(
message.chat.id,
msg,
reply_markup=main_markup
)
@bot.message_handler(func=lambda message: message.text == "Get total stats")
async def send_qb_stats(message):
try:
# Get server state with all metrics
maindata = qbt_client.sync_maindata()
server_state = maindata.get('server_state', {})
transfer = qbt_client.transfer_info()
# Extract metrics with fallbacks
session_dl = humanize.naturalsize(transfer.dl_info_data, binary=True)
session_ul = humanize.naturalsize(transfer.up_info_data, binary=True)
current_dl = human_speed(transfer.dl_info_speed)
current_ul = human_speed(transfer.up_info_speed)
# Get torrent states
torrents = qbt_client.torrents_info()
states = [t.state for t in torrents]
downloading = sum(1 for s in states if 'downloading' in s and 'paused' not in s)
paused = sum(1 for s in states if 'paused' in s)
total = len(torrents)
# Format metrics with clear sections
stats = (
"📊 <b>qBittorrent Metrics</b>\n\n"
"🔄 <b>Current Session</b>\n"
f"⬇️ Downloaded: {session_dl}\n"
f"⬆️ Uploaded: {session_ul}\n\n"
"⚡ <b>Live Speeds</b>\n"
f"⏬ Down: {current_dl}\n"
f"⏫ Up: {current_ul}\n\n"
"🧩 <b>Torrent Summary</b>\n"
f"✅ Total: {total}\n"
f"⏬ Active DL: {downloading}\n"
f"⏸️ Paused: {paused}"
)
await bot.send_message(
message.chat.id,
stats,
parse_mode='HTML',
reply_markup=main_markup
)
except Exception as e:
print(e)
error_msg = (
"❌ <b>Error fetching metrics:</b>\n"
)
bot.reply_to(message, error_msg, parse_mode='HTML', reply_markup=main_markup)
@bot.message_handler(func=lambda message: message.text =="Delete torrent")
async def delete_torrent(message):
torrents = qbt_client.torrents_info(status_filter="completed")
torrents.sort(key=lambda x: x.name.lower())
msg = ""
for i, torrent in enumerate(torrents, start=1):
msg += f"**{i}**\\. {escape_markdown(torrent.name)}\n"
await bot.send_message(
message.chat.id,
msg,
parse_mode="MarkdownV2"
)
status_cache[message.chat.id] = States.AWAITING_FOR_TORRENT_NUMBER
@bot.message_handler(func=lambda message: message.text == "Refresh Jellyfin")
async def jellyfin_rescan_all(message):
url = "http://jellyfin:8096/Library/Refresh"
headers = {"X-Emby-Token": "d2ca7e1161d04b1aab5118d1f5982962"}
params = {"ReplaceAllMetadata": "true", "ScanLibrary": "true"}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, params=params) as response:
if response.content_length:
content = await response.read()
await bot.send_message(message.chat.id, content.decode())
else:
await bot.send_message(
message.chat.id,
"Library refresh started. Please wait a few minutes and then restart your client.",
reply_markup=main_markup
)
@bot.message_handler(content_types=['text'])
async def handle_message(message):
if (status_cache[message.chat.id] == States.AWAITING_FOR_TORRENT):
markup = types.ReplyKeyboardMarkup(row_width=3, resize_keyboard=True, one_time_keyboard=True)
for category in qbt_client.torrents_categories().keys():
markup.add(category)
awaiting_torrents[message.chat.id] = message.text.strip()
status_cache[message.chat.id] = States.AWAITING_FOR_CATEGORY
await bot.send_message(
message.chat.id,
"Select torrent category.",
reply_markup=markup
)
elif (status_cache[message.chat.id] == States.AWAITING_FOR_CATEGORY):
category = message.text.strip()
if (category not in qbt_client.torrents_categories().keys()):
markup = types.ReplyKeyboardMarkup(row_width=3, resize_keyboard=True, one_time_keyboard=True)
for category in qbt_client.torrents_categories().keys():
markup.add(category)
await bot.send_message(
message.chat.id,
"Select torrent category.",
reply_markup=markup
)
return
try:
print(awaiting_torrents[message.chat.id])
print(category)
result = qbt_client.torrents_add(urls=awaiting_torrents[message.chat.id], category=category)
if (result == "Ok."):
await bot.send_message(
message.chat.id,
"Torrent added successfully! Track download progress in qBitController",
reply_markup=main_markup
)
else:
await bot.send_message(
message.chat.id,
f"Failed to add torrent. Error: '{result}'",
reply_markup=main_markup
)
except qexceptions.Conflict409Error as err:
await bot.send_message(
message.chat.id,
"Torrent already has been added"
)
print(err)
elif (status_cache[message.chat.id] == States.AWAITING_FOR_TORRENT_NUMBER):
number = 0
try:
number = int(message.text)
except:
await bot.send_message(message.chat.id, "Please, send a number")
torrents = qbt_client.torrents_info(status_filter="completed")
torrents.sort(key=lambda x: x.name.lower())
try:
qbt_client.torrents_delete(delete_files=True, torrent_hashes=torrents[number - 1].hash.lower())
except Exception as err:
print(err)
if qbt_client.torrents_info(torrent_hashes=torrents[number - 1].hash.lower()):
raise Exception(f"Torrent still exists after deletion attempt! Hash: {torrents[number - 1].hash.lower()}")
if (qbt_client.torrents_info(torrent_hashes=torrents[number - 1].hash.lower())):
await bot.send_message(
message.chat.id,
"Failed to delete the torrent!",
reply_markup=main_markup
)
else:
await bot.send_message(
message.chat.id,
"Torrent deleted successfully.",
reply_markup=main_markup
)
status_cache[message.chat.id] == States.NONE
while True:
try:
asyncio.run(bot.infinity_polling())
except Exception as err:
print(err)
continue
break
+5
View File
@@ -0,0 +1,5 @@
telebot
aiohttp
qbittorrent-api
jellyfin-api-client
humanize