diff --git a/bot/cache.py b/bot/cache.py index 285f673..0fbdc93 100644 --- a/bot/cache.py +++ b/bot/cache.py @@ -1,17 +1,7 @@ -import hashlib - - -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() - - +# cache.py +import os 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) + 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 52d9f46..7a9eead 100644 --- a/bot/downloader.py +++ b/bot/downloader.py @@ -4,7 +4,8 @@ import threading import os import logging import shutil -from typing import List, Optional +import subprocess +from typing import List from config import ( DEFAULT_TIKTOK_VIDEO_QUALITY, @@ -14,24 +15,114 @@ from config import ( logger = logging.getLogger(__name__) - class DownloadCancelled(Exception): pass - def _progress_hook(cancel_event, progress_cb): def hook(d): if cancel_event.is_set(): raise DownloadCancelled("Cancelled by user") - if d["status"] in ["downloading", "finished"] and progress_cb: progress_cb(d) - return hook +# ---------------- TIKTOK: Видео + Аудио (автоматически) -------------- +def download_tiktok_video_and_audio( + url: str, + video_path: str, + audio_path: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, +): + # 1. Скачиваем видео (1080p) — УБРАН skip_impersonation + video_opts = { + "format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best", + "outtmpl": video_path.replace('.mp4', ''), + "merge_output_format": "mp4", + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "quiet": True, + "no_warnings": False, # ← ВКЛЮЧИЛ предупреждения для отладки + "concurrent_fragment_downloads": 4, + "http_chunk_size": 10485760, + } -# ---------------- STANDARD VIDEO (YouTube/VK) -------------- + with yt_dlp.YoutubeDL(video_opts) as ydl: + ydl.download([url]) + if not os.path.exists(video_path): + raise Exception("TikTok video file not created") + + # 2. Извлекаем аудио (MP3 192kbps) — УБРАН skip_impersonation + audio_opts = { + "format": "bestaudio/best", + "outtmpl": audio_path.replace('.mp3', ''), + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY, + }], + "quiet": True, + "no_warnings": False, # ← ВКЛЮЧИЛ для отладки + "concurrent_fragment_downloads": 4, + "http_chunk_size": 10485760, + } + + with yt_dlp.YoutubeDL(audio_opts) as ydl: + ydl.download([url]) + + if not os.path.exists(audio_path): + raise Exception("TikTok audio file not created") + +# ---------------- INSTAGRAM: Видео в 1080p (автоматически) -------------- +def download_instagram_video( + url: str, + out_path: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, +): + # УБРАН skip_impersonation + ydl_opts = { + "format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}]+bestaudio/best", + "outtmpl": out_path.replace('.mp4', ''), + "merge_output_format": "mp4", + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "quiet": True, + "no_warnings": False, # ← ВКЛЮЧИЛ для отладки + "concurrent_fragment_downloads": 4, + "http_chunk_size": 10485760, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + +# ---------------- AUDIO (YouTube/VK) -------------- +def download_audio( + url: str, + out_path: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, +): + ydl_opts = { + "format": "bestaudio/best", + "outtmpl": out_path.replace('.mp3', ''), + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}], + "quiet": True, + "no_warnings": True, + "concurrent_fragment_downloads": 4, + "http_chunk_size": 10485760, + } + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + +# ---------------- VIDEO (YouTube/VK — с выбором качества) -------------- def download_video( url: str, quality: str, @@ -44,178 +135,17 @@ def download_video( "format": f"best[height<={quality}]/best", "outtmpl": out_path, "merge_output_format": "mp4", - "cookiefile": cookies, + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, "no_warnings": True, - "extractor_args": { - "tiktok": {"skip_impersonation": True}, - "instagram": {"skip_impersonation": True}, - }, "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) - -# ---------------- TikTok: Видео + Аудио (автоматически) -------------- - -def download_tiktok_video_and_audio( - url: str, - video_path: str, - audio_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - """ - Скачивает TikTok: видео (1080p) + аудио (MP3 192kbps). - Возвращает (video_path, audio_path) или None. - """ - # 1. Скачиваем видео в 1080p - video_opts = { - "format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best", - "outtmpl": video_path.replace('.mp4', ''), - "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}}, - "concurrent_fragment_downloads": 4, - "http_chunk_size": 10485760, - } - - with yt_dlp.YoutubeDL(video_opts) as ydl: - ydl.download([url]) - - # Проверяем, что видео создано - if not os.path.exists(video_path): - raise Exception("TikTok video file not created") - - # 2. Извлекаем аудио в MP3 - audio_opts = { - "format": "bestaudio/best", - "outtmpl": audio_path.replace('.mp3', ''), - "cookiefile": cookies, - "postprocessors": [{ - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY, - }], - "quiet": True, - "no_warnings": True, - "extractor_args": {"tiktok": {"skip_impersonation": True}}, - "concurrent_fragment_downloads": 4, - "http_chunk_size": 10485760, - } - - with yt_dlp.YoutubeDL(audio_opts) as ydl: - ydl.download([url]) - - if not os.path.exists(audio_path): - raise Exception("TikTok audio file not created") - - return video_path, audio_path - - -# ---------------- Instagram: Видео в 1080p (автоматически) -------------- - -def download_instagram_video( - url: str, - out_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - """Instagram Reels: только видео в 1080p""" - ydl_opts = { - "format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}]+bestaudio/best", - "outtmpl": out_path.replace('.mp4', ''), - "merge_output_format": "mp4", - "cookiefile": cookies, - "progress_hooks": [_progress_hook(cancel_event, progress_cb)], - "quiet": True, - "no_warnings": True, - "extractor_args": {"instagram": {"skip_impersonation": True}}, - "concurrent_fragment_downloads": 4, - "http_chunk_size": 10485760, - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - - -# ---------------- AUDIO (для YouTube) -------------- - -def download_audio( - url: str, - out_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - ydl_opts = { - "format": "bestaudio/best", - "outtmpl": out_path.replace('.mp3', ''), - "cookiefile": cookies, - "progress_hooks": [_progress_hook(cancel_event, progress_cb)], - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": "192", - }, - ], - "quiet": True, - "no_warnings": True, - "extractor_args": { - "tiktok": {"skip_impersonation": True}, - "instagram": {"skip_impersonation": True}, - }, - "concurrent_fragment_downloads": 4, - "http_chunk_size": 10485760, - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - - -# ---------------- ORIGINAL QUALITY (YouTube) -------------- - -def download_original_quality( - url: str, - out_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - """Скачивает видео в оригинальном качестве (YouTube)""" - 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}, - }, - "format_sort": ["quality", "res", "codec", "size"], - "concurrent_fragment_downloads": 4, - "http_chunk_size": 10485760, - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - - # ---------------- PLAYLIST (осталось без изменений) -------------- - def download_playlist_videos( playlist_info: dict, output_dir: str, @@ -224,12 +154,11 @@ def download_playlist_videos( progress_cb, ) -> List[str]: downloaded_files = [] - ydl_opts = { "format": "best[height<=1080]/best", "outtmpl": os.path.join(output_dir, "%(title)s [%(id)s].%(ext)s"), "merge_output_format": "mp4", - "cookiefile": cookies, + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, "no_warnings": True, @@ -239,99 +168,45 @@ def download_playlist_videos( "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } - try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: entries = playlist_info.get('entries', []) - total_videos = len(entries) - - logger.info(f"Starting playlist download with {total_videos} videos") - + total = len(entries) + logger.info(f"Downloading playlist: {total} videos") for i, entry in enumerate(entries, 1): if cancel_event.is_set(): raise DownloadCancelled("Cancelled by user") - if not entry.get('url'): - logger.warning(f"Entry {i} has no URL, skipping") + logger.warning(f"Entry {i} has no URL") continue - - video_url = entry['url'] - video_title = entry.get('title', f'Video {i}') - - logger.info(f"Downloading video {i}/{total_videos}: {video_title}") - try: - info = ydl.extract_info(video_url, download=True) - - if not info: - logger.error(f"Failed to extract info for video {i}") - continue - - filename = ydl.prepare_filename(info) - - if os.path.exists(filename): - downloaded_files.append(filename) - else: - base_name = filename.rsplit('.', 1)[0] - for ext in ['.mp4', '.mkv', '.webm', '.flv']: - alt_path = base_name + ext - if os.path.exists(alt_path): - downloaded_files.append(alt_path) - break - else: - logger.error(f"File not found for video {i}") - + info = ydl.extract_info(entry['url'], download=True) + if info and os.path.exists(ydl.prepare_filename(info)): + downloaded_files.append(ydl.prepare_filename(info)) except Exception as e: - logger.error(f"Error downloading video {i} ({video_title}): {e}") + logger.error(f"Error downloading video {i}: {e}") continue - - logger.info(f"Playlist download complete. Downloaded {len(downloaded_files)} files") return downloaded_files - except Exception as e: - logger.error(f"Error downloading playlist: {e}") + logger.error(f"Playlist download error: {e}") raise - # ---------------- METADATA (осталось без изменений) -------------- - -def add_metadata_to_audio( - input_path: str, - output_path: str, - metadata: dict, -): +def add_metadata_to_audio(input_path: str, output_path: str, metadata: dict): if metadata is None: metadata = {} - if not input_path.lower().endswith('.mp3'): shutil.copy2(input_path, output_path) return - metadata_args = [] - if metadata.get('title'): - metadata_args.extend(['-metadata', f'title={metadata["title"][:100]}']) - if metadata.get('artist'): - metadata_args.extend(['-metadata', f'artist={metadata["artist"][:100]}']) - if metadata.get('album'): - metadata_args.extend(['-metadata', f'album={metadata["album"][:100]}']) - + for key in ['title', 'artist', 'album']: + if metadata.get(key): + metadata_args.extend(['-metadata', f'{key}={str(metadata[key])[:100]}']) cmd = [ - 'ffmpeg', - '-i', input_path, - '-c', 'copy', - '-id3v2_version', '3', - '-loglevel', 'error', - '-y', - *metadata_args, - output_path + 'ffmpeg', '-i', input_path, '-c', 'copy', + '-id3v2_version', '3', '-loglevel', 'error', '-y', + *metadata_args, output_path ] - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 - ) - + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: shutil.copy2(input_path, output_path) \ No newline at end of file diff --git a/bot/main.py b/bot/main.py index 555a929..98dc2ca 100644 --- a/bot/main.py +++ b/bot/main.py @@ -3,13 +3,12 @@ import os import asyncio import threading 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, InputMediaDocument +from aiogram.types import Message, CallbackQuery, FSInputFile from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.telegram import TelegramAPIServer @@ -19,7 +18,6 @@ from config import ( CACHE_DIR, TMP_DIR, COOKIES_FILE, - RATE_LIMIT_SECONDS, CACHE_MAX_AGE_DAYS, CACHE_MAX_SIZE_MB, ) @@ -29,25 +27,19 @@ from downloader import ( download_instagram_video, 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, is_playlist, get_platform_info -from cache import cache_key, cache_path +from cache import cache_path from cleanup import cleanup_tmp -# Настройка логирования logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# Создаем директории -os.makedirs(CACHE_DIR, exist_ok=True) -os.makedirs(TMP_DIR, exist_ok=True) - -# Инициализация бота +# Инициализация if LOCAL_API_URL: api_server = TelegramAPIServer.from_base(LOCAL_API_URL) session = AiohttpSession(api=api_server) @@ -56,8 +48,6 @@ else: bot = Bot(token=BOT_TOKEN) dp = Dispatcher() - -# Регистрация middleware private_middleware = PrivateMiddleware() dp.message.middleware(private_middleware) dp.callback_query.middleware(private_middleware) @@ -66,14 +56,11 @@ USER_URLS: dict[int, str] = {} USER_DATA: dict[int, dict] = {} ACTIVE_DOWNLOADS: dict[int, dict] = {} - -# -------------------- HELPERS -------------- - +# Вспомогательные функции def render_bar(percent: float, size: int = 10) -> str: filled = int(size * percent / 100) return "█" * filled + "░" * (size - filled) - def make_progress_cb(loop, message): last_percent = {"value": 0} last_update = {"time": 0} @@ -84,51 +71,40 @@ def make_progress_cb(loop, message): total = d.get("total_bytes") or d.get("total_bytes_estimate") or 1 if total <= 0: return - percent = min(100, downloaded * 100 / total) current_time = time.time() if percent - last_percent["value"] < 2 and current_time - last_update["time"] < 2: return - last_percent["value"] = percent last_update["time"] = current_time - - bar = render_bar(percent) eta = d.get("eta") eta_str = str(int(float(eta))) if eta and eta != "?" else "?" - - text = ( - "⏬ Загрузка\n" - f"{bar} {percent:.0f}%\n" - f"⏱ Осталось: {eta_str} сек" - ) - - await message.edit_text( - text, - reply_markup=cancel_keyboard() if "playlist" not in message.text.lower() else None, - parse_mode="HTML" - ) + bar = render_bar(percent) + text = f"⏬ Загрузка\n{bar} {percent:.0f}%\n⏱ Осталось: {eta_str} сек" + await message.edit_text(text, reply_markup=cancel_keyboard() if "playlist" in message.text.lower() else None, parse_mode="HTML") except Exception as e: - logger.error(f"Error updating progress: {e}") + logger.error(f"Progress error: {e}") def cb(d): asyncio.run_coroutine_threadsafe(update(d), loop) - return cb +# ---------------------- AUTO DOWNLOADS (TikTok/Instagram) ---------------------- async def process_tiktok_auto(message: Message, user_id: int, url: str): - """Автоматически загружает TikTok: видео (1080p) + аудио (MP3)""" status = await message.answer("🎵 Загружаю TikTok (видео + аудио)...", parse_mode="HTML") key_video = cache_key(url, "tiktok_video", audio=False) key_audio = cache_key(url, "tiktok_audio", audio=True) + # ✅ ИСПРАВЛЕНО: создаём подкаталоги в TMP video_cache = cache_path(CACHE_DIR, key_video, "mp4") - audio_cache = cache_path(CACHE_DIR, key_audio, "mp3") + tmp_video = cache_path(TMP_DIR, key_video, "mp4") + os.makedirs(os.path.dirname(tmp_video), exist_ok=True) - tmp_video = os.path.join(TMP_DIR, f"{key_video}.mp4") - tmp_audio = os.path.join(TMP_DIR, f"{key_audio}.mp3") + audio_cache = cache_path(CACHE_DIR, key_audio, "mp3") + tmp_audio = cache_path(TMP_DIR, key_audio, "mp3") + os.makedirs(os.path.dirname(tmp_audio), exist_ok=True) # Проверка кэша if os.path.exists(video_cache) and os.path.exists(audio_cache): @@ -168,14 +144,14 @@ async def process_tiktok_auto(message: Message, user_id: int, url: str): if os.path.exists(p): os.remove(p) return - # Перемещаем в кэш - os.makedirs(os.path.dirname(video_cache), exist_ok=True) + # Перемещение в кэш for path in [video_cache, audio_cache]: if os.path.exists(path): os.remove(path) + + os.makedirs(os.path.dirname(video_cache), exist_ok=True) os.rename(tmp_video, video_cache) os.rename(tmp_audio, audio_cache) - # Отправка await status.edit_text("📤 Отправляю видео + аудио...", parse_mode="HTML") await message.answer_video(FSInputFile(video_cache), supports_streaming=True) await message.answer_audio(FSInputFile(audio_cache)) @@ -189,14 +165,13 @@ async def process_tiktok_auto(message: Message, user_id: int, url: str): ACTIVE_DOWNLOADS.pop(user_id, None) cleanup_tmp(TMP_DIR) - async def process_instagram_auto(message: Message, user_id: int, url: str): - """Автоматически загружает Instagram: видео в 1080p""" status = await message.answer("📸 Загружаю Instagram (1080p)...", parse_mode="HTML") key = cache_key(url, "instagram_video", audio=False) final_cache = cache_path(CACHE_DIR, key, "mp4") - tmp_path = os.path.join(TMP_DIR, f"{key}.mp4") + tmp_path = cache_path(TMP_DIR, key, "mp4") + os.makedirs(os.path.dirname(tmp_path), exist_ok=True) # Проверка кэша if os.path.exists(final_cache): @@ -232,9 +207,9 @@ async def process_instagram_auto(message: Message, user_id: int, url: str): if os.path.exists(tmp_path): os.remove(tmp_path) return - # Перемещаем в кэш - os.makedirs(os.path.dirname(final_cache), exist_ok=True) + # Перемещение в кэш if os.path.exists(final_cache): os.remove(final_cache) + os.makedirs(os.path.dirname(final_cache), exist_ok=True) os.rename(tmp_path, final_cache) await status.edit_text("📤 Отправляю видео...", parse_mode="HTML") @@ -249,32 +224,28 @@ async def process_instagram_auto(message: Message, user_id: int, url: str): ACTIVE_DOWNLOADS.pop(user_id, None) cleanup_tmp(TMP_DIR) - -# -------------------- HANDLERS -------------- +# ---------------------- HANDLERS ---------------------- @dp.message(F.text == "/start") async def start(message: Message): await message.answer( "👋 Привет!\n\n" - "📥 Я скачиваю видео и звук из видео по ссылке.\n\n" + "📥 Скачиваю видео и аудио по ссылке.\n\n" "✨ Новые возможности:\n" "• 🎬 TikTok: автоматически видео + аудио\n" "• 📸 Instagram: автоматически видео в 1080p\n" - "• 🎵 Отдельный звук из видео\n" + "• 🎵 Аудио из любого видео\n" "• 📁 Плейлисты YouTube\n" - "• 🔄 Автоочистка кэша\n\n" "👉 Просто отправь ссылку.", parse_mode="HTML" ) - @dp.message(F.text.startswith("http")) async def handle_link(message: Message): url = message.text.strip() user_id = message.from_user.id USER_URLS[user_id] = url - # Определяем платформу platform_info = await asyncio.to_thread(get_platform_info, url) # TikTok — автоматически @@ -304,8 +275,7 @@ async def handle_link(message: Message): parse_mode="HTML" ) - -# ------------------------ YouTube/VK handlers ------------------------ +# ---------------------- YOUTUBE/VK HANDLERS ---------------------- @dp.callback_query(F.data.startswith("q:")) async def handle_video(callback: CallbackQuery): @@ -323,19 +293,16 @@ async def handle_video(callback: CallbackQuery): key = cache_key(url, quality, audio=False) final_path = cache_path(CACHE_DIR, key, "mp4") - tmp_path = os.path.join(TMP_DIR, f"{key}.mp4") - optimized_path = os.path.join(TMP_DIR, f"{key}_optimized.mp4") + tmp_path = cache_path(TMP_DIR, key, "mp4") + os.makedirs(os.path.dirname(tmp_path), exist_ok=True) - # Проверяем кэш + # Проверка кэша 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" - ) + 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("❌ Ошибка при отправке файла") @@ -358,31 +325,22 @@ async def handle_video(callback: CallbackQuery): ) if cancel_event.is_set(): - for p in [tmp_path, optimized_path]: - if os.path.exists(p): os.remove(p) + if os.path.exists(tmp_path): os.remove(tmp_path) await status.edit_text("⛔ Загрузка отменена") return - # Оптимизируем видео для Telegram (кроме оригинального качества) - if quality != "original": - await status.edit_text("⚙️ Оптимизирую видео для телеграма…", parse_mode="HTML") - await asyncio.to_thread(optimize_for_telegram, tmp_path, optimized_path) - for p in [tmp_path, optimized_path]: - if os.path.exists(p): os.rename(p, optimized_path) - else: - os.rename(tmp_path, final_path) - - os.rename(optimized_path, final_path) + # ✅ ИСПРАВЛЕНО: просто перемещаем без оптимизации + if os.path.exists(final_path): os.remove(final_path) + os.makedirs(os.path.dirname(final_path), exist_ok=True) + os.rename(tmp_path, final_path) except DownloadCancelled: - for p in [tmp_path, optimized_path]: - if os.path.exists(p): os.remove(p) + if os.path.exists(tmp_path): os.remove(tmp_path) await status.edit_text("⛔ Загрузка отменена") return except Exception as e: logger.error(f"Error downloading video: {e}") - for p in [tmp_path, optimized_path]: - if os.path.exists(p): os.remove(p) + if os.path.exists(tmp_path): os.remove(tmp_path) await status.edit_text("❌ Не удалось скачать видео") return finally: @@ -391,24 +349,17 @@ async def handle_video(callback: CallbackQuery): 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" - ) + 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" - ) + 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("❌ Ошибка при отправке файла") - @dp.callback_query(F.data == "audio") async def handle_audio(callback: CallbackQuery): await callback.answer() @@ -424,22 +375,16 @@ async def handle_audio(callback: CallbackQuery): key = cache_key(url, "audio", audio=True) final_path = cache_path(CACHE_DIR, key, "mp3") - tmp_path = os.path.join(TMP_DIR, f"{key}.mp3") + tmp_path = cache_path(TMP_DIR, key, "mp3") + os.makedirs(os.path.dirname(tmp_path), exist_ok=True) - # Создаем директории - os.makedirs(TMP_DIR, exist_ok=True) - os.makedirs(os.path.dirname(final_path), exist_ok=True) - - # Проверяем кэш + # Проверка кэша if os.path.exists(final_path): await status.edit_text("📤 Отправляю аудио из кэша…", parse_mode="HTML") try: await callback.message.answer_audio(FSInputFile(final_path)) size_mb = os.path.getsize(final_path) / (1024 * 1024) - await callback.message.answer( - f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", - parse_mode="HTML" - ) + await callback.message.answer(f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML") except Exception as e: logger.error(f"Error sending cached audio: {e}") await status.edit_text("❌ Ошибка при отправке аудио") @@ -467,32 +412,10 @@ async def handle_audio(callback: CallbackQuery): if not os.path.exists(tmp_path): raise Exception("Аудио файл не был создан") - - file_size = os.path.getsize(tmp_path) - if file_size == 0: + if os.path.getsize(tmp_path) == 0: os.remove(tmp_path) raise Exception("Создан пустой аудио файл") - # Добавляем метаданные - video_info = None - try: - video_info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) - except: - pass - - if video_info: - metadata = { - 'title': video_info.get('title', ''), - 'artist': video_info.get('uploader', ''), - 'album': video_info.get('title', '')[:50], - } - await asyncio.to_thread( - lambda: add_metadata_to_audio(tmp_path, tmp_path + "_meta.mp3", metadata) - ) - if os.path.exists(tmp_path + "_meta.mp3"): - os.remove(tmp_path) - os.rename(tmp_path + "_meta.mp3", tmp_path) - os.rename(tmp_path, final_path) except DownloadCancelled: @@ -508,47 +431,35 @@ async def handle_audio(callback: CallbackQuery): ACTIVE_DOWNLOADS.pop(user_id, None) await status.edit_text("📤 Отправляю аудио…", parse_mode="HTML") - try: await callback.message.answer_audio(FSInputFile(final_path)) size_mb = os.path.getsize(final_path) / (1024 * 1024) - await callback.message.answer( - f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", - parse_mode="HTML" - ) + await callback.message.answer(f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML") except Exception as e: logger.error(f"Error sending audio: {e}") await status.edit_text("❌ Ошибка при отправке аудио") - -# -------------------- PLAYLIST HANDLERS -------------- - +# ---------------------- 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 - 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" @@ -560,55 +471,43 @@ async def handle_playlist_all(callback: CallbackQuery): ) 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): + import uuid + import shutil + 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_progress_cb(loop, status) + playlist_dir = os.path.join(TMP_DIR, f"playlist_{uuid.uuid4().hex[:8]}") + os.makedirs(playlist_dir, exist_ok=True) try: - import uuid - import shutil - - 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_progress_cb(loop, status) - - 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, @@ -617,26 +516,20 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status): cancel_event, progress_cb ) - if cancel_event.is_set(): await status.edit_text("⛔ Загрузка плейлиста отменена") shutil.rmtree(playlist_dir, ignore_errors=True) return - if not downloaded_files: await status.edit_text("❌ Не удалось загрузить видео из плейлиста") shutil.rmtree(playlist_dir, ignore_errors=True) return - await status.edit_text(f"📤 Отправляю {len(downloaded_files)} видео…", parse_mode="HTML") - downloaded_files.sort(key=lambda x: os.path.getsize(x)) sent_count = 0 - for i, file_path in enumerate(downloaded_files, 1): if cancel_event.is_set(): break - try: file_name = os.path.basename(file_path) display_name = os.path.splitext(file_name)[0] @@ -649,10 +542,8 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status): except Exception as e: logger.error(f"Error sending file {file_path}: {e}") continue - 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"📁 Видео в плейлисте: {video_count}\n" @@ -661,9 +552,7 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status): f"🎬 Название: {playlist_title}", parse_mode="HTML" ) - shutil.rmtree(playlist_dir, ignore_errors=True) - except DownloadCancelled: await status.edit_text("⛔ Загрузка плейлиста отменена") except Exception as e: @@ -673,30 +562,24 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status): 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) - @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( @@ -704,17 +587,14 @@ async def handle_playlist_first(callback: CallbackQuery): reply_markup=youtube_quality_keyboard(), parse_mode="HTML" ) - except Exception as e: logger.error(f"Error getting first video: {e}") await callback.message.answer("❌ Ошибка при получении видео из плейлиста") - @dp.callback_query(F.data == "cancel") async def cancel_download(callback: CallbackQuery): user_id = callback.from_user.id data = ACTIVE_DOWNLOADS.get(user_id) - if data: data["cancel"].set() ACTIVE_DOWNLOADS.pop(user_id, None) @@ -722,19 +602,14 @@ async def cancel_download(callback: CallbackQuery): else: await callback.answer("❌ Нет активной загрузки", show_alert=True) - -# -------------------- ENTRYPOINT -------------- - +# ---------------------- ENTRYPOINT ---------------------- async def main(): - # Очистка временных файлов при старте cleanup_tmp(TMP_DIR) - try: await dp.start_polling(bot) except KeyboardInterrupt: logger.info("Bot stopped by user") - if __name__ == "__main__": try: asyncio.run(main())