Fix bot polling, downloads, and file delivery
This commit is contained in:
commit
8a21cbe18a
16 changed files with 1712 additions and 0 deletions
27
.env.example
Normal file
27
.env.example
Normal 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
22
.gitignore
vendored
Normal 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
26
Dockerfile
Normal 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
117
README.md
Normal 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
1
app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
130
app/admin_bot.py
Normal file
130
app/admin_bot.py
Normal 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
61
app/admin_manager.py
Normal 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
47
app/config.py
Normal 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
127
app/main.py
Normal 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
127
app/queue_manager.py
Normal 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
71
app/statistics.py
Normal 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
481
app/user_bot.py
Normal 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
253
app/youtube_downloader.py
Normal 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
25
docker-compose.yml
Normal 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
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
python-telegram-bot==20.7
|
||||
yt-dlp>=2025.12.8
|
||||
195
youtube_mp3_telegram_service_TZ.md
Normal file
195
youtube_mp3_telegram_service_TZ.md
Normal 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` меняет только токены, оба бота всегда работают.
|
||||
Loading…
Add table
Add a link
Reference in a new issue