Initial commit
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -2,4 +2,6 @@ telebot
|
||||
aiohttp
|
||||
qbittorrent-api
|
||||
jellyfin-api-client
|
||||
humanize
|
||||
humanize
|
||||
python-dotenv
|
||||
pysocks
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user