diff --git a/bot/cache.py b/bot/cache.py index c898252..285f673 100644 --- a/bot/cache.py +++ b/bot/cache.py @@ -1,9 +1,17 @@ import hashlib -import os -def cache_key(url, quality, audio=False): - raw = f"{url}:{quality}:{audio}" - return hashlib.sha256(raw.encode()).hexdigest() -def cache_path(cache_dir, key, ext): - return os.path.join(cache_dir, f"{key}.{ext}") \ No newline at end of file +def cache_key(url: str, quality: str, audio: bool = False) -> str: + """Генерирует ключ кэша на основе URL, качества и типа""" + key_str = f"{url}_{quality}_{audio}" + return hashlib.sha256(key_str.encode()).hexdigest() + + +def cache_path(cache_dir: str, key: str, extension: str) -> str: + """Создает путь к файлу в кэше""" + # Создаем вложенную структуру для лучшей организации + subdir = key[:2] + import os + full_dir = os.path.join(cache_dir, subdir) + os.makedirs(full_dir, exist_ok=True) + return os.path.join(full_dir, f"{key}.{extension}") \ No newline at end of file diff --git a/bot/downloader.py b/bot/downloader.py index b94454b..eb204a4 100644 --- a/bot/downloader.py +++ b/bot/downloader.py @@ -2,6 +2,7 @@ import yt_dlp import threading import os import logging +from typing import List, Optional logger = logging.getLogger(__name__) @@ -15,13 +16,15 @@ def _progress_hook(cancel_event, progress_cb): if cancel_event.is_set(): raise DownloadCancelled("Cancelled by user") - if d["status"] == "downloading": + if d["status"] == "downloading" and progress_cb: + progress_cb(d) + elif d["status"] == "finished" and progress_cb: progress_cb(d) return hook -# ---------------- VIDEO ---------------- +# ---------------- STANDARD VIDEO ---------------- def download_video( url: str, @@ -32,18 +35,16 @@ def download_video( progress_cb, ): ydl_opts = { - "format": quality, + "format": f"best[height<={quality}]/best", "outtmpl": out_path, "merge_output_format": "mp4", "cookiefile": cookies, "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, "no_warnings": True, - # Настройки для TikTok "extractor_args": { - "tiktok": { - "skip_impersonation": True # Пропускаем имитацию если зависимости не установлены - } + "tiktok": {"skip_impersonation": True}, + "instagram": {"skip_impersonation": True}, }, } @@ -51,6 +52,91 @@ def download_video( ydl.download([url]) +# ---------------- ORIGINAL QUALITY (Instagram/TikTok) ---------------- + +def download_original_quality( + url: str, + out_path: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, +): + """Скачивает видео в оригинальном качестве (без перекодировки)""" + ydl_opts = { + "format": "best", # Лучшее качество доступное + "outtmpl": out_path, + "merge_output_format": "mp4", + "cookiefile": cookies, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "quiet": True, + "no_warnings": True, + "extractor_args": { + "tiktok": {"skip_impersonation": True}, + "instagram": {"skip_impersonation": True}, + }, + # Для Instagram и TikTok используем специфичные форматы + "format_sort": ["quality", "res", "codec", "size"], + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + +# ---------------- PLAYLIST DOWNLOAD ---------------- + +def download_playlist_videos( + playlist_info: dict, + output_dir: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, +) -> List[str]: + """Скачивает все видео из плейлиста""" + downloaded_files = [] + + ydl_opts = { + "format": "best[height<=1080]/best", + "outtmpl": os.path.join(output_dir, "%(title)s.%(ext)s"), + "merge_output_format": "mp4", + "cookiefile": cookies, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "quiet": True, + "no_warnings": True, + "ignoreerrors": True, # Игнорируем ошибки при загрузке отдельных видео + "extract_flat": False, + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + # Скачиваем каждое видео по отдельности + for entry in playlist_info.get('entries', []): + if cancel_event.is_set(): + raise DownloadCancelled("Cancelled by user") + + if not entry.get('url'): + continue + + try: + ydl.download([entry['url']]) + + # Ищем скачанный файл + for filename in os.listdir(output_dir): + if filename.endswith(('.mp4', '.mkv', '.webm')): + file_path = os.path.join(output_dir, filename) + downloaded_files.append(file_path) + break + + except Exception as e: + logger.error(f"Error downloading video {entry.get('title')}: {e}") + continue + + return downloaded_files + + except Exception as e: + logger.error(f"Error downloading playlist: {e}") + raise + + # ---------------- AUDIO FROM VIDEO ---------------- def download_audio( @@ -77,11 +163,9 @@ def download_audio( "writethumbnail": True, "quiet": True, "no_warnings": True, - # Настройки для TikTok "extractor_args": { - "tiktok": { - "skip_impersonation": True - } + "tiktok": {"skip_impersonation": True}, + "instagram": {"skip_impersonation": True}, }, } diff --git a/bot/info.py b/bot/info.py index 58fd38f..bf12714 100644 --- a/bot/info.py +++ b/bot/info.py @@ -1,12 +1,93 @@ import yt_dlp +import re -def extract_info(url, cookies): - opts = { + +def extract_info(url: str, cookies_file: str = None) -> dict: + """Извлекает информацию о видео""" + ydl_opts = { "quiet": True, - "skip_download": True, + "no_warnings": True, + "cookiefile": cookies_file, + "extractor_args": { + "tiktok": {"skip_impersonation": True}, + "instagram": {"skip_impersonation": True}, + }, } - if cookies: - opts["cookiefile"] = cookies + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + try: + return ydl.extract_info(url, download=False) + except: + return None - with yt_dlp.YoutubeDL(opts) as ydl: - return ydl.extract_info(url, download=False) \ No newline at end of file + +def is_playlist(url: str) -> bool: + """Проверяет, является ли ссылка плейлистом""" + # Паттерны для плейлистов + playlist_patterns = [ + r"youtube\.com/playlist", # YouTube плейлист + r"youtube\.com/watch.*list=", # YouTube видео из плейлиста + r"youtu\.be/.*list=", # Сокращенная ссылка YouTube + ] + + for pattern in playlist_patterns: + if re.search(pattern, url, re.IGNORECASE): + return True + + # Используем yt-dlp для более точной проверки + try: + ydl_opts = {"quiet": True, "no_warnings": True} + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False, process=False) + return info.get('_type') == 'playlist' + except: + return False + + +def get_platform_info(url: str) -> str: + """Определяет платформу по ссылке""" + patterns = { + "youtube": r"(youtube\.com|youtu\.be)", + "tiktok": r"tiktok\.com", + "instagram": r"(instagram\.com|instagr\.am)", + "twitter": r"(twitter\.com|x\.com)", + "facebook": r"facebook\.com", + "vk": r"vk\.com", + "rutube": r"rutube\.ru", + "vimeo": r"vimeo\.com", + "twitch": r"twitch\.tv", + "dailymotion": r"dailymotion\.com", + } + + for platform, pattern in patterns.items(): + if re.search(pattern, url, re.IGNORECASE): + return platform + + return "other" + + +def get_playlist_info(url: str) -> dict: + """Получает информацию о плейлисте""" + ydl_opts = { + "quiet": True, + "no_warnings": True, + "extract_flat": True, + "playlistend": 50, # Ограничиваем количество видео (можно изменить) + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + try: + return ydl.extract_info(url, download=False) + except: + return None + + +def get_first_video_from_playlist(url: str) -> str: + """Получает первую ссылку на видео из плейлиста""" + playlist_info = get_playlist_info(url) + + if playlist_info and playlist_info.get('entries'): + first_video = playlist_info['entries'][0] + return first_video.get('url') + + return None \ No newline at end of file diff --git a/bot/keyboards.py b/bot/keyboards.py index c4a3377..84cedf8 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -2,27 +2,71 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton def quality_keyboard(): - return InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton(text="480p", callback_data="q:best[height<=480]"), - InlineKeyboardButton(text="720p", callback_data="q:best[height<=720]"), - ], - [ - InlineKeyboardButton(text="1080p", callback_data="q:best[height<=1080]"), - InlineKeyboardButton(text="4K", callback_data="q:bestvideo+bestaudio"), - ], - [ - InlineKeyboardButton(text="🎧 Аудио из видео", callback_data="audio"), - ] + """Клавиатура выбора качества""" + keyboard = [ + [ + InlineKeyboardButton(text="📹 720p", callback_data="q:720"), + InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"), + InlineKeyboardButton(text="🎥 1440p", callback_data="q:1440"), + ], + [ + InlineKeyboardButton(text="🎧 Аудио", callback_data="audio"), + InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"), ] - ) - + ] + return InlineKeyboardMarkup(inline_keyboard=keyboard) def cancel_keyboard(): - return InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text="❌ Отменить", callback_data="cancel")] + """Клавиатура с кнопкой отмены""" + keyboard = [ + [InlineKeyboardButton(text="⛔ Отменить загрузку", callback_data="cancel")] + ] + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def playlist_keyboard(confirm=False): + """Клавиатура для плейлистов""" + if confirm: + keyboard = [ + [ + InlineKeyboardButton(text="✅ Да, загрузить все", callback_data="playlist_confirm_yes"), + InlineKeyboardButton(text="❌ Нет, отменить", callback_data="playlist_confirm_no"), + ] ] - ) + else: + keyboard = [ + [ + InlineKeyboardButton(text="📁 Загрузить все видео", callback_data="playlist_all"), + InlineKeyboardButton(text="🎬 Только первое видео", callback_data="playlist_first"), + ], + [ + InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"), + ] + ] + return InlineKeyboardMarkup(inline_keyboard=keyboard) + + +def platform_keyboard(platform): + """Клавиатура для Instagram/TikTok""" + if platform == "instagram": + text = "📸 Instagram" + elif platform == "tiktok": + text = "🎵 TikTok" + else: + text = platform.capitalize() + + keyboard = [ + [ + InlineKeyboardButton(text=f"🎬 Оригинальное качество", callback_data="original_quality"), + ], + [ + InlineKeyboardButton(text="📹 720p", callback_data="q:720"), + InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"), + ], + [ + InlineKeyboardButton(text="🎧 Аудио", callback_data="audio"), + InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"), + ] + ] + return InlineKeyboardMarkup(inline_keyboard=keyboard) \ No newline at end of file diff --git a/bot/main.py b/bot/main.py index d8a1011..b2cad32 100644 --- a/bot/main.py +++ b/bot/main.py @@ -5,9 +5,10 @@ import logging import subprocess import time from datetime import datetime +from typing import Optional from aiogram import Bot, Dispatcher, F -from aiogram.types import Message, CallbackQuery, FSInputFile +from aiogram.types import Message, CallbackQuery, FSInputFile, InputMediaDocument from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.telegram import TelegramAPIServer @@ -21,11 +22,22 @@ from config import ( CACHE_MAX_AGE_DAYS, CACHE_MAX_SIZE_MB, ) -from keyboards import quality_keyboard, cancel_keyboard -from downloader import download_video, download_audio, DownloadCancelled +from keyboards import ( + quality_keyboard, + cancel_keyboard, + playlist_keyboard, + platform_keyboard +) +from downloader import ( + download_video, + download_audio, + download_original_quality, + download_playlist_videos, + DownloadCancelled +) from middleware import PrivateMiddleware from rate_limit import check_rate_limit -from info import extract_info +from info import extract_info, is_playlist, get_platform_info from cache import cache_key, cache_path from cleanup import cleanup_tmp @@ -55,6 +67,7 @@ dp.message.middleware(private_middleware) dp.callback_query.middleware(private_middleware) USER_URLS: dict[int, str] = {} +USER_DATA: dict[int, dict] = {} # Для хранения дополнительных данных ACTIVE_DOWNLOADS: dict[int, dict] = {} # -------------------- cache cleaning -------------------- @@ -232,6 +245,41 @@ def make_progress_cb(loop, message): return cb +def make_playlist_progress_cb(loop, message, total_videos: int): + current_video = {"value": 0} + last_update = {"time": 0} + + async def update(d): + try: + # Обновляем не чаще чем раз в 3 секунды + current_time = time.time() + if current_time - last_update["time"] < 3: + return + + last_update["time"] = current_time + + if d.get("status") == "finished": + current_video["value"] += 1 + + text = ( + f"📁 Загрузка плейлиста\n" + f"📹 Видео: {current_video['value']}/{total_videos}\n" + f"⏳ Продолжаем загрузку..." + ) + + await message.edit_text( + text, + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Error updating playlist progress: {e}") + + def cb(d): + asyncio.run_coroutine_threadsafe(update(d), loop) + + return cb + + def optimize_for_telegram(input_path: str, output_path: str) -> bool: """ Оптимизирует видео для телеграма @@ -289,12 +337,35 @@ async def start(message: Message): await message.answer( "👋 Привет!\n\n" "📥 Я скачиваю видео и звук из видео по ссылке.\n\n" - "🔄 Кэш автоматически очищается раз в сутки\n" + "✨ Новые возможности:\n" + "• 🎬 Оригинальное качество (Instagram, TikTok)\n" + "• 📁 Плейлисты YouTube\n" + "• 🔄 Автоочистка кэша\n\n" "👉 Просто отправь ссылку.", parse_mode="HTML" ) +@dp.message(F.text == "/help") +async def help_command(message: Message): + await message.answer( + "📚 Справка по командам:\n\n" + "• /start - Начать работу\n" + "• /help - Эта справка\n" + "• /cache_stats - Статистика кэша\n\n" + "✨ Поддерживаемые платформы:\n" + "• YouTube (видео и плейлисты)\n" + "• TikTok (оригинальное качество)\n" + "• Instagram (Reels, видео, IGTV)\n" + "• Twitter/X, Facebook, VK и другие\n\n" + "🎯 Особенности:\n" + "• Instagram/TikTok: есть опция оригинального качества\n" + "• Плейлисты: загрузка всех видео из плейлиста\n" + "• Аудио: извлечение звука из любого видео", + parse_mode="HTML" + ) + + @dp.message(F.text == "/cache_stats") async def cache_stats(message: Message): """Показывает статистику кэша""" @@ -345,12 +416,44 @@ async def cache_stats(message: Message): @dp.message(F.text.startswith("http")) async def handle_link(message: Message): url = message.text.strip() - USER_URLS[message.from_user.id] = url - await message.answer( - "🔽 Выбери формат загрузки:", - reply_markup=quality_keyboard(), - parse_mode="HTML" - ) + user_id = message.from_user.id + + USER_URLS[user_id] = url + + # Проверяем, является ли ссылка плейлистом + try: + is_playlist_url = await asyncio.to_thread(is_playlist, url) + if is_playlist_url: + USER_DATA[user_id] = {"is_playlist": True} + await message.answer( + "📁 Обнаружен плейлист!\n\n" + "Выберите действие:", + reply_markup=playlist_keyboard(), + parse_mode="HTML" + ) + return + except: + pass + + # Определяем платформу + platform_info = await asyncio.to_thread(get_platform_info, url) + + # Для Instagram и TikTok предлагаем оригинальное качество + if platform_info in ["instagram", "tiktok"]: + USER_DATA[user_id] = {"platform": platform_info} + await message.answer( + f"🎬 Ссылка с {platform_info.capitalize()}\n\n" + "Выберите качество загрузки:", + reply_markup=platform_keyboard(platform_info), + parse_mode="HTML" + ) + else: + # Для других платформ обычное меню + await message.answer( + "🔽 Выбери формат загрузки:", + reply_markup=quality_keyboard(), + parse_mode="HTML" + ) @dp.callback_query(F.data == "cancel") @@ -366,7 +469,323 @@ async def cancel_download(callback: CallbackQuery): await callback.answer("❌ Нет активной загрузки", show_alert=True) -# ---------------- VIDEO ---------------- +# ---------------- PLAYLIST HANDLERS ---------------- + +@dp.callback_query(F.data == "playlist_all") +async def handle_playlist_all(callback: CallbackQuery): + await callback.answer() + user_id = callback.from_user.id + url = USER_URLS.get(user_id) + + if not url: + await callback.message.answer("❌ Ссылка не найдена") + return + + if not check_rate_limit(user_id, RATE_LIMIT_SECONDS * 3): # Больший лимит для плейлистов + await callback.message.answer("⏳ Подожди немного перед следующим запросом") + return + + await callback.message.edit_reply_markup(reply_markup=None) + status = await callback.message.answer("📁 Анализирую плейлист…", parse_mode="HTML") + + try: + # Получаем информацию о плейлисте + from info import get_playlist_info + playlist_info = await asyncio.to_thread(get_playlist_info, url) + + if not playlist_info or 'entries' not in playlist_info: + await status.edit_text("❌ Не удалось получить информацию о плейлисте") + return + + video_count = len(playlist_info['entries']) + if video_count == 0: + await status.edit_text("❌ Плейлист пуст") + return + + # Подтверждение для больших плейлистов + if video_count > 10: + await callback.message.answer( + f"⚠️ Внимание!\n\n" + f"Плейлист содержит {video_count} видео.\n" + f"Это может занять много времени и места.\n\n" + f"Продолжить загрузку?", + reply_markup=playlist_keyboard(confirm=True), + parse_mode="HTML" + ) + USER_DATA[user_id] = {"playlist_info": playlist_info, "status_message": status} + return + + await download_playlist_confirm(callback, user_id, playlist_info, status) + + except Exception as e: + logger.error(f"Error analyzing playlist: {e}") + await status.edit_text("❌ Ошибка при анализе плейлиста") + + +@dp.callback_query(F.data == "playlist_confirm_yes") +async def handle_playlist_confirm(callback: CallbackQuery): + await callback.answer() + user_id = callback.from_user.id + + data = USER_DATA.get(user_id, {}) + playlist_info = data.get("playlist_info") + status = data.get("status_message") + + if not playlist_info or not status: + await callback.message.answer("❌ Данные плейлиста не найдены") + return + + await callback.message.edit_reply_markup(reply_markup=None) + await download_playlist_confirm(callback, user_id, playlist_info, status) + + +async def download_playlist_confirm(callback, user_id, playlist_info, status): + """Загружает плейлист после подтверждения""" + try: + video_count = len(playlist_info['entries']) + playlist_title = playlist_info.get('title', 'Плейлист') + + await status.edit_text( + f"📁 Начинаю загрузку плейлиста\n\n" + f"🎬 Название: {playlist_title}\n" + f"📹 Видео: {video_count}\n" + f"⏳ Подготовка...", + parse_mode="HTML" + ) + + cancel_event = threading.Event() + ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} + loop = asyncio.get_running_loop() + progress_cb = make_playlist_progress_cb(loop, status, video_count) + + # Создаем временную директорию для плейлиста + import uuid + playlist_dir = os.path.join(TMP_DIR, f"playlist_{uuid.uuid4().hex[:8]}") + os.makedirs(playlist_dir, exist_ok=True) + + # Загружаем плейлист + downloaded_files = await asyncio.to_thread( + download_playlist_videos, + playlist_info, + playlist_dir, + COOKIES_FILE, + cancel_event, + progress_cb + ) + + if cancel_event.is_set(): + await status.edit_text("⛔ Загрузка плейлиста отменена") + # Очищаем временные файлы + import shutil + shutil.rmtree(playlist_dir, ignore_errors=True) + return + + if not downloaded_files: + await status.edit_text("❌ Не удалось загрузить видео из плейлиста") + shutil.rmtree(playlist_dir, ignore_errors=True) + return + + # Отправляем файлы частями (телеграм лимит - 10 файлов в группе) + await status.edit_text(f"📤 Отправляю {len(downloaded_files)} видео…", parse_mode="HTML") + + for i in range(0, len(downloaded_files), 10): + batch = downloaded_files[i:i+10] + media_group = [] + + for file_path in batch: + media_group.append( + InputMediaDocument( + media=FSInputFile(file_path), + caption=f"🎬 {os.path.basename(file_path)}" if i == 0 else None + ) + ) + + try: + await callback.message.answer_media_group(media_group) + except Exception as e: + logger.error(f"Error sending media group: {e}") + # Отправляем по одному если группа не работает + for file_path in batch: + try: + await callback.message.answer_document(FSInputFile(file_path)) + except: + pass + + # Небольшая пауза между группами + await asyncio.sleep(1) + + total_size = sum(os.path.getsize(f) for f in downloaded_files) + total_size_mb = total_size / (1024 * 1024) + + await callback.message.answer( + f"✅ Плейлист загружен!\n\n" + f"📁 Видео: {len(downloaded_files)}\n" + f"💾 Общий размер: {total_size_mb:.1f} МБ\n" + f"🎬 Название: {playlist_title}", + parse_mode="HTML" + ) + + # Очищаем временные файлы + shutil.rmtree(playlist_dir, ignore_errors=True) + + except DownloadCancelled: + await status.edit_text("⛔ Загрузка плейлиста отменена") + except Exception as e: + logger.error(f"Error downloading playlist: {e}") + await status.edit_text(f"❌ Ошибка: {str(e)[:100]}") + finally: + ACTIVE_DOWNLOADS.pop(user_id, None) + cleanup_tmp(TMP_DIR) + + +@dp.callback_query(F.data == "playlist_confirm_no") +async def handle_playlist_cancel(callback: CallbackQuery): + await callback.answer("Отменено", show_alert=True) + await callback.message.edit_reply_markup(reply_markup=None) + + +@dp.callback_query(F.data == "playlist_first") +async def handle_playlist_first(callback: CallbackQuery): + await callback.answer() + user_id = callback.from_user.id + url = USER_URLS.get(user_id) + + if not url: + await callback.message.answer("❌ Ссылка не найдена") + return + + # Получаем первое видео из плейлиста + try: + from info import get_first_video_from_playlist + video_url = await asyncio.to_thread(get_first_video_from_playlist, url) + + if not video_url: + await callback.message.answer("❌ Не удалось получить видео из плейлиста") + return + + # Сохраняем новую ссылку и показываем меню выбора качества + USER_URLS[user_id] = video_url + await callback.message.edit_reply_markup(reply_markup=None) + await callback.message.answer( + "🔽 Выбери формат загрузки для первого видео:", + reply_markup=quality_keyboard(), + parse_mode="HTML" + ) + + except Exception as e: + logger.error(f"Error getting first video: {e}") + await callback.message.answer("❌ Ошибка при получении видео из плейлиста") + + +# ---------------- ORIGINAL QUALITY HANDLERS ---------------- + +@dp.callback_query(F.data == "original_quality") +async def handle_original_quality(callback: CallbackQuery): + await callback.answer() + user_id = callback.from_user.id + url = USER_URLS.get(user_id) + + if not url: + await callback.message.answer("❌ Ссылка не найдена") + return + + if not check_rate_limit(user_id, RATE_LIMIT_SECONDS): + await callback.message.answer("⏳ Подожди немного перед следующим запросом") + return + + await callback.message.edit_reply_markup(reply_markup=None) + status = await callback.message.answer("🎬 Загрузка в оригинальном качестве…", parse_mode="HTML") + + key = cache_key(url, "original", audio=False) + final_path = cache_path(CACHE_DIR, key, "mp4") + tmp_path = os.path.join(TMP_DIR, f"{key}.mp4") + + # Проверяем кэш + if os.path.exists(final_path): + await status.edit_text("📤 Отправляю файл из кэша…", parse_mode="HTML") + try: + await callback.message.answer_video(FSInputFile(final_path)) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Готово! (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Error sending cached file: {e}") + await status.edit_text("❌ Ошибка при отправке файла") + return + + cancel_event = threading.Event() + ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} + loop = asyncio.get_running_loop() + progress_cb = make_progress_cb(loop, status) + + try: + await asyncio.to_thread( + download_original_quality, + url, + tmp_path, + COOKIES_FILE, + cancel_event, + progress_cb, + ) + + if cancel_event.is_set(): + if os.path.exists(tmp_path): + os.remove(tmp_path) + await status.edit_text("⛔ Загрузка отменена") + return + + os.rename(tmp_path, final_path) + + except DownloadCancelled: + await status.edit_text("⛔ Загрузка отменена") + if os.path.exists(tmp_path): + os.remove(tmp_path) + return + except Exception as e: + logger.error(f"Error downloading original quality: {e}") + await status.edit_text( + "❌ Не удалось скачать в оригинальном качестве\n" + "💡 Попробуй обычное качество" + ) + if os.path.exists(tmp_path): + os.remove(tmp_path) + return + finally: + ACTIVE_DOWNLOADS.pop(user_id, None) + + await status.edit_text("📤 Отправляю видео…", parse_mode="HTML") + + try: + await callback.message.answer_video( + FSInputFile(final_path), + supports_streaming=True + ) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Готово! (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + except Exception as e: + logger.error(f"Error sending video: {e}") + try: + await callback.message.answer_document(FSInputFile(final_path)) + size_mb = os.path.getsize(final_path) / 1024 / 1024 + await callback.message.answer( + f"✅ Отправлено как документ (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + except Exception as e2: + logger.error(f"Error sending as document: {e2}") + await status.edit_text("❌ Ошибка при отправке файла") + if os.path.exists(final_path): + os.remove(final_path) + + cleanup_tmp(TMP_DIR) + + +# ---------------- VIDEO HANDLER (стандартное качество) ---------------- @dp.callback_query(F.data.startswith("q:")) async def handle_video(callback: CallbackQuery): @@ -440,15 +859,19 @@ async def handle_video(callback: CallbackQuery): await status.edit_text("⛔ Загрузка отменена") return - # Оптимизируем видео для телеграма - await status.edit_text("⚙️ Оптимизирую видео для телеграма…", parse_mode="HTML") - await asyncio.to_thread(optimize_for_telegram, tmp_path, optimized_path) - - # Удаляем исходный файл и используем оптимизированный - if os.path.exists(tmp_path): - os.remove(tmp_path) + # Оптимизируем видео для телеграма (кроме оригинального качества) + if quality != "original": + await status.edit_text("⚙️ Оптимизирую видео для телеграма…", parse_mode="HTML") + await asyncio.to_thread(optimize_for_telegram, tmp_path, optimized_path) - os.rename(optimized_path, final_path) + # Удаляем исходный файл и используем оптимизированный + if os.path.exists(tmp_path): + os.remove(tmp_path) + + os.rename(optimized_path, final_path) + else: + # Для оригинального качества не оптимизируем + os.rename(tmp_path, final_path) except DownloadCancelled: await status.edit_text("⛔ Загрузка отменена") @@ -499,7 +922,7 @@ async def handle_video(callback: CallbackQuery): cleanup_tmp(TMP_DIR) -# ---------------- AUDIO FROM VIDEO ---------------- +# ---------------- AUDIO HANDLER (остается без изменений) ---------------- @dp.callback_query(F.data == "audio") async def handle_audio(callback: CallbackQuery):