Initial commit

This commit is contained in:
2026-04-18 20:07:52 +04:00
parent 2a41d6488f
commit 62b418eddb
13 changed files with 923 additions and 323 deletions
+14
View File
@@ -0,0 +1,14 @@
# Telegram Configuration
TELEGRAM_BOT_TOKEN=
# qBittorrent Configuration
QBITTORRENT_HOST=
QBITTORRENT_PORT=
QBITTORRENT_USERNAME=
QBITTORRENT_PASSWORD=
# Jellyfin Configuration
JELLYFIN_HOST=
JELLYFIN_PORT=
JELLYFIN_API_KEY=
JELLYFIN_UPLOAD_PATH=
+44
View File
@@ -0,0 +1,44 @@
# Environment variables
.env
# Session files
*.session
*.session-journal
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
+4 -2
View File
@@ -2,9 +2,11 @@ FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
COPY main.py .
RUN apt-get update && apt-get install -y --no-install-recommends iputils-ping && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENTRYPOINT ["python", "main.py"]
+97
View File
@@ -0,0 +1,97 @@
# AI SLOP WARNING
# Server Manager Bot
A Telegram bot for managing qBittorrent torrents and Jellyfin media library.
## Project Structure
```
server-manager/
├── config.py # Configuration and environment loading
├── utils.py # Utility functions
├── handlers.py # Keyboard layouts and state management
├── bot.py # Main bot class with all handlers
├── main.py # Application entry point
├── .env.example # Environment variables template
├── requirements.txt # Python dependencies
├── Dockerfile # Docker container configuration
└── services/
├── __init__.py
├── qbittorrent_service.py # qBittorrent API wrapper
└── jellyfin_service.py # Jellyfin API wrapper
```
## Key Features
### 1. **Modular Architecture**
- Separated concerns into dedicated service modules
- Clean separation between configuration, business logic, and bot handlers
- Easy to extend with new services
### 2. **Configuration Management**
- All credentials and settings moved to environment variables
- Type-safe configuration using dataclasses
- Custom Telegram API server support
### 3. **Error Handling & Logging**
- Proper exception handling throughout
- Structured logging instead of print statements
- Graceful error recovery
### 4. **Code Quality**
- Type hints for better IDE support and catch errors early
- Docstrings for all public functions and classes
- Consistent code style following PEP 8
## Setup
### 1. Install Dependencies
```bash
pip install -r requirements.txt
```
### 2. Configure Environment
Copy `.env.example` to `.env` and update values:
```bash
cp .env.example .env
# Edit .env with your credentials
```
**Important:** Never commit `.env` file to version control!
### 3. Run the Bot
```bash
python main.py
```
## Docker Usage
Build and run with Docker:
```bash
docker build -t server-manager .
docker run --env-file .env server-manager
```
## Features
### qBittorrent Management
- Add torrents via magnet links
- Delete completed torrents
- View active downloads with ETA
- Get transfer statistics
### Jellyfin Integration
- Trigger library refresh
- Upload video files directly to Jellyfin
## Security Notes
- Store credentials in environment variables, never in code
- Use `.gitignore` to exclude `.env` and `*.session` files
- Consider using Telegram's secret chats for sensitive operations
+337
View File
@@ -0,0 +1,337 @@
"""Main bot module."""
import logging
from telebot.async_telebot import AsyncTeleBot
from telebot import types, asyncio_helper
from config import TelegramConfig
from handlers import (
BotState,
create_main_keyboard,
create_qbittorrent_keyboard,
create_jellyfin_keyboard,
create_category_keyboard,
)
from services import QbittorrentService, JellyfinService
from utils import escape_markdown
logger = logging.getLogger(__name__)
class ServerManagerBot:
"""Main bot class that orchestrates all services and handlers."""
def __init__(
self,
telegram_config: TelegramConfig,
qbittorrent_service: QbittorrentService,
jellyfin_service: JellyfinService,
):
"""Initialize the bot."""
self.telegram_config = telegram_config
self.qbittorrent = qbittorrent_service
self.jellyfin = jellyfin_service
if self.telegram_config.proxy_server is not None:
asyncio_helper.proxy = self.telegram_config.proxy_server
self.bot = AsyncTeleBot(
telegram_config.bot_token
)
self.state = BotState()
self._setup_handlers()
def _setup_handlers(self) -> None:
"""Register all bot handlers."""
self.bot.message_handler(commands=["help", "start"])(self.send_welcome)
self.bot.message_handler(commands=["chatid"])(self.handle_chat_id)
self.bot.message_handler(func=lambda message: message.text == "Go back")(self.go_back)
self.bot.message_handler(func=lambda message: message.text == "qBittorrent")(self.handle_qbittorrent)
self.bot.message_handler(func=lambda message: message.text == "Jellyfin")(self.handle_jellyfin)
self.bot.message_handler(func=lambda message: message.text == "Add torrent")(self.handle_add_torrent)
self.bot.message_handler(func=lambda message: message.text == "Delete torrent")(self.handle_delete_torrent_menu)
self.bot.message_handler(func=lambda message: message.text == "Get list of active torrents")(self.handle_active_torrents)
self.bot.message_handler(func=lambda message: message.text == "Get total stats")(self.handle_stats)
self.bot.message_handler(func=lambda message: message.text == "Refresh Jellyfin")(self.handle_jellyfin_refresh)
self.bot.message_handler(func=lambda message: message.text == "Upload video")(self.handle_upload_video)
self.bot.message_handler(content_types=["document"])(self.handle_document)
self.bot.message_handler(content_types=["video"])(self.handle_video)
self.bot.message_handler(content_types=["text"])(self.handle_text_message)
async def send_welcome(self, message: types.Message) -> None:
"""Send welcome message with main menu."""
await self.bot.send_message(
message.chat.id,
"Hello! Choose an option below:",
reply_markup=create_main_keyboard(),
)
async def handle_chat_id(self, message: types.Message) -> None:
"""Handle chat ID request for debugging."""
chat_id = message.chat.id
thread_id = message.message_thread_id
if message.chat.is_forum and thread_id:
logger.info(f"Group: {chat_id} | Topic: {thread_id}")
else:
logger.info(f"Group: {chat_id} | (Not a forum or no topic)")
await self.bot.send_message(
message.chat.id,
"Captured chat_id and thread_id, check developer console",
)
async def go_back(self, message: types.Message) -> None:
"""Return to main menu."""
await self.send_welcome(message)
async def handle_qbittorrent(self, message: types.Message) -> None:
"""Show qBittorrent menu."""
await self.bot.send_message(
message.chat.id,
"Select action.",
reply_markup=create_qbittorrent_keyboard(),
)
async def handle_jellyfin(self, message: types.Message) -> None:
"""Show Jellyfin menu."""
await self.bot.send_message(
message.chat.id,
"Select action.",
reply_markup=create_jellyfin_keyboard(),
)
async def handle_add_torrent(self, message: types.Message) -> None:
"""Start torrent addition flow."""
self.state.set_state(message.chat.id, States.AWAITING_FOR_TORRENT)
await self.bot.reply_to(message, "Please send a magnet link or a torrent file")
async def handle_active_torrents(self, message: types.Message) -> None:
"""Show active downloading torrents."""
torrents = self.qbittorrent.get_downloading_torrents()
if not torrents:
msg = "No torrents are being downloaded at the moment."
else:
msg = "\n".join(
f"- {torrent.name}, ETA: {torrent.eta}s"
for torrent in torrents
)
await self.bot.send_message(
message.chat.id,
msg,
reply_markup=create_main_keyboard(),
)
async def handle_stats(self, message: types.Message) -> None:
"""Send qBittorrent statistics."""
try:
stats = self.qbittorrent.get_stats()
stats_message = (
"📊 <b>qBittorrent Metrics</b>\n\n"
"🔄 <b>Current Session</b>\n"
f"⬇️ Downloaded: {stats['session_download']}\n"
f"⬆️ Uploaded: {stats['session_upload']}\n\n"
"⚡ <b>Live Speeds</b>\n"
f"⏬ Down: {stats['current_download']}\n"
f"⏫ Up: {stats['current_upload']}\n\n"
"🧩 <b>Torrent Summary</b>\n"
f"✅ Total: {stats['total_torrents']}\n"
f"⏬ Active DL: {stats['downloading_count']}\n"
f"⏸️ Paused: {stats['paused_count']}"
)
await self.bot.send_message(
message.chat.id,
stats_message,
parse_mode="HTML",
reply_markup=create_main_keyboard(),
)
except Exception as e:
logger.error(f"Error fetching stats: {e}")
await self.bot.send_message(
message.chat.id,
"❌ <b>Error fetching metrics:</b>",
parse_mode="HTML",
reply_markup=create_main_keyboard(),
)
async def handle_delete_torrent_menu(self, message: types.Message) -> None:
"""Show completed torrents for deletion."""
torrents = self.qbittorrent.get_completed_torrents()
if not torrents:
msg = "No completed torrents available."
else:
msg = "\n".join(
f"**{i}**. {escape_markdown(torrent.name)}"
for i, torrent in enumerate(torrents, start=1)
)
await self.bot.send_message(
message.chat.id,
msg,
parse_mode="MarkdownV2",
)
self.state.set_state(message.chat.id, States.AWAITING_FOR_TORRENT_NUMBER)
async def handle_jellyfin_refresh(self, message: types.Message) -> None:
"""Trigger Jellyfin library refresh."""
try:
result = await self.jellyfin.refresh_library()
if result:
await self.bot.send_message(message.chat.id, result)
else:
await self.bot.send_message(
message.chat.id,
"Library refresh started. Please wait a few minutes and then restart your client.",
reply_markup=create_main_keyboard(),
)
except Exception as e:
logger.error(f"Jellyfin refresh failed: {e}")
await self.bot.send_message(
message.chat.id,
f"Failed to refresh library: {e}",
reply_markup=create_main_keyboard(),
)
async def handle_upload_video(self, message: types.Message) -> None:
"""Start video upload flow."""
self.state.set_state(message.chat.id, States.AWAITING_FOR_VIDEO)
await self.bot.send_message(
message.chat.id,
"Please, send video file to upload to Jellyfin.",
)
async def _download_and_save_video(self, message: types.Message, file_id: str, file_name: str) -> None:
"""Download and save a video file."""
await self.bot.send_message(message.chat.id, "Downloading...")
file_info = await self.bot.get_file(file_id)
downloaded_file = await self.bot.download_file(file_info.file_path)
try:
await self.jellyfin.upload_video(file_id, file_name, lambda fid: self.bot.download_file(file_info.file_path))
await self.bot.send_message(
message.chat.id,
"File downloaded. Triggering Jellyfin library update, please wait a few minutes...",
)
except Exception as e:
logger.error(f"Video upload failed: {e}")
await self.bot.send_message(
message.chat.id,
f"Failed to upload video: {e}",
reply_markup=create_main_keyboard(),
)
finally:
self.state.clear_state(message.chat.id)
async def handle_document(self, message: types.Message) -> None:
"""Handle document (video file) messages."""
if self.state.get_state(message.chat.id) != States.AWAITING_FOR_VIDEO:
return
file_id = message.document.file_id
file_name = message.document.file_name
await self._download_and_save_video(message, file_id, file_name)
async def handle_video(self, message: types.Message) -> None:
"""Handle video messages."""
if self.state.get_state(message.chat.id) != States.AWAITING_FOR_VIDEO:
return
file_id = message.video.file_id
file_name = message.video.file_name
await self._download_and_save_video(message, file_id, file_name)
async def handle_text_message(self, message: types.Message) -> None:
"""Handle text messages based on current state."""
state = self.state.get_state(message.chat.id)
if state == States.AWAITING_FOR_TORRENT:
self.state.set_awaiting_torrent(message.chat.id, message.text.strip())
self.state.set_state(message.chat.id, States.AWAITING_FOR_CATEGORY)
categories = self.qbittorrent.get_categories()
await self.bot.send_message(
message.chat.id,
"Select torrent category.",
reply_markup=create_category_keyboard(categories),
)
elif state == States.AWAITING_FOR_CATEGORY:
category = message.text.strip()
categories = self.qbittorrent.get_categories()
if category not in categories:
await self.bot.send_message(
message.chat.id,
"Select torrent category.",
reply_markup=create_category_keyboard(categories),
)
return
magnet = self.state.get_awaiting_torrent(message.chat.id)
if not magnet:
await self.bot.send_message(
message.chat.id,
"Error: No torrent found. Please start over.",
reply_markup=create_main_keyboard(),
)
return
try:
result = self.qbittorrent.add_torrent(magnet, category)
if result == "Ok.":
await self.bot.send_message(
message.chat.id,
"Torrent added successfully! Track download progress in qBitController",
reply_markup=create_main_keyboard(),
)
else:
await self.bot.send_message(
message.chat.id,
f"Failed to add torrent. Error: '{result}'",
reply_markup=create_main_keyboard(),
)
except Exception as e:
logger.error(f"Failed to add torrent: {e}")
await self.bot.send_message(
message.chat.id,
"Torrent already has been added",
reply_markup=create_main_keyboard(),
)
elif state == States.AWAITING_FOR_TORRENT_NUMBER:
try:
number = int(message.text)
except ValueError:
await self.bot.send_message(message.chat.id, "Please, send a number")
return
torrents = self.qbittorrent.get_completed_torrents()
if number < 1 or number > len(torrents):
await self.bot.send_message(
message.chat.id,
"Invalid number. Please try again.",
reply_markup=create_main_keyboard(),
)
return
torrent_hash = torrents[number - 1].hash.lower()
success = self.qbittorrent.delete_torrent(torrent_hash)
if success:
await self.bot.send_message(
message.chat.id,
"Torrent deleted successfully.",
reply_markup=create_main_keyboard(),
)
else:
await self.bot.send_message(
message.chat.id,
"Failed to delete the torrent!",
reply_markup=create_main_keyboard(),
)
self.state.clear_state(message.chat.id)
async def run(self) -> None:
"""Start the bot."""
await self.bot.infinity_polling()
+71
View File
@@ -0,0 +1,71 @@
"""Configuration module for the server manager bot."""
import os
from dataclasses import dataclass
from enum import Enum
class States(Enum):
"""Bot conversation states."""
AWAITING_FOR_TORRENT = 0
AWAITING_FOR_CATEGORY = 1
AWAITING_FOR_TORRENT_NUMBER = 2
AWAITING_FOR_VIDEO = 3
NONE = 4
@dataclass(frozen=True)
class TelegramConfig:
"""Telegram bot configuration."""
bot_token: str
chat_id: int
message_thread_id: int
proxy_server: str
@dataclass(frozen=True)
class QbittorrentConfig:
"""qBittorrent client configuration."""
host: str
port: int
username: str
password: str
@dataclass(frozen=True)
class JellyfinConfig:
"""Jellyfin server configuration."""
host: str
port: int
api_key: str
upload_path: str
def load_telegram_config() -> TelegramConfig:
"""Load Telegram configuration from environment variables."""
return TelegramConfig(
bot_token=os.getenv("TELEGRAM_BOT_TOKEN", None),
chat_id=int(os.getenv("TELEGRAM_CHAT_ID", None)),
message_thread_id=int(os.getenv("TELEGRAM_MESSAGE_THREAD_ID", None)),
proxy_server=os.getenv("TELEGRAM_PROXY", None)
)
def load_qbittorrent_config() -> QbittorrentConfig:
"""Load qBittorrent configuration from environment variables."""
return QbittorrentConfig(
host=os.getenv("QBITTORRENT_HOST", None),
port=int(os.getenv("QBITTORRENT_PORT", None)),
username=os.getenv("QBITTORRENT_USERNAME", None),
password=os.getenv("QBITTORRENT_PASSWORD", None),
)
def load_jellyfin_config() -> JellyfinConfig:
"""Load Jellyfin configuration from environment variables."""
return JellyfinConfig(
host=os.getenv("JELLYFIN_HOST", None),
port=int(os.getenv("JELLYFIN_PORT", None)),
api_key=os.getenv("JELLYFIN_API_KEY", None),
upload_path=os.getenv("JELLYFIN_UPLOAD_PATH", None),
)
+85
View File
@@ -0,0 +1,85 @@
"""Telegram bot handlers module."""
import logging
from dataclasses import dataclass, field
from telebot.async_telebot import AsyncTeleBot
from telebot import types
from config import States
logger = logging.getLogger(__name__)
@dataclass
class BotState:
"""Bot conversation state manager."""
status_cache: dict[int, States] = field(default_factory=dict)
awaiting_torrents: dict[int, str] = field(default_factory=dict)
def get_state(self, chat_id: int) -> States:
"""Get the current state for a chat."""
return self.status_cache.get(chat_id, States.NONE)
def set_state(self, chat_id: int, state: States) -> None:
"""Set the state for a chat."""
self.status_cache[chat_id] = state
def clear_state(self, chat_id: int) -> None:
"""Clear the state for a chat."""
self.status_cache.pop(chat_id, None)
def set_awaiting_torrent(self, chat_id: int, magnet: str) -> None:
"""Store awaiting torrent magnet link."""
self.awaiting_torrents[chat_id] = magnet
def get_awaiting_torrent(self, chat_id: int) -> str | None:
"""Get awaiting torrent magnet link."""
return self.awaiting_torrents.pop(chat_id, None)
def create_main_keyboard() -> types.ReplyKeyboardMarkup:
"""Create the main menu keyboard."""
markup = types.ReplyKeyboardMarkup(
row_width=2,
resize_keyboard=True,
one_time_keyboard=True,
)
markup.row(types.KeyboardButton("qBittorrent"), types.KeyboardButton("Jellyfin"))
return markup
def create_qbittorrent_keyboard() -> types.ReplyKeyboardMarkup:
"""Create the qBittorrent menu keyboard."""
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"))
return markup
def create_jellyfin_keyboard() -> types.ReplyKeyboardMarkup:
"""Create the Jellyfin menu keyboard."""
markup = types.ReplyKeyboardMarkup(
row_width=2,
resize_keyboard=True,
one_time_keyboard=True,
)
markup.add(types.KeyboardButton("Refresh Jellyfin"))
return markup
def create_category_keyboard(categories: list[str]) -> types.ReplyKeyboardMarkup:
"""Create a keyboard with torrent categories."""
markup = types.ReplyKeyboardMarkup(
row_width=3,
resize_keyboard=True,
one_time_keyboard=True,
)
for category in categories:
markup.add(category)
return markup
+46 -320
View File
@@ -1,337 +1,63 @@
import qbittorrentapi
import qbittorrentapi.exceptions as qexceptions
import subprocess
import humanize
import requests
"""Server Manager Bot - Main entry point."""
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
import logging
import sys
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",
from config import (
load_telegram_config,
load_qbittorrent_config,
load_jellyfin_config,
)
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}")
from services import QbittorrentService, JellyfinService
from bot import ServerManagerBot
bot = AsyncTeleBot('8259146079:AAHpPFoDYK5nw3idJlJvSx6pck8xP5vQG8M')
status_cache = {}
awaiting_torrents = {}
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
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 main() -> None:
"""Main entry point."""
setup_logging()
logger = logging.getLogger(__name__)
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
# Load configuration
telegram_config = load_telegram_config()
qbittorrent_config = load_qbittorrent_config()
jellyfin_config = load_jellyfin_config()
@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
)
# Initialize services
logger.info("Initializing qBittorrent service...")
qbittorrent_service = QbittorrentService(qbittorrent_config)
version_info = qbittorrent_service.get_version_info()
logger.info(f"qBittorrent version: {version_info['version']}")
@bot.message_handler(func=lambda message: message.text == "Go back")
async def go_back(message):
await send_welcome(message)
logger.info("Initializing Jellyfin service...")
jellyfin_service = JellyfinService(jellyfin_config)
@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}"
# Create and run bot
logger.info("Starting bot...")
bot = ServerManagerBot(
telegram_config=telegram_config,
qbittorrent_service=qbittorrent_service,
jellyfin_service=jellyfin_service,
)
await bot.send_message(
message.chat.id,
stats,
parse_mode='HTML',
reply_markup=main_markup
)
asyncio.run(bot.run())
except KeyboardInterrupt:
logger.info("Bot stopped by user")
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)
logger.error(f"Bot failed: {e}", exc_info=True)
sys.exit(1)
@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
if __name__ == "__main__":
main()
+3 -1
View File
@@ -2,4 +2,6 @@ telebot
aiohttp
qbittorrent-api
jellyfin-api-client
humanize
humanize
python-dotenv
pysocks
+9
View File
@@ -0,0 +1,9 @@
"""Services package for the server manager bot."""
from services.qbittorrent_service import QbittorrentService
from services.jellyfin_service import JellyfinService
__all__ = [
"QbittorrentService",
"JellyfinService",
]
+74
View File
@@ -0,0 +1,74 @@
"""Jellyfin service module."""
import logging
import os
from pathlib import Path
import aiohttp
from config import JellyfinConfig
logger = logging.getLogger(__name__)
class JellyfinService:
"""Service for interacting with Jellyfin."""
def __init__(self, config: JellyfinConfig):
"""Initialize Jellyfin service."""
self.config = config
self.base_url = f"http://{config.host}:{config.port}"
self.api_key = config.api_key
self.upload_path = Path(config.upload_path)
async def refresh_library(
self,
replace_all_metadata: bool = True,
scan_library: bool = True,
) -> str | None:
"""Trigger a Jellyfin library refresh."""
url = f"{self.base_url}/Library/Refresh"
headers = {"X-Emby-Token": self.api_key}
params = {
"ReplaceAllMetadata": str(replace_all_metadata).lower(),
"ScanLibrary": str(scan_library).lower(),
}
try:
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()
result = content.decode()
logger.info(f"Library refresh response: {result[:100]}")
return result
else:
logger.info("Library refresh started")
return None
except aiohttp.ClientError as e:
logger.error(f"Failed to refresh Jellyfin library: {e}")
raise
async def upload_video(self, file_id: str, file_name: str, download_func) -> str:
"""Download and save a video file for Jellyfin."""
file_path = self.upload_path / file_name
try:
# Ensure upload directory exists
self.upload_path.mkdir(parents=True, exist_ok=True)
# Download the file
downloaded_file = await download_func(file_id)
# Save to disk
with open(file_path, "wb") as f:
f.write(downloaded_file)
logger.info(f"Video saved: {file_path}")
return str(file_path)
except Exception as e:
logger.error(f"Failed to upload video: {e}")
raise
finally:
# Trigger library refresh after upload
await self.refresh_library()
+106
View File
@@ -0,0 +1,106 @@
"""qBittorrent service module."""
import logging
import humanize
from typing import Any
import qbittorrentapi
import qbittorrentapi.exceptions as qexceptions
from config import QbittorrentConfig
from utils import human_speed
logger = logging.getLogger(__name__)
class QbittorrentService:
"""Service for interacting with qBittorrent."""
def __init__(self, config: QbittorrentConfig):
"""Initialize qBittorrent service."""
self.config = config
self.client = qbittorrentapi.Client(
host=config.host,
port=config.port,
username=config.username,
password=config.password,
)
self._login()
def _login(self) -> None:
"""Authenticate with qBittorrent."""
try:
self.client.auth_log_in()
logger.info(f"qBittorrent connected: {self.client.app.version}")
except qbittorrentapi.LoginFailed as e:
logger.error(f"qBittorrent login failed: {e}")
raise
def get_version_info(self) -> dict[str, Any]:
"""Get qBittorrent version and build info."""
return {
"version": self.client.app.version,
"web_api_version": self.client.app.web_api_version,
"build_info": dict(self.client.app.build_info.items()),
}
def add_torrent(self, url: str, category: str) -> str:
"""Add a torrent with the specified category."""
try:
result = self.client.torrents_add(urls=url, category=category)
logger.info(f"Torrent added: {url[:50]}...")
return result
except qexceptions.Conflict409Error as e:
logger.warning(f"Torrent already exists: {e}")
raise
except Exception as e:
logger.error(f"Failed to add torrent: {e}")
raise
def get_downloading_torrents(self) -> list[Any]:
"""Get list of torrents currently downloading."""
torrents = self.client.torrents_info(status_filter="downloading")
return sorted(torrents, key=lambda x: x.name.lower())
def get_completed_torrents(self) -> list[Any]:
"""Get list of completed torrents."""
torrents = self.client.torrents_info(status_filter="completed")
return sorted(torrents, key=lambda x: x.name.lower())
def delete_torrent(self, torrent_hash: str) -> bool:
"""Delete a torrent by its hash."""
try:
self.client.torrents_delete(delete_files=True, torrent_hashes=torrent_hash)
# Verify deletion
remaining = self.client.torrents_info(torrent_hashes=torrent_hash)
if remaining:
logger.error(f"Torrent {torrent_hash} still exists after deletion")
return False
logger.info(f"Torrent deleted: {torrent_hash}")
return True
except Exception as e:
logger.error(f"Failed to delete torrent: {e}")
return False
def get_stats(self) -> dict[str, Any]:
"""Get qBittorrent statistics."""
transfer = self.client.transfer_info()
torrents = self.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)
return {
"session_download": humanize.naturalsize(transfer.dl_info_data, binary=True),
"session_upload": humanize.naturalsize(transfer.up_info_data, binary=True),
"current_download": human_speed(transfer.dl_info_speed),
"current_upload": human_speed(transfer.up_info_speed),
"total_torrents": len(torrents),
"downloading_count": downloading,
"paused_count": paused,
}
def get_categories(self) -> list[str]:
"""Get list of available torrent categories."""
return list(self.client.torrents_categories().keys())
+33
View File
@@ -0,0 +1,33 @@
"""Utility functions for the server manager bot."""
import logging
import humanize
from telebot.formatting import escape_markdown
logger = logging.getLogger(__name__)
__all__ = ["format_seconds", "human_speed", "escape_markdown"]
def format_seconds(seconds: int) -> str:
"""Format seconds into human-readable duration."""
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: int) -> str:
"""Format bytes per second into human-readable speed."""
if speed_bytes < 1024:
return f"{speed_bytes} B/s"
return humanize.naturalsize(speed_bytes, binary=True, format="%.1f") + "/s"