Fix bot polling, downloads, and file delivery

This commit is contained in:
vrubel 2026-01-28 14:45:56 +03:00
commit 8a21cbe18a
16 changed files with 1712 additions and 0 deletions

1
app/__init__.py Normal file
View file

@ -0,0 +1 @@

130
app/admin_bot.py Normal file
View file

@ -0,0 +1,130 @@
"""Admin-bot для получения уведомлений о скачанных файлах."""
import logging
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
ContextTypes,
)
from telegram.request import HTTPXRequest
from app.config import Config
from app.admin_manager import AdminManager
from app.statistics import Statistics
logger = logging.getLogger(__name__)
def get_user_language(update: Update) -> str:
"""Определить язык пользователя для локализации."""
user = update.effective_user
lang_code = user.language_code or 'en'
# Если язык русский или начинается с ru, возвращаем 'ru', иначе 'en'
return 'ru' if lang_code.startswith('ru') else 'en'
def get_start_message(language: str, first_name: str) -> str:
"""Получить приветственное сообщение для admin-bot в зависимости от языка."""
if language == 'ru':
return (
f"Привет, {first_name}! 👋\n\n"
"Это административный бот для мониторинга сервиса YouTube → MP3.\n\n"
"Ты зарегистрирован как администратор. "
"Ты будешь получать уведомления о всех скачанных файлах с метаданными:\n"
"• Название файла\n"
"• Пользователь, который запросил\n"
"• Исходная ссылка на видео\n"
"• Сам MP3 файл\n\n"
"Используй /stat для просмотра статистики."
)
else:
return (
f"Hello, {first_name}! 👋\n\n"
"This is an admin bot for monitoring the YouTube → MP3 service.\n\n"
"You are registered as an administrator. "
"You will receive notifications about all downloaded files with metadata:\n"
"• File name\n"
"• User who requested\n"
"• Original video link\n"
"• The MP3 file itself\n\n"
"Use /stat to view statistics."
)
def get_stat_message(language: str, user_count: int, processed_urls: int) -> str:
"""Получить сообщение со статистикой в зависимости от языка."""
if language == 'ru':
return (
"📊 Статистика сервиса:\n\n"
f"👥 Уникальных пользователей: {user_count}\n"
f"🔗 Обработано ссылок: {processed_urls}"
)
else:
return (
"📊 Service Statistics:\n\n"
f"👥 Unique users: {user_count}\n"
f"🔗 Processed links: {processed_urls}"
)
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик команды /start для регистрации администратора."""
user = update.effective_user
admin_manager: AdminManager = context.bot_data.get('admin_manager')
language = get_user_language(update)
if admin_manager:
admin_manager.add_admin(user.id)
message = get_start_message(language, user.first_name or "Admin")
await update.message.reply_text(message)
logger.info(f"Admin registered: {user.id} (@{user.username})")
else:
error_msg = "Ошибка: менеджер администраторов не инициализирован." if language == 'ru' else "Error: admin manager not initialized."
await update.message.reply_text(error_msg)
async def stat_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик команды /stat для вывода статистики."""
statistics: Statistics = context.bot_data.get('statistics')
language = get_user_language(update)
if not statistics:
error_msg = "Ошибка: статистика не инициализирована." if language == 'ru' else "Error: statistics not initialized."
await update.message.reply_text(error_msg)
return
user_count = statistics.get_user_count()
processed_urls = statistics.get_processed_urls_count()
message = get_stat_message(language, user_count, processed_urls)
await update.message.reply_text(message)
logger.info(f"Statistics requested by admin {update.effective_user.id}")
def create_admin_bot_application(config: Config, admin_manager: AdminManager, statistics: Statistics) -> Application:
"""Создать и настроить приложение admin-bot."""
request = HTTPXRequest(
connect_timeout=config.tg_connect_timeout,
read_timeout=config.tg_read_timeout,
write_timeout=config.tg_write_timeout,
pool_timeout=config.tg_pool_timeout,
)
app = Application.builder().token(config.admin_bot_token).request(request).build()
# Сохраняем в bot_data
app.bot_data['config'] = config
app.bot_data['admin_manager'] = admin_manager
app.bot_data['statistics'] = statistics
# Обработчик команды /start
app.add_handler(CommandHandler("start", start_command))
# Обработчик команды /stat
app.add_handler(CommandHandler("stat", stat_command))
# Обработчик всех остальных сообщений (просто игнорируем)
app.add_handler(MessageHandler(filters.ALL, lambda u, c: None))
return app

61
app/admin_manager.py Normal file
View file

