fix: отправка видео как документ (без сжатия Telegram) и исправление format_id для точного выбора качества

- Замена reply_video() на reply_document() в bot.py — Telegram больше не сжимает видео
- Исправление format_id в get_youtube_formats(): конкретные format codes + fallback best[height<=N]
- Замена bestvideo[height<=N]+bestaudio на best[height<=N] — гарантированно работает когда
  YouTube не отдаёт отдельные video-only потоки для низких разрешений
- Добавлено логирование реально скачанного формата для диагностики
This commit is contained in:
vrubelroman 2026-04-30 01:36:43 +03:00
parent 4b7cc403b2
commit 4629535e97
6 changed files with 632 additions and 95 deletions

160
bot.py
View file

@ -9,8 +9,8 @@ from urllib.parse import urlparse
from datetime import datetime
import httpx
from telegram import Update, Message, Bot
from telegram.ext import Application, MessageHandler, filters, ContextTypes, CommandHandler, Defaults
from telegram import Update, Message, Bot, InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery
from telegram.ext import Application, MessageHandler, filters, ContextTypes, CommandHandler, Defaults, CallbackQueryHandler
from telegram.request import HTTPXRequest
from dataclasses import dataclass
from typing import Optional
@ -110,6 +110,9 @@ TEXTS = {
'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)",
'queue_position': "🕐 Ваше видео #{position} в очереди\nВаш запрос очень важен для нас!",
'queue_first': "⬇️ Скачиваю видео...",
'select_quality': "Выберите качество видео:",
'quality_cancelled': "❌ Выбор отменён",
'fetching_formats': "🔍 Получаю доступные форматы...",
},
'en': {
'start': (
@ -164,6 +167,9 @@ TEXTS = {
'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB, max = 50)",
'queue_position': "🕐 Your video is #{position} in queue\nYour request is very important to us!",
'queue_first': "⬇️ Downloading video...",
'select_quality': "Select video quality:",
'quality_cancelled': "❌ Cancelled",
'fetching_formats': "🔍 Fetching available formats...",
}
}
@ -491,6 +497,7 @@ class QueueItem:
chat_id: int
chat_type: str
locale: str
format_id: str | None = None
# Глобальная очередь и список элементов для отслеживания позиций
@ -521,7 +528,7 @@ async def process_queue_item(item: QueueItem):
"""Обрабатывает один элемент очереди"""
try:
# Скачиваем видео
video_path = await download_video(item.url, item.chat_id, item.locale)
video_path = await download_video(item.url, item.chat_id, item.locale, format_id=item.format_id)
# Проверяем размер файла (лимит Telegram Bot API - 50 МБ)
file_size = Path(video_path).stat().st_size
@ -548,10 +555,16 @@ async def process_queue_item(item: QueueItem):
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,
# Определяем имя файла для отправки
video_filename = Path(video_path).name
# Отправляем как документ, чтобы Telegram НЕ сжимал видео
# (reply_video сжимает, что приводит к потере качества и одинаковому размеру)
await item.original_message.reply_document(
document=video_file,
filename=video_filename,
caption=caption,
supports_streaming=True,
read_timeout=600, # 10 минут на ответ от Telegram
write_timeout=600, # 10 минут на отправку файла
connect_timeout=60,
@ -661,7 +674,7 @@ async def add_to_queue(item: QueueItem) -> int:
# ФУНКЦИИ СКАЧИВАНИЯ
# ============================================================================
async def download_youtube_video(url: str, chat_id: int, max_retries: int = 3) -> str:
async def download_youtube_video(url: str, chat_id: int, max_retries: int = 3, format_id: str | None = None) -> str:
"""Скачивает видео с YouTube через внешний сервис"""
logger.info(f"YouTube: отправка запроса на внешний сервис {YOUTUBE_DOWNLOADER_URL}")
@ -669,9 +682,13 @@ async def download_youtube_video(url: str, chat_id: int, max_retries: int = 3) -
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
# Формируем тело запроса, опционально с format_id
body = {"url": url}
if format_id:
body["format_id"] = format_id
response = await client.post(
f"{YOUTUBE_DOWNLOADER_URL}/download/stream",
json={"url": url},
json=body,
headers={"Content-Type": "application/json"}
)
@ -941,13 +958,116 @@ async def download_tiktok_video(url: str, chat_id: int, max_retries: int = 3) ->
raise last_error or Exception("Неизвестная ошибка при скачивании с TikTok через внешний сервис")
async def download_video(url: str, chat_id: int, locale: str, max_retries: int = 3) -> str:
# ============================================================================
# ВЫБОР КАЧЕСТВА (только для YouTube)
# ============================================================================
async def get_formats_from_service(url: str) -> list[dict] | None:
"""Получает список доступных форматов для YouTube URL через сервис youtube-downloader"""
logger.info(f"Получение форматов для YouTube: {url}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
response = await client.post(
f"{YOUTUBE_DOWNLOADER_URL}/formats",
json={"url": url},
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
data = response.json()
return data.get('formats', [])
logger.warning(f"Не удалось получить форматы: {response.status_code}")
return None
except Exception as e:
logger.error(f"Ошибка при получении форматов: {e}")
return None
async def show_quality_selection(status_message: Message, formats: list[dict], locale: str):
"""Показывает inline клавиатуру с выбором качества видео"""
keyboard = []
for fmt in formats:
label = fmt.get('label', fmt.get('quality', 'Unknown'))
filesize = fmt.get('filesize_mb')
if filesize:
button_text = f"{label} ({filesize:.0f} MB)"
else:
button_text = label
keyboard.append([InlineKeyboardButton(
text=button_text,
callback_data=f"quality:{fmt['format_id']}"
)])
# Кнопка отмены
keyboard.append([InlineKeyboardButton(
text=get_text(locale, 'quality_cancelled'),
callback_data="quality:cancel"
)])
reply_markup = InlineKeyboardMarkup(keyboard)
await status_message.edit_text(
get_text(locale, 'select_quality'),
reply_markup=reply_markup
)
async def handle_format_selection(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обрабатывает выбор качества пользователем через callback query"""
query = update.callback_query
await query.answer()
chat_id = query.message.chat_id
callback_data = query.data
# Получаем сохраненные данные
data = context.user_data.pop(f'quality_{chat_id}', None)
if not data:
await query.edit_message_text("Session expired, please send the link again")
return
locale = data['locale']
status_message = data['status_message']
if callback_data == "quality:cancel":
await status_message.edit_text(get_text(locale, 'quality_cancelled'))
return
# Извлекаем format_id
format_id = callback_data.replace('quality:', '')
# Обновляем сообщение - добавляем в очередь
await status_message.edit_text(get_text(locale, 'processing'))
# Создаём элемент очереди с выбранным format_id
item = QueueItem(
original_message=data['original_message'],
status_message=status_message,
url=data['url'],
chat_id=chat_id,
chat_type=data['chat_type'],
locale=locale,
format_id=format_id
)
# Добавляем в очередь
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)
)
async def download_video(url: str, chat_id: int, locale: str, max_retries: int = 3, format_id: str | None = None) -> str:
"""Главная функция скачивания - вызывает нужную функцию в зависимости от источника"""
source = detect_video_source(url)
logger.info(f"Определен источник: {source} для URL: {url}")
if source == 'youtube':
return await download_youtube_video(url, chat_id, max_retries)
return await download_youtube_video(url, chat_id, max_retries, format_id=format_id)
elif source == 'instagram':
return await download_instagram_video(url, chat_id, max_retries)
elif source == 'vk':
@ -1007,6 +1127,25 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Отправляем сообщение о начале обработки
status_message = await update.message.reply_text(get_text(locale, 'processing'))
# Для YouTube - показываем выбор качества перед добавлением в очередь
if source == 'youtube':
await status_message.edit_text(get_text(locale, 'fetching_formats'))
formats = await get_formats_from_service(url)
if formats:
# Сохраняем данные для обработки в колбэке
context.user_data[f'quality_{chat_id}'] = {
'url': url,
'locale': locale,
'chat_id': chat_id,
'chat_type': chat_type,
'original_message': update.message,
'status_message': status_message
}
await show_quality_selection(status_message, formats, locale)
return
# Если не удалось получить форматы, скачиваем как обычно (без выбора качества)
await status_message.edit_text(get_text(locale, 'processing'))
# Создаём элемент очереди
item = QueueItem(
original_message=update.message,
@ -1091,6 +1230,7 @@ def main():
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
application.add_handler(CommandHandler("start", start_command))
application.add_handler(CommandHandler("support", support_command))
application.add_handler(CallbackQueryHandler(handle_format_selection, pattern=r'^quality:'))
# Инициализация очереди и запуск воркера
async def post_init(application: Application):