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

27
.env.example Normal file
View file

@ -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

22
.gitignore vendored Normal file
View file

@ -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

26
Dockerfile Normal file
View file

@ -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"]

117
README.md Normal file
View file

@ -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
```

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]}")

25
docker-compose.yml Normal file
View file

@ -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"

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
python-telegram-bot==20.7
yt-dlp>=2025.12.8

View file

@ -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 символов)
- итоговый файл: `<title>.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` меняет только токены, оба бота всегда работают.