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):