commit 8a21cbe18a02e3518f311cd7c0c2bf293fcca7fb Author: vrubel Date: Wed Jan 28 14:45:56 2026 +0300 Fix bot polling, downloads, and file delivery diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f57f00 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Режим работы: true для продакшена, false для теста +IS_PROD=false + +# Токены ботов для продакшена +TG_USER_BOT_TOKEN_PROD=8510243053:AAHOEn_mamofQOZpk4qGWg2VxzuY6JLENwM +TG_ADMIN_BOT_TOKEN_PROD=8553717900:AAEQhtnOV_rzSmkzwu_BUP_CRcfT_WdaHeI + +# Токены ботов для теста +TG_USER_BOT_TOKEN_TEST=8377919544:AAEHfOoH_OWI5_DUAaNQHNbGOAum8Fpyq_s +TG_ADMIN_BOT_TOKEN_TEST=8500693290:AAEyF1VWzBqLUem3V1U3sfpEqjGrA7nM49M + +# ID чата для admin-bot (опционально, если не задан - отправлять всем админам, написавшим /start) +ADMIN_CHAT_ID= + +# Рабочая директория для временных файлов +WORKDIR=/data + +# Уровень логирования +LOG_LEVEL=INFO + +# yt-dlp: cookies и user-agent для обхода 403 +YTDLP_COOKIES_FILE=/data/cookies.txt +YTDLP_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 +YTDLP_PLAYER_CLIENT=android +YTDLP_FORCE_IPV4=true +MAX_PART_MB=40 +AUDIO_BITRATE_KBPS=128 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9d724e --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.env +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage +htmlcov/ +*.log +/data/ +*.mp3 +*.m4a +*.webm +*.ogg +admins.json +statistics.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b73d77 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + ffmpeg \ + nodejs \ + npm \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Установка yt-dlp +RUN pip install --no-cache-dir yt-dlp + +# Установка зависимостей Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование кода приложения +COPY app/ /app/app/ +WORKDIR /app + +# Создание рабочей директории +RUN mkdir -p /data + +# Запуск приложения +CMD ["python", "-m", "app.main"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..523d159 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# YouTube → MP3 Telegram Service + +Сервис для скачивания аудио из YouTube видео через Telegram ботов. + +## Описание + +Сервис состоит из двух Telegram ботов: + +1. **User-bot** — принимает ссылки на YouTube видео и возвращает MP3 файлы +2. **Admin-bot** — получает уведомления о всех скачанных файлах с метаданными + +## Требования + +- Docker и Docker Compose +- Ubuntu 24.04 (или другая Linux система) +- Токены Telegram ботов + +## Установка и запуск + +1. Скопируйте `.env.example` в `.env`: + ```bash + cp .env.example .env + ``` + +2. Отредактируйте `.env` и укажите токены ваших ботов: + ```env + IS_PROD=false + TG_USER_BOT_TOKEN_TEST=your_test_user_bot_token + TG_ADMIN_BOT_TOKEN_TEST=your_test_admin_bot_token + TG_USER_BOT_TOKEN_PROD=your_prod_user_bot_token + TG_ADMIN_BOT_TOKEN_PROD=your_prod_admin_bot_token + ``` + +3. Запустите сервис: + ```bash + docker compose up -d --build + ``` + +4. Просмотр логов: + ```bash + docker compose logs -f + ``` + +5. Остановка сервиса: + ```bash + docker compose down + ``` + +## Использование + +### User-bot + +Отправьте боту ссылку на YouTube видео: +- `https://www.youtube.com/watch?v=...` +- `https://youtu.be/...` + +Бот обработает запрос и отправит вам MP3 файл с названием, идентичным названию видео. + +### Admin-bot + +1. Напишите admin-bot команду `/start` для регистрации в качестве администратора +2. Вы будете получать уведомления о всех скачанных файлах: + - Название файла + - Username пользователя (или user_id) + - Исходная ссылка на видео + - Сам MP3 файл + +## Особенности + +- **Очередь задач**: Все запросы обрабатываются последовательно (FIFO) +- **Безопасные имена файлов**: Автоматическая очистка запрещённых символов +- **Обработка ошибок**: Информативные сообщения об ошибках +- **Логирование**: Подробные логи всех операций +- **Временные файлы**: Автоматическое удаление после отправки + +## Структура проекта + +``` +. +├── app/ +│ ├── __init__.py +│ ├── main.py # Главный файл запуска +│ ├── config.py # Конфигурация +│ ├── user_bot.py # User-bot +│ ├── admin_bot.py # Admin-bot +│ ├── queue_manager.py # Менеджер очереди +│ ├── youtube_downloader.py # Скачивание и конвертация +│ └── admin_manager.py # Управление администраторами +├── docker-compose.yml +├── Dockerfile +├── requirements.txt +├── .env.example +└── README.md +``` + +## Переменные окружения + +- `IS_PROD` — режим работы (`true`/`false`) +- `TG_USER_BOT_TOKEN_PROD` — токен user-bot для продакшена +- `TG_ADMIN_BOT_TOKEN_PROD` — токен admin-bot для продакшена +- `TG_USER_BOT_TOKEN_TEST` — токен user-bot для теста +- `TG_ADMIN_BOT_TOKEN_TEST` — токен admin-bot для теста +- `ADMIN_CHAT_ID` — (опционально) ID чата для отправки уведомлений +- `WORKDIR` — рабочая директория для временных файлов (по умолчанию `/data`) +- `LOG_LEVEL` — уровень логирования (по умолчанию `INFO`) + +## Логи + +Логи доступны через: +```bash +docker compose logs -f +``` + +Или для конкретного сервиса: +```bash +docker compose logs -f youtube-mp3-service +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/admin_bot.py b/app/admin_bot.py new file mode 100644 index 0000000..77cdd6f --- /dev/null +++ b/app/admin_bot.py @@ -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 diff --git a/app/admin_manager.py b/app/admin_manager.py new file mode 100644 index 0000000..64dce86 --- /dev/null +++ b/app/admin_manager.py @@ -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) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..0d7bda8 --- /dev/null +++ b/app/config.py @@ -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 не установлен") diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3892450 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/queue_manager.py b/app/queue_manager.py new file mode 100644 index 0000000..708bd44 --- /dev/null +++ b/app/queue_manager.py @@ -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) # Небольшая задержка перед следующей попыткой diff --git a/app/statistics.py b/app/statistics.py new file mode 100644 index 0000000..8a62192 --- /dev/null +++ b/app/statistics.py @@ -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 diff --git a/app/user_bot.py b/app/user_bot.py new file mode 100644 index 0000000..5b11f85 --- /dev/null +++ b/app/user_bot.py @@ -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}") diff --git a/app/youtube_downloader.py b/app/youtube_downloader.py new file mode 100644 index 0000000..2851b50 --- /dev/null +++ b/app/youtube_downloader.py @@ -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]}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d30080e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + youtube-mp3-service: + build: . + container_name: youtube-mp3-bot + restart: unless-stopped + env_file: + - .env + volumes: + - ./data:/data + environment: + - IS_PROD=${IS_PROD} + - TG_USER_BOT_TOKEN_PROD=${TG_USER_BOT_TOKEN_PROD} + - TG_ADMIN_BOT_TOKEN_PROD=${TG_ADMIN_BOT_TOKEN_PROD} + - TG_USER_BOT_TOKEN_TEST=${TG_USER_BOT_TOKEN_TEST} + - TG_ADMIN_BOT_TOKEN_TEST=${TG_ADMIN_BOT_TOKEN_TEST} + - ADMIN_CHAT_ID=${ADMIN_CHAT_ID} + - WORKDIR=${WORKDIR:-/data} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e4cca4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-telegram-bot==20.7 +yt-dlp>=2025.12.8 diff --git a/youtube_mp3_telegram_service_TZ.md b/youtube_mp3_telegram_service_TZ.md new file mode 100644 index 0000000..8eb359b --- /dev/null +++ b/youtube_mp3_telegram_service_TZ.md @@ -0,0 +1,195 @@ +# ТЗ: Telegram-сервис “YouTube → MP3” + Admin bot (Docker Compose, Ubuntu 24.04, Python) + +## 0. Цель +Нужно реализовать сервис на Python, который состоит из **двух Telegram-ботов**: +1) **User-bot** — принимает от пользователя ссылку на YouTube-видео и возвращает аудио-дорожку в **MP3**. Имя файла должно быть **идентично названию видео на YouTube**. +2) **Admin-bot** — получает **все MP3**, которые были выданы пользователям user-bot, вместе с метаданными: + - название файла (title) + - Telegram username пользователя (кто запросил) + - исходная ссылка на видео + +Сервис разворачивается в Docker через docker-compose на хосте **Ubuntu 24.04**. + +--- + +## 1. Общие требования +- Язык: **Python**. +- Сервис работает в Docker. +- Используется **docker compose** (актуальная версия). +- В репозитории должен быть файл `.env.example`. Реальный `.env` хранится на сервере и находится в `.gitignore`. +- При `isProd=true` используются **продовые** токены ботов, при `isProd=false` — **тестовые**. +- В обоих режимах всегда запускаются **оба бота** (user + admin), меняются только токены. + +--- + +## 2. Переменные окружения +Файл `.env.example` должен содержать: + +- `IS_PROD=false` (строка `true/false`) + +Токены 4 ботов: +- `TG_USER_BOT_TOKEN_PROD=...` +- `TG_ADMIN_BOT_TOKEN_PROD=...` +- `TG_USER_BOT_TOKEN_TEST=...` +- `TG_ADMIN_BOT_TOKEN_TEST=...` + +Дополнительно (можно включить, если нужно для реализации): +- `ADMIN_CHAT_ID=` (если admin-bot должен отправлять файлы в конкретный чат/пользователю; если не задан — отправлять **все админам admin-бота, которые ему написали /start** и тем самым зарегистрировались) +- `WORKDIR=/data` (папка для временных файлов внутри контейнера) +- `LOG_LEVEL=INFO` + +> Важно: логика выбора токенов: +- если `IS_PROD=true` → берём `*_PROD` +- иначе (`false`) → берём `*_TEST` + +--- + +## 3. Docker / Compose требования +### 3.1 Контейнер +В контейнере должны быть установлены: +- `yt-dlp` +- `ffmpeg` +- **опционально** `nodejs` (т.к. YouTube иногда требует JS runtime; установка допускается) + +### 3.2 Временное хранение файлов +- Все промежуточные файлы (скачанное аудио/MP3) должны храниться во **временной папке**, смонтированной как **volume** на хост. +- После успешной отправки MP3 **и пользователю, и в admin-bot** файл должен быть **удалён**. + +--- + +## 4. Очередь и конкурентность +- Все запросы на скачивание должны обрабатываться **строго последовательно** (FIFO очередь). +- В один момент времени выполняется **только 1 активная загрузка/конвертация** (однопоточно, один worker). +- Если несколько пользователей отправляют ссылки одновременно — они “встают в очередь”. +- Пользователь должен получать сообщения о статусе: + - “Принято в очередь, позиция: N” + - “Начинаю обработку” + - “Готово, отправляю файл” + - “Ошибка: …” + +--- + +## 5. Функционал user-bot +### 5.1 Вход +Пользователь отправляет сообщение, содержащее ссылку на YouTube-видео. + +Поддерживаемые ссылки: +- `https://www.youtube.com/...` +- `https://youtu.be/...` (это короткий домен YouTube, его тоже нужно поддержать) + +Если ссылка не похожа на YouTube — отвечаем пользователю: +“Пришли ссылку на YouTube-видео.” + +### 5.2 Получение названия видео +- Бот пытается получить `title` через `yt-dlp` метаданными. +- Если **название получить не удалось** (пустое/ошибка/не распарсилось), включается интерактивный шаг: + 1) Бот пишет: “Не смог определить название. Введи имя файла (без расширения .mp3).” + 2) Бот ждёт ответ пользователя текстом. + 3) Полученное имя используется как название выходного mp3. + +### 5.3 Формирование корректного имени файла +- Название файла должно быть безопасным для файловой системы: + - запрещённые символы заменять на `_` + - ограничить длину (например, до 150 символов) + - итоговый файл: `.mp3` + +### 5.4 Скачивание и конвертация +Команда yt-dlp должна: +- извлечь аудио и сконвертировать в mp3 +- использовать ffmpeg + +Рекомендуемый шаблон: +- `yt-dlp -x --audio-format mp3 --audio-quality 0 -o "<path>/%(title)s.%(ext)s" "<URL>"` +Но с учётом того, что название может быть задано вручную, реализация может: +- скачать во временный файл, +- затем переименовать, +- либо подставить имя в `-o`. + +### 5.5 Выход +- User-bot отправляет пользователю MP3 как документ/аудио файл. +- Имя файла (filename) в Telegram должно быть `<title>.mp3`. + +--- + +## 6. Функционал admin-bot +### 6.1 Что отправлять +На каждый успешный запрос user-bot admin-bot должен получить: +- сообщение (текст) с полями: + - `title: ...` + - `requested_by: @username` (если username отсутствует — использовать user_id) + - `url: ...` +- затем **сам mp3 файл** (один файл = одно событие) + +### 6.2 Куда отправлять +Варианты (выбрать и реализовать): +- Если задан `ADMIN_CHAT_ID` → отправлять туда. +- Иначе: admin-bot должен иметь механизм регистрации администраторов: + - любой, кто написал admin-bot `/start`, добавляется в список получателей + - всем зарегистрированным администраторам отправлять уведомления и mp3 + +Список администраторов хранить в простом виде: +- JSON файл в volume (`/data/admins.json`) **или** +- SQLite в volume + +--- + +## 7. Логирование и наблюдаемость +- Логи писать в stdout/stderr (чтобы смотреть через `docker logs`). +- Логировать: + - входящие запросы (user_id, username, url) + - постановку в очередь (позиция) + - старт/успех/ошибку обработки + - ошибки `yt-dlp` и `ffmpeg` (кратко, но информативно) +- Уровень логов через `LOG_LEVEL`. + +--- + +## 8. Обработка ошибок +Нужно аккуратно обрабатывать: +- yt-dlp не смог скачать (403/geo/timeout и т.п.) +- ffmpeg упал +- Telegram API ошибка отправки файла +- пользователь прислал мусор вместо ссылки +- пользователь не ответил на запрос имени файла (можно таймаут, например 5 минут → отмена) + +Поведение при ошибке: +- пользователю отправить сообщение с ошибкой (без гигантского трейсбека, но с причиной) +- задачу удалить из очереди и перейти к следующей +- временные файлы удалить + +--- + +## 9. Структура проекта (ожидание) +В репозитории должны быть: +- `docker-compose.yml` +- `Dockerfile` +- `.env.example` +- `.gitignore` (включает `.env` и временные файлы) +- `app/` (код) +- `README.md` с командами запуска + +--- + +## 10. Команды запуска (описать в README) +- `docker compose up -d --build` +- просмотр логов: `docker compose logs -f` + +--- + +## 11. Ограничения (сознательно НЕ делаем сейчас) +Пока **не вводим**: +- лимиты по длительности видео +- лимиты по размеру файла +- поддержку плейлистов +- сложные ACL/авторизации, кроме механизма получателей admin-bot (см. 6.2) + +--- + +## 12. Критерии приёмки +1) Пользователь отправляет YouTube-ссылку → получает mp3 с названием как у видео. +2) Одновременно 5 пользователей отправляют ссылки → задачи встают в очередь, скачивание идёт строго по одной. +3) На каждый успешно выданный mp3 admin-bot получает: + - текст с title/username/url + - затем mp3 файл +4) После отправки пользователю и в admin-bot временный файл удаляется. +5) Переключение `IS_PROD` меняет только токены, оба бота всегда работают.