@ -0,0 +1,61 @@
"""Менеджер для работы с администраторами admin-bot."""
import json
import logging
from pathlib import Path
from typing import List, Set
logger = logging.getLogger(__name__)
class AdminManager:
"""Управление списком администраторов."""
def __init__(self, admins_file: Path):
"""
Args:
admins_file: Путь к JSON файлу со списком администраторов
"""
self.admins_file = admins_file
self.admins_file.parent.mkdir(parents=True, exist_ok=True)
self._admins: Set[int] = set()
self._load()
def _load(self):
"""Загрузить список администраторов из файла."""
try:
if self.admins_file.exists():
with open(self.admins_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._admins = set(data.get('admins', []))
logger.info(f"Loaded {len(self._admins)} admins from {self.admins_file}")
else:
logger.info("Admins file not found, starting with empty list")
except Exception as e:
logger.error(f"Error loading admins: {e}", exc_info=True)
self._admins = set()
def _save(self):
"""Сохранить список администраторов в файл."""
try:
with open(self.admins_file, 'w', encoding='utf-8') as f:
json.dump({'admins': list(self._admins)}, f, indent=2)
logger.debug(f"Saved {len(self._admins)} admins to {self.admins_file}")
except Exception as e:
logger.error(f"Error saving admins: {e}", exc_info=True)
def add_admin(self, user_id: int):
"""Добавить администратора."""
if user_id not in self._admins:
self._admins.add(user_id)
self._save()
logger.info(f"Added admin: {user_id}")
else:
logger.debug(f"Admin {user_id} already exists")
def is_admin(self, user_id: int) -> bool:
"""Проверить, является ли пользователь администратором."""
return user_id in self._admins
def get_all_admins(self) -> List[int]:
"""Получить список всех администраторов."""
return list(self._admins)

47
app/config.py Normal file
View file

@ -0,0 +1,47 @@
"""Конфигурация приложения из переменных окружения."""
import os
from pathlib import Path
class Config:
"""Класс для хранения конфигурации."""
def __init__(self):
self.is_prod = os.getenv("IS_PROD", "false").lower() == "true"
# Выбор токенов в зависимости от режима
if self.is_prod:
self.user_bot_token = os.getenv("TG_USER_BOT_TOKEN_PROD")
self.admin_bot_token = os.getenv("TG_ADMIN_BOT_TOKEN_PROD")
else:
self.user_bot_token = os.getenv("TG_USER_BOT_TOKEN_TEST")
self.admin_bot_token = os.getenv("TG_ADMIN_BOT_TOKEN_TEST")
self.admin_chat_id = os.getenv("ADMIN_CHAT_ID")
self.workdir = Path(os.getenv("WORKDIR", "/data"))
self.log_level = os.getenv("LOG_LEVEL", "INFO")
# Таймауты для Telegram API (секунды)
self.tg_connect_timeout = float(os.getenv("TG_CONNECT_TIMEOUT", "20"))
self.tg_read_timeout = float(os.getenv("TG_READ_TIMEOUT", "120"))
self.tg_write_timeout = float(os.getenv("TG_WRITE_TIMEOUT", "120"))
self.tg_pool_timeout = float(os.getenv("TG_POOL_TIMEOUT", "20"))
# Параметры для yt-dlp (обход 403 при необходимости)
self.ytdlp_cookies_file = os.getenv("YTDLP_COOKIES_FILE")
self.ytdlp_user_agent = os.getenv("YTDLP_USER_AGENT")
self.ytdlp_player_client = os.getenv("YTDLP_PLAYER_CLIENT", "android")
self.ytdlp_force_ipv4 = os.getenv("YTDLP_FORCE_IPV4", "true").lower() == "true"
# Параметры отправки файлов в Telegram
self.max_part_mb = int(os.getenv("MAX_PART_MB", "40"))
self.audio_bitrate_kbps = int(os.getenv("AUDIO_BITRATE_KBPS", "128"))
# Создаём рабочую директорию если её нет
self.workdir.mkdir(parents=True, exist_ok=True)
# Проверка обязательных переменных
if not self.user_bot_token:
raise ValueError("TG_USER_BOT_TOKEN не установлен")
if not self.admin_bot_token:
raise ValueError("TG_ADMIN_BOT_TOKEN не установлен")

127
app/main.py Normal file
View file

@ -0,0 +1,127 @@
"""Главный файл для запуска обоих ботов."""
import asyncio
import logging
import sys
from pathlib import Path
from app.config import Config
from app.queue_manager import QueueManager
from app.admin_manager import AdminManager
from app.statistics import Statistics
from app.user_bot import create_user_bot_application, worker_function
from app.admin_bot import create_admin_bot_application
def setup_logging(log_level: str):
"""Настройка логирования."""
level = getattr(logging, log_level.upper(), logging.INFO)
logging.basicConfig(
level=level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
async def main():
"""Главная функция запуска сервиса."""
# Загружаем конфигурацию
try:
config = Config()
except Exception as e:
print(f"Ошибка загрузки конфигурации: {e}")
sys.exit(1)
# Настраиваем логирование
setup_logging(config.log_level)
logger = logging.getLogger(__name__)
logger.info(f"Starting service in {'PROD' if config.is_prod else 'TEST'} mode")
# Создаём менеджер администраторов
admins_file = config.workdir / "admins.json"
admin_manager = AdminManager(admins_file)
# Создаём статистику
stats_file = config.workdir / "statistics.json"
statistics = Statistics(stats_file)
# Создаём admin-bot приложение
admin_bot_app = create_admin_bot_application(config, admin_manager, statistics)
user_bot_app = None
# Создаём функцию-воркер с замыканием на config, admin_manager и statistics
async def worker(task):
return await worker_function(task, config, admin_manager, admin_bot_app, statistics, user_bot_app)
# Создаём менеджер очереди
queue_manager = QueueManager(worker)
# Создаём user-bot приложение
user_bot_app = create_user_bot_application(
config,
queue_manager,
admin_manager,
admin_bot_app,
statistics
)
# Сохраняем ссылку на user_bot_app в admin_bot_app для доступа из worker
admin_bot_app.bot_data['user_bot_app'] = user_bot_app
# Запускаем очередь
await queue_manager.start()
# Инициализируем ботов
await admin_bot_app.initialize()
await user_bot_app.initialize()
# Запускаем ботов
await admin_bot_app.start()
await user_bot_app.start()
logger.info("Both bots started successfully")
try:
# Запускаем polling (start_polling возвращает очередь и не блокирует)
await admin_bot_app.updater.start_polling(drop_pending_updates=True)
await user_bot_app.updater.start_polling(drop_pending_updates=True)
logger.info("Polling started for both bots")
# Держим процесс живым, пока не прилетит остановка/сигнал
await asyncio.Event().wait()
except KeyboardInterrupt:
logger.info("Received shutdown signal")
finally:
# Останавливаем ботов
try:
await user_bot_app.updater.stop()
await admin_bot_app.updater.stop()
except Exception as e:
logger.error(f"Error stopping updaters: {e}")
try:
await user_bot_app.stop()
await admin_bot_app.stop()
except Exception as e:
logger.error(f"Error stopping apps: {e}")
# Останавливаем очередь
await queue_manager.stop()
logger.info("Service stopped")
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nShutting down...")
except Exception as e:
print(f"Fatal error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

127
app/queue_manager.py Normal file
View file

@ -0,0 +1,127 @@
"""Менеджер очереди задач для последовательной обработки."""
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Optional, Callable, Awaitable
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class Task:
"""Задача на скачивание и конвертацию."""
task_id: int
user_id: int
username: Optional[str]
url: str
chat_id: int
message_id: int
created_at: datetime
callback: Optional[Callable[[str], Awaitable[None]]] = None # callback для отправки статуса
status_message_ids: list[int] = field(default_factory=list)
class QueueManager:
"""Менеджер очереди для последовательной обработки задач."""
def __init__(self, worker: Callable[[Task], Awaitable[str]]):
"""
Args:
worker: Асинхронная функция для обработки задачи, возвращает путь к файлу
"""
self.queue: asyncio.Queue = asyncio.Queue()
self.worker = worker
self.task_counter = 0
self._worker_task: Optional[asyncio.Task] = None
self._lock = asyncio.Lock()
async def start(self):
"""Запуск воркера для обработки очереди."""
if self._worker_task is None or self._worker_task.done():
self._worker_task = asyncio.create_task(self._process_queue())
logger.info("Queue manager started")
async def stop(self):
"""Остановка воркера."""
if self._worker_task and not self._worker_task.done():
self._worker_task.cancel()
try:
await self._worker_task
except asyncio.CancelledError:
pass
logger.info("Queue manager stopped")
async def add_task(
self,
user_id: int,
username: Optional[str],
url: str,
chat_id: int,
message_id: int,
callback: Optional[Callable[[str], Awaitable[None]]] = None,
status_message_ids: Optional[list[int]] = None
) -> int:
"""Добавить задачу в очередь."""
async with self._lock:
self.task_counter += 1
task_id = self.task_counter
task = Task(
task_id=task_id,
user_id=user_id,
username=username,
url=url,
chat_id=chat_id,
message_id=message_id,
created_at=datetime.now(),
callback=callback,
status_message_ids=status_message_ids if status_message_ids is not None else []
)
await self.queue.put(task)
position = self.queue.qsize()
logger.info(f"Task {task_id} added to queue. Position: {position}, User: {user_id} (@{username}), URL: {url}")
if callback:
await callback(f"Принято в очередь, позиция: {position}")
return task_id
async def _process_queue(self):
"""Обработка очереди задач (FIFO, последовательно)."""
logger.info("Queue processor started")
while True:
try:
# Получаем задачу из очереди (блокирующая операция)
task = await self.queue.get()
logger.info(f"Processing task {task.task_id} for user {task.user_id}")
if task.callback:
await task.callback("Начинаю обработку")
try:
# Обрабатываем задачу
result = await self.worker(task)
logger.info(f"Task {task.task_id} completed successfully")
except Exception as e:
error_msg = f"Ошибка при обработке: {str(e)}"
logger.error(f"Task {task.task_id} failed: {e}", exc_info=True)
if task.callback:
await task.callback(error_msg)
finally:
self.queue.task_done()
except asyncio.CancelledError:
logger.info("Queue processor cancelled")
break
except Exception as e:
logger.error(f"Error in queue processor: {e}", exc_info=True)
await asyncio.sleep(1) # Небольшая задержка перед следующей попыткой

71
app/statistics.py Normal file
View file

@ -0,0 +1,71 @@
"""Модуль для хранения и управления статистикой."""
import json
import logging
from pathlib import Path
from typing import Set
logger = logging.getLogger(__name__)
class Statistics:
"""Класс для управления статистикой сервиса."""
def __init__(self, stats_file: Path):
"""
Args:
stats_file: Путь к JSON файлу со статистикой
"""
self.stats_file = stats_file
self.stats_file.parent.mkdir(parents=True, exist_ok=True)
self._users: Set[int] = set()
self._processed_urls: int = 0
self._load()
def _load(self):
"""Загрузить статистику из файла."""
try:
if self.stats_file.exists():
with open(self.stats_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._users = set(data.get('users', []))
self._processed_urls = data.get('processed_urls', 0)
logger.info(f"Loaded statistics: {len(self._users)} users, {self._processed_urls} processed URLs")
else:
logger.info("Statistics file not found, starting with empty stats")
except Exception as e:
logger.error(f"Error loading statistics: {e}", exc_info=True)
self._users = set()
self._processed_urls = 0
def _save(self):
"""Сохранить статистику в файл."""
try:
with open(self.stats_file, 'w', encoding='utf-8') as f:
json.dump({
'users': list(self._users),
'processed_urls': self._processed_urls
}, f, indent=2)
logger.debug(f"Saved statistics: {len(self._users)} users, {self._processed_urls} processed URLs")
except Exception as e:
logger.error(f"Error saving statistics: {e}", exc_info=True)
def add_user(self, user_id: int):
"""Добавить пользователя в статистику."""
if user_id not in self._users:
self._users.add(user_id)
self._save()
logger.debug(f"Added user to statistics: {user_id}")
def increment_processed_urls(self):
"""Увеличить счётчик обработанных ссылок."""
self._processed_urls += 1
self._save()
logger.debug(f"Incremented processed URLs counter: {self._processed_urls}")
def get_user_count(self) -> int:
"""Получить количество уникальных пользователей."""
return len(self._users)
def get_processed_urls_count(self) -> int:
"""Получить количество обработанных ссылок."""
return self._processed_urls

481
app/user_bot.py Normal file
View file

@ -0,0 +1,481 @@
"""User-bot для обработки запросов пользователей."""
import asyncio
import logging
from pathlib import Path
from typing import Optional
from telegram import Update
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
ContextTypes,
ConversationHandler,
)
from telegram.request import HTTPXRequest
from app.config import Config
from app.queue_manager import QueueManager, Task
from app.youtube_downloader import is_youtube_url, get_video_title, download_and_convert, sanitize_filename
from app.admin_manager import AdminManager
from app.statistics import Statistics
logger = logging.getLogger(__name__)
# Состояния для ConversationHandler (ожидание имени файла)
WAITING_FOR_FILENAME = 1
# Хранилище ожидающих ответа пользователей: user_id -> (url, output_path, event, result_container)
pending_filename_requests = {}
async def send_status_message(context: ContextTypes.DEFAULT_TYPE, chat_id: int, text: str) -> Optional[int]:
"""Отправить сообщение о статусе и вернуть message_id."""
try:
message = await context.bot.send_message(chat_id=chat_id, text=text)
return message.message_id
except Exception as e:
logger.error(f"Error sending status message: {e}")
return None
async def process_task(task: Task, config: Config, admin_manager: AdminManager, admin_bot_app: Application) -> str:
"""
Обработать задачу: скачать видео и сконвертировать в MP3.
Returns:
Путь к созданному MP3 файлу
"""
# Получаем название видео
title = await get_video_title(task.url, config=config)
# Если название не получено, запрашиваем у пользователя
if not title:
# Сохраняем информацию о запросе
output_path = config.workdir / f"task_{task.task_id}"
pending_filename_requests[task.user_id] = (task.url, output_path)
# Отправляем запрос пользователю
status_callback = task.callback
if status_callback:
await status_callback("Не смог определить название. Введи имя файла (без расширения .mp3).")
# Ждём ответа пользователя (таймаут 5 минут)
try:
custom_title = await asyncio.wait_for(
_wait_for_filename(task.user_id),
timeout=300.0 # 5 минут
)
title = custom_title
except asyncio.TimeoutError:
raise Exception("Таймаут ожидания имени файла (5 минут)")
finally:
# Удаляем из ожидающих
pending_filename_requests.pop(task.user_id, None)
# Формируем безопасное имя файла
safe_title = sanitize_filename(title)
output_path = config.workdir / f"task_{task.task_id}_{safe_title}"
# Скачиваем и конвертируем
mp3_path = await download_and_convert(task.url, output_path, custom_title=safe_title, config=config)
return str(mp3_path)
async def _wait_for_filename(user_id: int) -> str:
"""
Ожидание ответа пользователя с именем файла.
Используется asyncio.Event для синхронизации.
"""
event = asyncio.Event()
result_container = {'value': None}
# Сохраняем event в глобальном словаре для доступа из handler
if user_id not in pending_filename_requests:
raise Exception("Request not found")
# Получаем существующую запись и добавляем event
url, output_path = pending_filename_requests[user_id]
pending_filename_requests[user_id] = (url, output_path, event, result_container)
# Ждём события
await event.wait()
if result_container['value'] is None:
raise Exception("No filename received")
return result_container['value']
async def handle_filename_response(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка ответа пользователя с именем файла."""
user_id = update.effective_user.id
text = update.message.text.strip()
language = get_user_language(update)
if user_id in pending_filename_requests:
try:
url, output_path, event, result_container = pending_filename_requests[user_id]
result_container['value'] = text
event.set()
confirm_msg = f"Использую имя: {text}.mp3" if language == 'ru' else f"Using name: {text}.mp3"
await update.message.reply_text(confirm_msg)
except (ValueError, KeyError):
# Если структура не та, что ожидалась
error_msg = "Ошибка обработки имени файла." if language == 'ru' else "Error processing filename."
await update.message.reply_text(error_msg)
return ConversationHandler.END
else:
# Если нет активного запроса, проверяем, не является ли это ссылкой
return await handle_message(update, context)
def get_user_language(update: Update) -> str:
"""Определить язык пользователя для локализации."""
user = update.effective_user
lang_code = user.language_code or 'en'
# Если язык русский или начинается с ru, возвращаем 'ru', иначе 'en'
return 'ru' if lang_code.startswith('ru') else 'en'
def get_start_message(language: str) -> str:
"""Получить приветственное сообщение в зависимости от языка."""
if language == 'ru':
return (
"Привет! 👋\n\n"
"Я бот для скачивания аудио из YouTube видео.\n\n"
"Просто отправь мне ссылку на YouTube видео, и я верну тебе MP3 файл с аудиодорожкой.\n\n"
"Поддерживаются ссылки:\n"
"• https://www.youtube.com/...\n"
"• https://youtu.be/..."
)
else:
return (
"Hello! 👋\n\n"
"I'm a bot for downloading audio from YouTube videos.\n\n"
"Just send me a link to a YouTube video, and I'll return an MP3 file with the audio track.\n\n"
"Supported links:\n"
"• https://www.youtube.com/...\n"
"• https://youtu.be/..."
)
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик команды /start для user-bot."""
user = update.effective_user
language = get_user_language(update)
message = get_start_message(language)
# Добавляем пользователя в статистику
statistics = context.bot_data.get('statistics')
if statistics:
statistics.add_user(user.id)
await update.message.reply_text(message)
logger.info(f"User {user.id} (@{user.username}) started the bot (language: {language})")
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработка сообщения от пользователя."""
user = update.effective_user
message = update.message
# Если пользователь ожидает ввода имени файла, не обрабатываем как ссылку
if user.id in pending_filename_requests:
# Это обработается в handle_filename_response
return
text = message.text.strip()
language = get_user_language(update)
# Проверяем, является ли это ссылкой на YouTube
if not is_youtube_url(text):
error_msg = "Пришли ссылку на YouTube-видео." if language == 'ru' else "Please send a YouTube video link."
await message.reply_text(error_msg)
return
# Получаем queue_manager из context
queue_manager: QueueManager = context.bot_data.get('queue_manager')
config: Config = context.bot_data.get('config')
admin_manager: AdminManager = context.bot_data.get('admin_manager')
admin_bot_app: Application = context.bot_data.get('admin_bot_app')
if not queue_manager:
error_msg = "Ошибка: очередь не инициализирована." if language == 'ru' else "Error: queue not initialized."
await message.reply_text(error_msg)
return
# Добавляем пользователя в статистику
statistics = context.bot_data.get('statistics')
if statistics:
statistics.add_user(user.id)
status_message_ids: list[int] = []
# Callback для отправки статуса
async def status_callback(status_text: str):
msg_id = await send_status_message(context, message.chat_id, status_text)
if msg_id is not None:
status_message_ids.append(msg_id)
# Добавляем задачу в очередь
await queue_manager.add_task(
user_id=user.id,
username=user.username,
url=text,
chat_id=message.chat_id,
message_id=message.message_id,
callback=status_callback,
status_message_ids=status_message_ids
)
async def _split_audio_to_parts(file_path: Path, max_part_mb: int, bitrate_kbps: int) -> list[Path]:
"""Разбить аудио на части (перекодирование в CBR для стабильного размера)."""
parts_dir = file_path.parent / f"parts_{file_path.stem}"
parts_dir.mkdir(parents=True, exist_ok=True)
part_pattern = parts_dir / f"{file_path.stem}_part%03d.mp3"
max_bytes = max_part_mb * 1024 * 1024
segment_time = max(60, int((max_bytes * 8) / (bitrate_kbps * 1000))) # минимум 60 секунд
cmd = [
'ffmpeg',
'-i', str(file_path),
'-b:a', f'{bitrate_kbps}k',
'-f', 'segment',
'-segment_time', str(segment_time),
'-reset_timestamps', '1',
'-y',
str(part_pattern),
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error = stderr.decode('utf-8', errors='ignore')
raise Exception(f"Ошибка разбиения на части: {error[:200]}")
prefix = f"{file_path.stem}_part"
parts = sorted(
p for p in parts_dir.iterdir()
if p.is_file() and p.name.startswith(prefix) and p.suffix == ".mp3"
)
logger.info(f"Split into {len(parts)} parts in {parts_dir}")
return parts
async def send_file_to_user(bot, chat_id: int, file_path: str, filename: str, config: Config):
"""Отправить MP3 файл пользователю. Если файл большой — отправить частями."""
try:
path = Path(file_path)
max_bytes = config.max_part_mb * 1024 * 1024
if path.stat().st_size > max_bytes:
parts = await _split_audio_to_parts(path, config.max_part_mb, config.audio_bitrate_kbps)
total = len(parts)
if total == 0:
raise Exception("Не удалось разбить файл на части")
for idx, part in enumerate(parts, start=1):
part_name = f"{idx:02d}_{path.stem}.mp3"
with open(part, 'rb') as f:
await bot.send_document(
chat_id=chat_id,
document=f,
filename=part_name
)
logger.info(f"Sent part {idx}/{total} to user {chat_id}")
# Очистка частей
for part in parts:
part.unlink(missing_ok=True)
parts_dir = parts[0].parent if parts else None
if parts_dir:
try:
parts_dir.rmdir()
except OSError:
logger.warning(f"Parts dir not empty: {parts_dir}")
else:
with open(path, 'rb') as f:
await bot.send_document(
chat_id=chat_id,
document=f,
filename=filename
)
logger.info(f"Sent file {filename} to user {chat_id}")
except Exception as e:
logger.error(f"Error sending file to user: {e}", exc_info=True)
raise
async def send_to_admin_bot(
admin_bot_app: Application,
admin_manager: AdminManager,
config: Config,
title: str,
username: Optional[str],
user_id: int,
url: str,
file_path: str
):
"""Отправить уведомление и файл в admin-bot."""
try:
# Формируем сообщение
requested_by = f"@{username}" if username else f"user_id: {user_id}"
message_text = f"title: {title}\nrequested_by: {requested_by}\nurl: {url}"
# Определяем получателей
if config.admin_chat_id:
recipients = [int(config.admin_chat_id)]
else:
recipients = admin_manager.get_all_admins()
if not recipients:
logger.warning("No admin recipients found")
return
# Отправляем всем получателям
filename = Path(file_path).name
for chat_id in recipients:
try:
# Отправляем текст
await admin_bot_app.bot.send_message(
chat_id=chat_id,
text=message_text
)
# Отправляем файл (если большой — частями)
path = Path(file_path)
max_bytes = config.max_part_mb * 1024 * 1024
if path.stat().st_size > max_bytes:
parts = await _split_audio_to_parts(path, config.max_part_mb, config.audio_bitrate_kbps)
total = len(parts)
for idx, part in enumerate(parts, start=1):
part_name = f"{idx:02d}_{path.stem}.mp3"
with open(part, 'rb') as f:
await admin_bot_app.bot.send_document(
chat_id=chat_id,
document=f,
filename=part_name
)
for part in parts:
part.unlink(missing_ok=True)
parts_dir = parts[0].parent if parts else None
if parts_dir:
parts_dir.rmdir()
else:
with open(file_path, 'rb') as f:
await admin_bot_app.bot.send_document(
chat_id=chat_id,
document=f,
filename=filename
)
logger.info(f"Sent notification and file to admin {chat_id}")
except Exception as e:
logger.error(f"Error sending to admin {chat_id}: {e}")
except Exception as e:
logger.error(f"Error in send_to_admin_bot: {e}", exc_info=True)
def create_user_bot_application(
config: Config,
queue_manager: QueueManager,
admin_manager: AdminManager,
admin_bot_app: Application,
statistics: Statistics
) -> Application:
"""Создать и настроить приложение user-bot."""
# Создаём приложение с увеличенными таймаутами для отправки файлов
request = HTTPXRequest(
connect_timeout=config.tg_connect_timeout,
read_timeout=config.tg_read_timeout,
write_timeout=config.tg_write_timeout,
pool_timeout=config.tg_pool_timeout,
)
app = Application.builder().token(config.user_bot_token).request(request).build()
# Сохраняем в bot_data для доступа из handlers
app.bot_data['config'] = config
app.bot_data['queue_manager'] = queue_manager
app.bot_data['admin_manager'] = admin_manager
app.bot_data['admin_bot_app'] = admin_bot_app
app.bot_data['statistics'] = statistics
# Обработчик команды /start
app.add_handler(CommandHandler("start", start_command))
# Обработчик сообщений (сначала проверяем на ожидание имени файла, потом на ссылку)
message_handler = MessageHandler(
filters.TEXT & ~filters.COMMAND,
handle_filename_response # Этот handler проверяет оба случая
)
app.add_handler(message_handler)
return app
async def worker_function(task: Task, config: Config, admin_manager: AdminManager, admin_bot_app: Application, statistics: Statistics, user_bot_app: Optional[Application]) -> str:
"""Функция-воркер для обработки задачи."""
mp3_path = None
try:
# Обрабатываем задачу
mp3_path = await process_task(task, config, admin_manager, admin_bot_app)
# Получаем название файла
filename = Path(mp3_path).name
# Отправляем файл пользователю
if user_bot_app:
await send_file_to_user(
user_bot_app.bot,
task.chat_id,
mp3_path,
filename,
config
)
else:
logger.warning("user_bot_app is not available; skipping send to user")
# Отправляем в admin-bot
await send_to_admin_bot(
admin_bot_app,
admin_manager,
config,
filename.replace('.mp3', ''),
task.username,
task.user_id,
task.url,
mp3_path
)
# Увеличиваем счётчик обработанных ссылок
statistics.increment_processed_urls()
# Удаляем статусные сообщения после завершения
if user_bot_app and task.status_message_ids:
for msg_id in task.status_message_ids:
try:
await user_bot_app.bot.delete_message(chat_id=task.chat_id, message_id=msg_id)
except Exception as e:
logger.warning(f"Failed to delete status message {msg_id}: {e}")
return mp3_path
finally:
# Удаляем временный файл
if mp3_path:
try:
Path(mp3_path).unlink(missing_ok=True)
logger.info(f"Deleted temporary file: {mp3_path}")
except Exception as e:
logger.error(f"Error deleting file {mp3_path}: {e}")

253
app/youtube_downloader.py Normal file
View file

@ -0,0 +1,253 @@
"""Модуль для скачивания и конвертации YouTube видео в MP3."""
import asyncio
import logging
import re
import subprocess
from pathlib import Path
from typing import Optional
from app.config import Config
logger = logging.getLogger(__name__)
def sanitize_filename(filename: str, max_length: int = 150) -> str:
"""
Очистка имени файла от запрещённых символов.
Args:
filename: Исходное имя файла
max_length: Максимальная длина имени файла
Returns:
Безопасное имя файла
"""
# Заменяем запрещённые символы на подчёркивание
# Windows: < > : " / \ | ? *
# Linux: /
forbidden_chars = r'[<>:"/\\|?*\x00-\x1f]'
sanitized = re.sub(forbidden_chars, '_', filename)
# Удаляем пробелы в начале и конце
sanitized = sanitized.strip()
# Ограничиваем длину
if len(sanitized) > max_length:
sanitized = sanitized[:max_length]
# Если имя пустое, используем дефолтное
if not sanitized:
sanitized = "audio"
return sanitized
def is_youtube_url(url: str) -> bool:
"""Проверка, является ли ссылка YouTube."""
patterns = [
r'https?://(www\.)?youtube\.com/',
r'https?://youtu\.be/',
]
return any(re.search(pattern, url) for pattern in patterns)
async def get_video_title(url: str, config: Optional[Config] = None) -> Optional[str]:
"""
Получить название видео через yt-dlp.
Args:
url: URL видео на YouTube
Returns:
Название видео или None в случае ошибки
"""
try:
cmd = [
'yt-dlp',
'--no-download',
'--skip-download',
'--get-title',
'--no-warnings',
url
]
if config:
if config.ytdlp_user_agent:
cmd.extend(['--user-agent', config.ytdlp_user_agent])
if config.ytdlp_cookies_file:
cmd.extend(['--cookies', config.ytdlp_cookies_file])
if config.ytdlp_player_client:
cmd.extend(['--extractor-args', f'youtube:player_client={config.ytdlp_player_client}'])
if config.ytdlp_force_ipv4:
cmd.append('--force-ipv4')
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
title = stdout.decode('utf-8', errors='ignore').strip()
if title:
logger.info(f"Got title for {url}: {title[:50]}...")
return title
else:
error = stderr.decode('utf-8', errors='ignore')
logger.warning(f"Failed to get title for {url}: {error}")
return None
except Exception as e:
logger.error(f"Error getting video title: {e}", exc_info=True)
return None
async def download_and_convert(
url: str,
output_path: Path,
custom_title: Optional[str] = None,
config: Optional[Config] = None
) -> Path:
"""
Скачать видео и сконвертировать в MP3.
Args:
url: URL видео на YouTube
output_path: Путь для сохранения файла (без расширения)
custom_title: Кастомное название файла (опционально)
Returns:
Путь к созданному MP3 файлу
Raises:
Exception: При ошибке скачивания или конвертации
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
# Если задано кастомное название, используем его
if custom_title:
final_path = output_path.parent / f"{sanitize_filename(custom_title)}.mp3"
else:
final_path = output_path.with_suffix('.mp3')
# Временный файл для скачивания (с шаблоном для yt-dlp)
temp_template = output_path.parent / f"temp_{output_path.name}.%(ext)s"
try:
cmd = [
'yt-dlp',
'-x', # Извлечь аудио
'-f', 'bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio/best',
'--hls-prefer-ffmpeg',
'--audio-format', 'mp3',
'--audio-quality', '0', # Лучшее качество
'-o', str(temp_template),
'--no-warnings',
'--progress',
'--newline',
url
]
if config:
if config.ytdlp_user_agent:
cmd.extend(['--user-agent', config.ytdlp_user_agent])
if config.ytdlp_cookies_file:
cmd.extend(['--cookies', config.ytdlp_cookies_file])
if config.ytdlp_player_client:
cmd.extend(['--extractor-args', f'youtube:player_client={config.ytdlp_player_client}'])
if config.ytdlp_force_ipv4:
cmd.append('--force-ipv4')
logger.info(f"Downloading {url}")
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
async def _log_stream(stream, level: str):
while True:
line = await stream.readline()
if not line:
break
text = line.decode('utf-8', errors='ignore').strip()
if text:
if level == "info":
logger.info(f"yt-dlp: {text}")
else:
logger.warning(f"yt-dlp: {text}")
stderr_task = asyncio.create_task(_log_stream(process.stderr, "info"))
stdout_task = asyncio.create_task(_log_stream(process.stdout, "info"))
await process.wait()
await stderr_task
await stdout_task
if process.returncode != 0:
logger.error("yt-dlp failed")
raise Exception("Ошибка скачивания: yt-dlp завершился с ошибкой")
# Находим скачанный файл (yt-dlp создаст файл с расширением)
# Ищем файлы, начинающиеся с temp_ и соответствующие нашему шаблону
temp_base = f"temp_{output_path.name}"
# Не используем glob-шаблоны, потому что в названии могут быть спецсимволы вроде [].
temp_files = [
f for f in output_path.parent.iterdir()
if f.is_file()
and f.name.startswith(f"{temp_base}.")
and f.suffix in ['.mp3', '.m4a', '.webm', '.ogg']
]
if not temp_files:
raise Exception("Скачанный файл не найден")
temp_file = temp_files[0]
# Если файл не MP3, конвертируем через ffmpeg
if temp_file.suffix != '.mp3':
logger.info(f"Converting {temp_file.suffix} to MP3")
await _convert_to_mp3(temp_file, final_path)
temp_file.unlink() # Удаляем исходный файл
else:
# Просто переименовываем
if temp_file != final_path:
temp_file.rename(final_path)
logger.info(f"Renamed {temp_file.name} to {final_path.name}")
logger.info(f"Successfully downloaded and converted: {final_path}")
return final_path
except subprocess.CalledProcessError as e:
logger.error(f"Subprocess error: {e}", exc_info=True)
raise Exception(f"Ошибка при скачивании: {str(e)}")
except Exception as e:
logger.error(f"Error in download_and_convert: {e}", exc_info=True)
raise
async def _convert_to_mp3(input_file: Path, output_file: Path):
"""Конвертировать аудио файл в MP3 через ffmpeg."""
cmd = [
'ffmpeg',
'-i', str(input_file),
'-codec:a', 'libmp3lame',
'-qscale:a', '0', # Лучшее качество
'-y', # Перезаписать выходной файл
str(output_file)
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error = stderr.decode('utf-8', errors='ignore')
logger.error(f"ffmpeg conversion failed: {error}")
raise Exception(f"Ошибка конвертации: {error[:200]}")