diff --git a/bot.py b/bot.py index 5d82b85..913095b 100644 --- a/bot.py +++ b/bot.py @@ -9,8 +9,15 @@ from urllib.parse import urlparse from datetime import datetime import httpx -from telegram import Update -from telegram.ext import Application, MessageHandler, filters, ContextTypes, CommandHandler +from telegram import Update, Message +from telegram.ext import Application, MessageHandler, filters, ContextTypes, CommandHandler, Defaults +from telegram.request import HTTPXRequest +from dataclasses import dataclass +from typing import Optional + +# Таймаут для HTTP запросов +# Все таймауты убраны - видео может качаться и отправляться очень долго +HTTP_TIMEOUT = httpx.Timeout(connect=None, read=None, write=None, pool=None) # Настройка логирования logging.basicConfig( @@ -103,6 +110,9 @@ TEXTS = { 'caption': "Видео скачано с @{bot_username}", 'error': "❌ Произошла ошибка при обработке видео:\n{error}", 'error_unknown_source': "Пардон, не умеем работать с этим источником", + 'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ)\n\nTelegram Bot API позволяет отправлять файлы до 50 МБ.\n\nПопробуйте выбрать видео покороче или в меньшем качестве.", + 'queue_position': "🕐 Ваше видео #{position} в очереди\nВаш запрос очень важен для нас!", + 'queue_first': "⬇️ Скачиваю видео...", }, 'en': { 'start': ( @@ -160,6 +170,9 @@ TEXTS = { 'caption': "Video downloaded via @{bot_username}", 'error': "❌ Error processing video:\n{error}", 'error_unknown_source': "Sorry, this source is not supported", + 'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB)\n\nTelegram Bot API allows files up to 50 MB.\n\nTry a shorter video or lower quality.", + 'queue_position': "🕐 Your video is #{position} in queue\nYour request is very important to us!", + 'queue_first': "⬇️ Downloading video...", } } @@ -363,6 +376,162 @@ def cleanup_old_files(): logger.error(f"Ошибка при очистке .part файлов: {e}") +# ============================================================================ +# СИСТЕМА ОЧЕРЕДЕЙ +# ============================================================================ + +@dataclass +class QueueItem: + """Элемент очереди для скачивания видео""" + original_message: Message + status_message: Message + url: str + chat_id: int + chat_type: str + locale: str + + +# Глобальная очередь и список элементов для отслеживания позиций +download_queue: asyncio.Queue = None +queue_items: list[QueueItem] = [] +queue_lock = asyncio.Lock() + + +async def update_queue_positions(): + """Обновляет статусы позиций в очереди для всех ожидающих""" + async with queue_lock: + for i, item in enumerate(queue_items): + position = i + 1 + try: + if position == 1: + # Первый в очереди - сейчас качается + await item.status_message.edit_text(get_text(item.locale, 'queue_first')) + else: + # Остальные в очереди + await item.status_message.edit_text( + get_text(item.locale, 'queue_position', position=position) + ) + except Exception as e: + logger.warning(f"Не удалось обновить статус очереди: {e}") + + +async def process_queue_item(item: QueueItem): + """Обрабатывает один элемент очереди""" + try: + # Скачиваем видео + video_path = await download_video(item.url, item.chat_id, item.locale) + + # Проверяем размер файла (лимит Telegram Bot API - 50 МБ) + file_size = Path(video_path).stat().st_size + max_size = 50 * 1024 * 1024 # 50 MB + + if file_size > max_size: + size_mb = file_size / (1024 * 1024) + error_msg = get_text(item.locale, 'error_file_too_large', size_mb=size_mb) + await item.status_message.edit_text(error_msg) + # Удаляем слишком большой файл + try: + Path(video_path).unlink() + logger.info(f"Удалён слишком большой файл: {video_path} ({size_mb:.1f} MB)") + except: + pass + return + + # Отправляем файл пользователю + await item.status_message.edit_text(get_text(item.locale, 'sending')) + + video_file = open(video_path, 'rb') + caption = get_text(item.locale, 'caption', bot_username=TELEGRAM_BOT_USERNAME) + await item.original_message.reply_video( + video=video_file, + caption=caption, + supports_streaming=True + ) + video_file.close() + + # Увеличиваем счетчик скачанных видео + increment_downloads() + + logger.info(f"Видео сохранено: {video_path}") + + # Удаляем статусное сообщение и исходное сообщение со ссылкой + try: + await item.status_message.delete() + await item.original_message.delete() + logger.info(f"Удалено сообщение пользователя с ссылкой (chat_id: {item.chat_id})") + except Exception as e: + logger.warning(f"Не удалось удалить сообщение: {e}") + + except Exception as e: + logger.error(f"Ошибка при обработке {item.url}: {e}") + error_msg = get_text(item.locale, 'error', error=str(e)) + try: + await item.status_message.edit_text(error_msg) + except: + try: + await item.original_message.reply_text(error_msg) + except: + pass + + try: + for part_file in DOWNLOADS_DIR.glob(f'{item.chat_id}_*.part'): + part_file.unlink() + logger.info(f"Удален .part файл после ошибки: {part_file.name}") + except Exception as cleanup_error: + logger.warning(f"Не удалось удалить .part файлы после ошибки: {cleanup_error}") + + +async def queue_worker(): + """Воркер, обрабатывающий очередь последовательно""" + global download_queue, queue_items + + logger.info("Воркер очереди запущен") + + while True: + try: + # Ждём элемент из очереди + item = await download_queue.get() + + logger.info(f"Начинаю обработку: {item.url} (в очереди: {len(queue_items)})") + + # Обновляем статусы - первый теперь качается + await update_queue_positions() + + # Обрабатываем элемент + await process_queue_item(item) + + # Удаляем из списка отслеживания + async with queue_lock: + if item in queue_items: + queue_items.remove(item) + + # Обновляем позиции оставшихся + await update_queue_positions() + + # Сообщаем что задача выполнена + download_queue.task_done() + + logger.info(f"Обработка завершена. Осталось в очереди: {len(queue_items)}") + + except Exception as e: + logger.error(f"Ошибка в воркере очереди: {e}") + + +async def add_to_queue(item: QueueItem) -> int: + """Добавляет элемент в очередь и возвращает позицию""" + global queue_items + + async with queue_lock: + queue_items.append(item) + position = len(queue_items) + + await download_queue.put(item) + + logger.info(f"Добавлено в очередь: {item.url}, позиция: {position}") + + return position + + # ============================================================================ # ФУНКЦИИ СКАЧИВАНИЯ # ============================================================================ @@ -374,7 +543,7 @@ async def download_youtube_video(url: str, chat_id: int, max_retries: int = 3) - last_error = None for attempt in range(max_retries): try: - async with httpx.AsyncClient(timeout=600.0) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: response = await client.post( f"{YOUTUBE_DOWNLOADER_URL}/download/stream", json={"url": url}, @@ -430,7 +599,7 @@ async def download_instagram_video(url: str, chat_id: int, max_retries: int = 3) last_error = None for attempt in range(max_retries): try: - async with httpx.AsyncClient(timeout=600.0) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: response = await client.post( f"{INSTAGRAM_DOWNLOADER_URL}/download/stream", json={"url": url}, @@ -486,7 +655,7 @@ async def download_vk_video(url: str, chat_id: int, max_retries: int = 3) -> str last_error = None for attempt in range(max_retries): try: - async with httpx.AsyncClient(timeout=600.0) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: response = await client.post( f"{VK_DOWNLOADER_URL}/download/stream", json={"url": url}, @@ -542,7 +711,7 @@ async def download_yapfiles_video(url: str, chat_id: int, max_retries: int = 3) last_error = None for attempt in range(max_retries): try: - async with httpx.AsyncClient(timeout=600.0) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: response = await client.post( f"{YAPFILES_DOWNLOADER_URL}/download/stream", json={"url": url}, @@ -598,7 +767,7 @@ async def download_tiktok_video(url: str, chat_id: int, max_retries: int = 3) -> last_error = None for attempt in range(max_retries): try: - async with httpx.AsyncClient(timeout=600.0) as client: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: response = await client.post( f"{TIKTOK_DOWNLOADER_URL}/download/stream", json={"url": url}, @@ -713,50 +882,28 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): # Отправляем сообщение о начале обработки status_message = await update.message.reply_text(get_text(locale, 'processing')) - try: - # Скачиваем видео - await status_message.edit_text(get_text(locale, 'downloading')) - video_path = await download_video(url, chat_id, locale) - - # Отправляем файл пользователю - await status_message.edit_text(get_text(locale, 'sending')) - - video_file = open(video_path, 'rb') - caption = get_text(locale, 'caption', bot_username=TELEGRAM_BOT_USERNAME) - await update.message.reply_video( - video=video_file, - caption=caption, - supports_streaming=True + # Создаём элемент очереди + item = QueueItem( + original_message=update.message, + status_message=status_message, + url=url, + chat_id=chat_id, + chat_type=chat_type, + locale=locale + ) + + # Добавляем в очередь + position = await add_to_queue(item) + + # Показываем позицию в очереди + if position == 1: + # Первый - сразу начинаем качать + await status_message.edit_text(get_text(locale, 'queue_first')) + else: + # В очереди - показываем позицию + await status_message.edit_text( + get_text(locale, 'queue_position', position=position) ) - video_file.close() - - # Увеличиваем счетчик скачанных видео - increment_downloads() - - logger.info(f"Видео сохранено: {video_path}") - - # Удаляем статусное сообщение и исходное сообщение со ссылкой - try: - await status_message.delete() - await update.message.delete() - logger.info(f"Удалено сообщение пользователя с ссылкой (chat_id: {chat_id}, тип чата: {chat_type})") - except Exception as e: - logger.warning(f"Не удалось удалить сообщение: {e}") - - except Exception as e: - logger.error(f"Ошибка: {e}") - error_msg = get_text(locale, 'error', error=str(e)) - try: - await status_message.edit_text(error_msg) - except: - await update.message.reply_text(error_msg) - - try: - for part_file in DOWNLOADS_DIR.glob(f'{chat_id}_*.part'): - part_file.unlink() - logger.info(f"Удален .part файл после ошибки: {part_file.name}") - except Exception as cleanup_error: - logger.warning(f"Не удалось удалить .part файлы после ошибки: {cleanup_error}") async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -813,8 +960,20 @@ def main(): logger.info("Очистка .part файлов при старте...") cleanup_old_files() - # Создаем приложение - application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() + # Создаем приложение с максимальными таймаутами для больших файлов + request = HTTPXRequest( + read_timeout=3600, # 1 час на получение ответа + write_timeout=3600, # 1 час на отправку видео + connect_timeout=300, # 5 минут на соединение + pool_timeout=300 # 5 минут на получение соединения из пула + ) + application = ( + Application.builder() + .token(TELEGRAM_BOT_TOKEN) + .request(request) + .get_updates_request(HTTPXRequest(read_timeout=120, connect_timeout=60)) + .build() + ) # Регистрируем обработчики application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) @@ -822,9 +981,19 @@ def main(): application.add_handler(CommandHandler("stat", stat_command)) application.add_handler(CommandHandler("support", support_command)) - # Запускаем периодическую очистку файлов + # Инициализация очереди и запуск воркера async def post_init(application: Application): """Выполняется после инициализации приложения""" + global download_queue + + # Инициализируем очередь + download_queue = asyncio.Queue() + + # Запускаем воркер очереди + asyncio.create_task(queue_worker()) + logger.info("Воркер очереди запущен") + + # Запускаем периодическую очистку .part файлов (каждые 6 часов) async def periodic_cleanup(): while True: await asyncio.sleep(6 * 3600) diff --git a/instagram-downloader/Dockerfile b/instagram-downloader/Dockerfile index 760c32a..78e1637 100644 --- a/instagram-downloader/Dockerfile +++ b/instagram-downloader/Dockerfile @@ -20,5 +20,6 @@ RUN mkdir -p downloads ENV PYTHONUNBUFFERED=1 -CMD ["python", "app.py"] +# Gunicorn: 1 worker (последовательная обработка), без таймаута +CMD ["gunicorn", "--workers=1", "--timeout=0", "--bind=0.0.0.0:5000", "app:app"] diff --git a/instagram-downloader/requirements.txt b/instagram-downloader/requirements.txt index 3fc2993..36f57c1 100644 --- a/instagram-downloader/requirements.txt +++ b/instagram-downloader/requirements.txt @@ -2,3 +2,4 @@ Flask==3.0.0 flask-cors==4.0.0 yt-dlp>=2024.12.13 +gunicorn==21.2.0 diff --git a/tiktok-downloader/Dockerfile b/tiktok-downloader/Dockerfile index 8edf75d..ef0e278 100644 --- a/tiktok-downloader/Dockerfile +++ b/tiktok-downloader/Dockerfile @@ -18,5 +18,6 @@ COPY app.py . RUN mkdir -p downloads # Запуск приложения -CMD ["python", "app.py"] +# Gunicorn: 1 worker (последовательная обработка), без таймаута +CMD ["gunicorn", "--workers=1", "--timeout=0", "--bind=0.0.0.0:5000", "app:app"] diff --git a/tiktok-downloader/requirements.txt b/tiktok-downloader/requirements.txt index 8962917..a27caaf 100644 --- a/tiktok-downloader/requirements.txt +++ b/tiktok-downloader/requirements.txt @@ -3,3 +3,4 @@ flask-cors==4.0.0 yt-dlp>=2024.12.13 requests==2.31.0 +gunicorn==21.2.0 diff --git a/vk-downloader/Dockerfile b/vk-downloader/Dockerfile index 760c32a..78e1637 100644 --- a/vk-downloader/Dockerfile +++ b/vk-downloader/Dockerfile @@ -20,5 +20,6 @@ RUN mkdir -p downloads ENV PYTHONUNBUFFERED=1 -CMD ["python", "app.py"] +# Gunicorn: 1 worker (последовательная обработка), без таймаута +CMD ["gunicorn", "--workers=1", "--timeout=0", "--bind=0.0.0.0:5000", "app:app"] diff --git a/vk-downloader/requirements.txt b/vk-downloader/requirements.txt index 8962917..a27caaf 100644 --- a/vk-downloader/requirements.txt +++ b/vk-downloader/requirements.txt @@ -3,3 +3,4 @@ flask-cors==4.0.0 yt-dlp>=2024.12.13 requests==2.31.0 +gunicorn==21.2.0 diff --git a/yapfiles-downloader/Dockerfile b/yapfiles-downloader/Dockerfile index 3eb1fe0..351ccbe 100644 --- a/yapfiles-downloader/Dockerfile +++ b/yapfiles-downloader/Dockerfile @@ -13,5 +13,6 @@ COPY app.py . RUN mkdir -p downloads # Запуск приложения -CMD ["python", "app.py"] +# Gunicorn: 1 worker (последовательная обработка), без таймаута +CMD ["gunicorn", "--workers=1", "--timeout=0", "--bind=0.0.0.0:5000", "app:app"] diff --git a/yapfiles-downloader/requirements.txt b/yapfiles-downloader/requirements.txt index e7abb06..8ebc7c1 100644 --- a/yapfiles-downloader/requirements.txt +++ b/yapfiles-downloader/requirements.txt @@ -3,3 +3,4 @@ flask-cors==4.0.0 requests==2.31.0 beautifulsoup4==4.12.2 +gunicorn==21.2.0 diff --git a/youtube-downloader/Dockerfile b/youtube-downloader/Dockerfile index 760c32a..78e1637 100644 --- a/youtube-downloader/Dockerfile +++ b/youtube-downloader/Dockerfile @@ -20,5 +20,6 @@ RUN mkdir -p downloads ENV PYTHONUNBUFFERED=1 -CMD ["python", "app.py"] +# Gunicorn: 1 worker (последовательная обработка), без таймаута +CMD ["gunicorn", "--workers=1", "--timeout=0", "--bind=0.0.0.0:5000", "app:app"] diff --git a/youtube-downloader/requirements.txt b/youtube-downloader/requirements.txt index 3fc2993..16efb3f 100644 --- a/youtube-downloader/requirements.txt +++ b/youtube-downloader/requirements.txt @@ -1,4 +1,5 @@ Flask==3.0.0 flask-cors==4.0.0 yt-dlp>=2024.12.13 +gunicorn==21.2.0