diff --git a/.DS_Store b/.DS_Store index fec1682..46d3529 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/bot/config.py b/bot/config.py index 4d7df84..e1719ae 100644 --- a/bot/config.py +++ b/bot/config.py @@ -1,3 +1,4 @@ +# config.py import os from dotenv import load_dotenv @@ -19,8 +20,14 @@ COOKIES_FILE = os.getenv("COOKIES_FILE") MAX_DURATION_SECONDS = int(os.getenv("MAX_DURATION_SECONDS", 1800)) RATE_LIMIT_SECONDS = int(os.getenv("RATE_LIMIT_SECONDS", 20)) MAX_FILE_SIZE_MB = 2000 -CACHE_MAX_AGE_DAYS = 7 # Удалять файлы старше 7 дней -CACHE_MAX_SIZE_MB = 4096 # Максимальный размер кэша 1 ГБ (0 = без ограничения) +CACHE_MAX_AGE_DAYS = 7 +CACHE_MAX_SIZE_MB = 4096 + +# Авто-качества для TikTok и Instagram (без выбора) +DEFAULT_TIKTOK_VIDEO_QUALITY = "1080" +DEFAULT_TIKTOK_AUDIO_QUALITY = "192" # кбит/с +DEFAULT_INSTAGRAM_VIDEO_QUALITY = "1080" + # Private mode ALLOWED_USERS = set( int(uid.strip()) @@ -30,4 +37,4 @@ ALLOWED_USERS = set( # Ensure directories exist os.makedirs(CACHE_DIR, exist_ok=True) -os.makedirs(TMP_DIR, exist_ok=True) +os.makedirs(TMP_DIR, exist_ok=True) \ No newline at end of file diff --git a/bot/downloader.py b/bot/downloader.py index 7510fe8..52d9f46 100644 --- a/bot/downloader.py +++ b/bot/downloader.py @@ -1,12 +1,17 @@ +# downloader.py import yt_dlp import threading import os import logging -import subprocess -import json import shutil from typing import List, Optional +from config import ( + DEFAULT_TIKTOK_VIDEO_QUALITY, + DEFAULT_TIKTOK_AUDIO_QUALITY, + DEFAULT_INSTAGRAM_VIDEO_QUALITY, +) + logger = logging.getLogger(__name__) @@ -25,7 +30,7 @@ def _progress_hook(cancel_event, progress_cb): return hook -# ---------------- STANDARD VIDEO ---------------- +# ---------------- STANDARD VIDEO (YouTube/VK) -------------- def download_video( url: str, @@ -55,64 +60,86 @@ def download_video( ydl.download([url]) -# ---------------- ORIGINAL QUALITY ---------------- +# ---------------- TikTok: Видео + Аудио (автоматически) -------------- -def download_original_quality( +def download_tiktok_video_and_audio( url: str, - out_path: str, + video_path: str, + audio_path: str, cookies: str | None, cancel_event: threading.Event, progress_cb, ): - """Скачивает видео в оригинальном качестве""" - ydl_opts = { - "format": "best", - "outtmpl": out_path, + """ + Скачивает 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}, - "instagram": {"skip_impersonation": True}, - }, - "format_sort": ["quality", "res", "codec", "size"], + "extractor_args": {"tiktok": {"skip_impersonation": True}}, "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: + 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") -# ---------------- TIKTOK MUSIC ONLY ---------------- - -def download_tiktok_music( - url: str, - out_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - """Скачивает только звук из TikTok""" - ydl_opts = { + # 2. Извлекаем аудио в MP3 + audio_opts = { "format": "bestaudio/best", - "outtmpl": out_path.replace('.mp3', ''), + "outtmpl": audio_path.replace('.mp3', ''), "cookiefile": cookies, - "progress_hooks": [_progress_hook(cancel_event, progress_cb)], - "postprocessors": [ - { - "key": "FFmpegExtractAudio", - "preferredcodec": "mp3", - "preferredquality": "192", - }, - ], + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY, + }], "quiet": True, "no_warnings": True, - "extractor_args": { - "tiktok": {"skip_impersonation": 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, } @@ -121,148 +148,7 @@ def download_tiktok_music( ydl.download([url]) -# ---------------- ADD METADATA TO AUDIO ---------------- - -def add_metadata_to_audio( - input_path: str, - output_path: str, - metadata: dict, -): - """Добавляет метаданные к аудио файлу""" - try: - # Проверяем metadata - 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]}']) - - cmd = [ - '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 - ) - - if result.returncode != 0: - shutil.copy2(input_path, output_path) - - except Exception as e: - logger.error(f"Metadata error: {e}") - shutil.copy2(input_path, output_path) - - -# ---------------- 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 [%(id)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, - "nooverwrites": True, - "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") - - 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") - 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) - logger.info(f"Successfully downloaded: {os.path.basename(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) - logger.info(f"Found file with extension {ext}: {os.path.basename(alt_path)}") - break - else: - logger.error(f"File not found for video {i}") - - except Exception as e: - logger.error(f"Error downloading video {i} ({video_title}): {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}") - raise - - -# ---------------- STANDARD AUDIO ---------------- +# ---------------- AUDIO (для YouTube) -------------- def download_audio( url: str, @@ -294,4 +180,158 @@ def download_audio( } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) \ No newline at end of file + 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, + 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 [%(id)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, + "nooverwrites": True, + "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") + + 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") + 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}") + + except Exception as e: + logger.error(f"Error downloading video {i} ({video_title}): {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}") + raise + + +# ---------------- METADATA (осталось без изменений) -------------- + +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]}']) + + cmd = [ + '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 + ) + + if result.returncode != 0: + shutil.copy2(input_path, output_path) \ No newline at end of file diff --git a/bot/keyboards.py b/bot/keyboards.py index 6a3274c..356b70e 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -1,14 +1,16 @@ +# keyboards.py from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -def quality_keyboard(): - """Клавиатура выбора качества""" +def youtube_quality_keyboard(): + """YouTube/VK: выбор качества""" keyboard = [ [ InlineKeyboardButton(text="📹 480p", callback_data="q:480"), 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"), @@ -19,7 +21,7 @@ def quality_keyboard(): def cancel_keyboard(): - """Клавиатура с кнопкой отмены""" + """Клавиатура с кнопкой отмены (для плейлистов)""" keyboard = [ [InlineKeyboardButton(text="⛔ Отменить загрузку", callback_data="cancel")] ] @@ -27,7 +29,7 @@ def cancel_keyboard(): def playlist_keyboard(confirm=False): - """Клавиатура для плейлистов""" + """Клавиатура для плейлистов YouTube""" if confirm: keyboard = [ [ @@ -45,45 +47,4 @@ def playlist_keyboard(confirm=False): InlineKeyboardButton(text="❌ Отмена", callback_data="cancel"), ] ] - return InlineKeyboardMarkup(inline_keyboard=keyboard) - - -def platform_keyboard(platform): - """Клавиатура для Instagram""" - keyboard = [ - [ - InlineKeyboardButton(text=f"🎬 Оригинальное качество", callback_data="original_quality"), - ], - [ - InlineKeyboardButton(text="📹 480p", callback_data="q:480"), - 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) - - -def tiktok_keyboard(): - """Специальная клавиатура для TikTok""" - keyboard = [ - [ - InlineKeyboardButton(text="🎬 Оригинальное качество", callback_data="original_quality"), - ], - [ - InlineKeyboardButton(text="📹 480p", callback_data="q:480"), - InlineKeyboardButton(text="📹 720p", callback_data="q:720"), - InlineKeyboardButton(text="🎬 1080p", callback_data="q:1080"), - ], - [ - InlineKeyboardButton(text="🎧 Аудио из видео", callback_data="audio"), - InlineKeyboardButton(text="🎵 Только звук (TikTok)", callback_data="tiktok_music"), - ], - [ - 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 4575f2b..555a929 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,3 +1,4 @@ +# main.py import os import asyncio import threading @@ -22,20 +23,14 @@ from config import ( CACHE_MAX_AGE_DAYS, CACHE_MAX_SIZE_MB, ) -from keyboards import ( - quality_keyboard, - cancel_keyboard, - playlist_keyboard, - platform_keyboard, - tiktok_keyboard, -) +from keyboards import youtube_quality_keyboard, cancel_keyboard, playlist_keyboard from downloader import ( - download_video, - download_audio, + download_tiktok_video_and_audio, + download_instagram_video, + download_video, + download_audio, download_original_quality, download_playlist_videos, - download_tiktok_music, - add_metadata_to_audio, # Только для аудио DownloadCancelled, ) from middleware import PrivateMiddleware @@ -44,8 +39,6 @@ from info import extract_info, is_playlist, get_platform_info from cache import cache_key, cache_path from cleanup import cleanup_tmp -# -------------------- init -------------------- - # Настройка логирования logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -54,7 +47,7 @@ logger = logging.getLogger(__name__) os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(TMP_DIR, exist_ok=True) -# Инициализация бота с локальным API (если используется) +# Инициализация бота if LOCAL_API_URL: api_server = TelegramAPIServer.from_base(LOCAL_API_URL) session = AiohttpSession(api=api_server) @@ -73,120 +66,8 @@ USER_URLS: dict[int, str] = {} USER_DATA: dict[int, dict] = {} ACTIVE_DOWNLOADS: dict[int, dict] = {} -# -------------------- cache cleaning -------------------- -def cleanup_old_cache(): - """ - Очищает старые файлы из кэша - """ - try: - current_time = time.time() - deleted_count = 0 - deleted_size = 0 - - # Удаляем файлы старше CACHE_MAX_AGE_DAYS дней - if CACHE_MAX_AGE_DAYS > 0: - cutoff_time = current_time - (CACHE_MAX_AGE_DAYS * 24 * 3600) - - for root, dirs, files in os.walk(CACHE_DIR): - for file in files: - file_path = os.path.join(root, file) - try: - file_mtime = os.path.getmtime(file_path) - if file_mtime < cutoff_time: - file_size = os.path.getsize(file_path) - os.remove(file_path) - deleted_count += 1 - deleted_size += file_size - logger.info(f"Deleted old cache file: {file}") - except Exception as e: - logger.error(f"Error deleting file {file}: {e}") - - # Если указан максимальный размер кэша, проверяем его - if CACHE_MAX_SIZE_MB > 0: - total_size_mb = get_cache_size_mb() - if total_size_mb > CACHE_MAX_SIZE_MB: - # Сортируем файлы по времени изменения (старые первыми) - files_with_mtime = [] - for root, dirs, files in os.walk(CACHE_DIR): - for file in files: - file_path = os.path.join(root, file) - try: - mtime = os.path.getmtime(file_path) - size = os.path.getsize(file_path) - files_with_mtime.append((file_path, mtime, size)) - except: - pass - - # Сортируем по времени (старые первыми) - files_with_mtime.sort(key=lambda x: x[1]) - - # Удаляем старые файлы пока не достигнем лимита - target_size_mb = CACHE_MAX_SIZE_MB * 0.8 - - for file_path, mtime, size in files_with_mtime: - if total_size_mb <= target_size_mb: - break - - try: - os.remove(file_path) - deleted_count += 1 - deleted_size += size - total_size_mb -= size / (1024 * 1024) - logger.info(f"Deleted cache file to free space: {os.path.basename(file_path)}") - except Exception as e: - logger.error(f"Error deleting file {file_path}: {e}") - - if deleted_count > 0: - logger.info(f"Cache cleanup: deleted {deleted_count} files, freed {deleted_size / (1024*1024):.2f} MB") - else: - logger.info("Cache cleanup: no files to delete") - - except Exception as e: - logger.error(f"Error in cache cleanup: {e}") - - -def get_cache_size_mb(): - """Возвращает размер кэша в МБ""" - total_size = 0 - for root, dirs, files in os.walk(CACHE_DIR): - for file in files: - file_path = os.path.join(root, file) - try: - total_size += os.path.getsize(file_path) - except: - pass - return total_size / (1024 * 1024) - - -async def scheduled_cache_cleanup(): - """Периодическая очистка кэша""" - # Запускаем очистку сразу при старте - logger.info("Running initial cache cleanup...") - await asyncio.to_thread(cleanup_old_cache) - - last_cleanup_day = datetime.now().day - - while True: - try: - now = datetime.now() - current_day = now.day - - # Запускаем очистку если наступил новый день И сейчас между 3:00 и 3:59 - if current_day != last_cleanup_day and now.hour == 3: - logger.info("Starting scheduled cache cleanup...") - await asyncio.to_thread(cleanup_old_cache) - last_cleanup_day = current_day - - # Ждем 5 минут перед следующей проверкой - await asyncio.sleep(300) - - except Exception as e: - logger.error(f"Error in scheduled cleanup: {e}") - await asyncio.sleep(60) - - -# -------------------- helpers -------------------- +# -------------------- HELPERS -------------- def render_bar(percent: float, size: int = 10) -> str: filled = int(size * percent / 100) @@ -201,29 +82,20 @@ def make_progress_cb(loop, message): try: downloaded = d.get("downloaded_bytes", 0) total = d.get("total_bytes") or d.get("total_bytes_estimate") or 1 - if total <= 0: return - - percent = min(100, downloaded * 100 / total) + 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") - - if eta is None or eta == "?": - eta_str = "?" - else: - try: - eta_str = str(int(float(eta))) - except (ValueError, TypeError): - eta_str = "?" + eta_str = str(int(float(eta))) if eta and eta != "?" else "?" text = ( "⏬ Загрузка\n" @@ -233,7 +105,7 @@ def make_progress_cb(loop, message): await message.edit_text( text, - reply_markup=cancel_keyboard(), + reply_markup=cancel_keyboard() if "playlist" not in message.text.lower() else None, parse_mode="HTML" ) except Exception as e: @@ -245,114 +117,140 @@ 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 process_tiktok_auto(message: Message, user_id: int, url: str): + """Автоматически загружает TikTok: видео (1080p) + аудио (MP3)""" + status = await message.answer("🎵 Загружаю TikTok (видео + аудио)...", parse_mode="HTML") - async def update(d): + key_video = cache_key(url, "tiktok_video", audio=False) + key_audio = cache_key(url, "tiktok_audio", audio=True) + + video_cache = cache_path(CACHE_DIR, key_video, "mp4") + audio_cache = cache_path(CACHE_DIR, key_audio, "mp3") + + tmp_video = os.path.join(TMP_DIR, f"{key_video}.mp4") + tmp_audio = os.path.join(TMP_DIR, f"{key_audio}.mp3") + + # Проверка кэша + if os.path.exists(video_cache) and os.path.exists(audio_cache): + await status.edit_text("📤 Отправляю из кэша...", parse_mode="HTML") try: - 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" - ) + await message.answer_video(FSInputFile(video_cache), supports_streaming=True) + await message.answer_audio(FSInputFile(audio_cache)) + size_mb = (os.path.getsize(video_cache) + os.path.getsize(audio_cache)) / (1024 * 1024) + await message.answer(f"✅ Готово! (TikTok)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML") except Exception as e: - logger.error(f"Error updating playlist progress: {e}") + logger.error(f"TikTok cache error: {e}") + await status.edit_text("❌ Ошибка при отправке") + finally: + cleanup_tmp(TMP_DIR) + return - def cb(d): - asyncio.run_coroutine_threadsafe(update(d), loop) + # Загрузка + cancel_event = threading.Event() + ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} + loop = asyncio.get_running_loop() + progress_cb = make_progress_cb(loop, status) - return cb - - -def optimize_for_telegram(input_path: str, output_path: str) -> bool: - """ - Оптимизирует видео для телеграма (без метаданных) - """ try: - import shutil - - # Проверяем размер файла - file_size_mb = os.path.getsize(input_path) / (1024 * 1024) - - # Если файл больше 50 МБ, сжимаем его - if file_size_mb > 50: - crf = 28 - else: - crf = 23 - - cmd = [ - 'ffmpeg', - '-i', input_path, - '-c:v', 'libx264', - '-preset', 'fast', - '-crf', str(crf), - '-c:a', 'aac', - '-b:a', '128k', - '-movflags', '+faststart', - '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', - '-y', - output_path - ] - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=300 + await asyncio.to_thread( + download_tiktok_video_and_audio, + url, + tmp_video, + tmp_audio, + COOKIES_FILE, + cancel_event, + progress_cb, ) - - if result.returncode != 0: - logger.error(f"FFmpeg error: {result.stderr}") - shutil.copy2(input_path, output_path) - return False - - return True - + + if cancel_event.is_set(): + await status.edit_text("⛔ Загрузка отменена") + for p in [tmp_video, tmp_audio]: + 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.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)) + size_mb = (os.path.getsize(video_cache) + os.path.getsize(audio_cache)) / (1024 * 1024) + await message.answer(f"✅ Готово! (TikTok)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML") + except Exception as e: - logger.error(f"Error optimizing video: {e}") - import shutil - shutil.copy2(input_path, output_path) - return False + logger.error(f"TikTok download error: {e}") + await status.edit_text(f"❌ Ошибка: {str(e)[:120]}") + finally: + ACTIVE_DOWNLOADS.pop(user_id, None) + cleanup_tmp(TMP_DIR) -def get_video_info_for_audio(video_info: dict) -> dict: - """Получает информацию о видео для метаданных аудио""" - if not video_info: - return {} - - metadata = { - 'title': video_info.get('title', ''), - 'artist': video_info.get('uploader', ''), - 'album': video_info.get('title', '')[:50], - 'url': video_info.get('webpage_url', ''), - } - - # Для TikTok добавляем информацию о музыке - if video_info.get('extractor') == 'TikTok': - if video_info.get('track'): - metadata['title'] = video_info.get('track', metadata['title']) - if video_info.get('artist'): - metadata['artist'] = video_info.get('artist', metadata['artist']) - - return metadata +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") + + # Проверка кэша + if os.path.exists(final_cache): + await status.edit_text("📤 Отправляю из кэша...", parse_mode="HTML") + try: + await message.answer_video(FSInputFile(final_cache), supports_streaming=True) + size_mb = os.path.getsize(final_cache) / (1024 * 1024) + await message.answer(f"✅ Готово! (Instagram 1080p)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML") + except Exception as e: + logger.error(f"Instagram cache error: {e}") + await status.edit_text("❌ Ошибка при отправке") + finally: + cleanup_tmp(TMP_DIR) + 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_instagram_video, + url, + tmp_path, + COOKIES_FILE, + cancel_event, + progress_cb, + ) + + if cancel_event.is_set(): + await status.edit_text("⛔ Загрузка отменена") + 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.rename(tmp_path, final_cache) + + await status.edit_text("📤 Отправляю видео...", parse_mode="HTML") + await message.answer_video(FSInputFile(final_cache), supports_streaming=True) + size_mb = os.path.getsize(final_cache) / (1024 * 1024) + await message.answer(f"✅ Готово! (Instagram 1080p)\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML") + + except Exception as e: + logger.error(f"Instagram download error: {e}") + await status.edit_text(f"❌ Ошибка: {str(e)[:120]}") + finally: + ACTIVE_DOWNLOADS.pop(user_id, None) + cleanup_tmp(TMP_DIR) -# -------------------- handlers -------------------- +# -------------------- HANDLERS -------------- @dp.message(F.text == "/start") async def start(message: Message): @@ -360,360 +258,54 @@ async def start(message: Message): "👋 Привет!\n\n" "📥 Я скачиваю видео и звук из видео по ссылке.\n\n" "✨ Новые возможности:\n" - "• 🎬 Оригинальное качество (Instagram, TikTok)\n" + "• 🎬 TikTok: автоматически видео + аудио\n" + "• 📸 Instagram: автоматически видео в 1080p\n" + "• 🎵 Отдельный звук из видео\n" "• 📁 Плейлисты YouTube\n" - "• 🎵 Отдельный звук для TikTok\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" - "• 🎵 TikTok: можно скачать отдельно звук\n" - "• 🎬 Instagram/TikTok: оригинальное качество\n" - "• 📁 Плейлисты: загрузка всех видео\n" - "• 🎧 Аудио: извлечение звука из любого видео", - parse_mode="HTML" - ) - - -@dp.message(F.text == "/cache_stats") -async def cache_stats(message: Message): - """Показывает статистику кэша""" - try: - total_size_mb = get_cache_size_mb() - file_count = 0 - - for root, dirs, files in os.walk(CACHE_DIR): - file_count += len(files) - - await message.answer( - f"📊 Статистика кэша:\n\n" - f"📁 Файлов: {file_count}\n" - f"💾 Размер: {total_size_mb:.2f} МБ\n" - f"⏰ Очистка: ежедневно в 3:00", - parse_mode="HTML" - ) - except Exception as e: - logger.error(f"Error getting cache stats: {e}") - await message.answer("❌ Ошибка при получении статистики кэша") - - @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 показываем специальное меню + + # TikTok — автоматически if platform_info == "tiktok": - USER_DATA[user_id] = {"platform": platform_info} - await message.answer( - f"🎵 Ссылка с TikTok\n\n" - "Выберите что скачать:", - reply_markup=tiktok_keyboard(), - parse_mode="HTML" - ) + await process_tiktok_auto(message, user_id, url) return - - # Проверяем, является ли ссылка плейлистом (кроме TikTok) - 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 - - # Для Instagram предлагаем оригинальное качество + + # Instagram — автоматически if platform_info == "instagram": - USER_DATA[user_id] = {"platform": platform_info} + await process_instagram_auto(message, user_id, url) + return + + # Плейлисты + if await asyncio.to_thread(is_playlist, url): await message.answer( - f"📸 Ссылка с Instagram\n\n" - "Выберите качество загрузки:", - reply_markup=platform_keyboard(platform_info), - parse_mode="HTML" - ) - else: - # Для других платформ обычное меню - await message.answer( - "🔽 Выбери формат загрузки:", - reply_markup=quality_keyboard(), + "📁 Обнаружен плейлист!\n\n" + "Выберите действие:", + reply_markup=playlist_keyboard(), parse_mode="HTML" ) + return + + # YouTube/VK — выбор качества + await message.answer( + "🔽 Выбери качество:", + reply_markup=youtube_quality_keyboard(), + parse_mode="HTML" + ) -@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) - await callback.answer("⛔ Загрузка отменена", show_alert=True) - else: - await callback.answer("❌ Нет активной загрузки", show_alert=True) - - -# ---------------- TIKTOK SPECIAL HANDLERS ---------------- - -@dp.callback_query(F.data == "tiktok_music") -async def handle_tiktok_music(callback: CallbackQuery): - """Отдельная загрузка звука из TikTok""" - 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("🎵 Извлекаю звук из TikTok…", parse_mode="HTML") - - key = cache_key(url, "tiktok_music", audio=True) - final_path = cache_path(CACHE_DIR, key, "mp3") - tmp_path = os.path.join(TMP_DIR, f"{key}.mp3") - - # Создаем директории если не существуют - 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"✅ Готово! (Звук из TikTok)\n📦 Размер: {size_mb:.1f} МБ", - parse_mode="HTML" - ) - except Exception as e: - logger.error(f"Error sending cached audio: {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: - # Загружаем звук из TikTok - await asyncio.to_thread( - download_tiktok_music, - url, - tmp_path, - COOKIES_FILE, - cancel_event, - progress_cb, - ) - - # Проверяем, был ли отменен процесс - if cancel_event.is_set(): - await status.edit_text("⛔ Загрузка отменена") - if os.path.exists(tmp_path): - os.remove(tmp_path) - return - - # Проверяем, создан ли файл - if not os.path.exists(tmp_path): - raise Exception("Аудио файл не был создан") - - # Проверяем размер файла - file_size = os.path.getsize(tmp_path) - if file_size == 0: - os.remove(tmp_path) - raise Exception("Создан пустой аудио файл") - - # Добавляем метаданные к аудио - try: - info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) - if info: - metadata = get_video_info_for_audio(info) - await asyncio.to_thread(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) - except Exception as e: - logger.error(f"Error adding metadata to audio: {e}") - - # Перемещаем файл в кэш - if os.path.exists(final_path): - os.remove(final_path) - 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 TikTok music: {str(e)}") - await status.edit_text(f"❌ Ошибка: {str(e)[:100]}") - if os.path.exists(tmp_path): - try: - os.remove(tmp_path) - except: - pass - return - finally: - 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"✅ Готово! (Звук из TikTok)\n📦 Размер: {size_mb:.1f} МБ", - parse_mode="HTML" - ) - except Exception as e: - logger.error(f"Error sending audio: {e}") - await status.edit_text("❌ Ошибка при отправке звука") - - cleanup_tmp(TMP_DIR) - - -# ---------------- 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) - - -# ---------------- STANDARD VIDEO HANDLER ---------------- +# ------------------------ YouTube/VK handlers ------------------------ @dp.callback_query(F.data.startswith("q:")) async def handle_video(callback: CallbackQuery): @@ -726,10 +318,6 @@ async def handle_video(callback: CallbackQuery): 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") @@ -768,55 +356,41 @@ async def handle_video(callback: CallbackQuery): cancel_event, progress_cb, ) - - # Проверяем, был ли отменен процесс + if cancel_event.is_set(): - if os.path.exists(tmp_path): - os.remove(tmp_path) + for p in [tmp_path, optimized_path]: + if os.path.exists(p): os.remove(p) 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) - - # Удаляем исходный файл и используем оптимизированный - if os.path.exists(tmp_path): - os.remove(tmp_path) - - os.rename(optimized_path, final_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) + except DownloadCancelled: + for p in [tmp_path, optimized_path]: + if os.path.exists(p): os.remove(p) await status.edit_text("⛔ Загрузка отменена") - for path in [tmp_path, optimized_path]: - if os.path.exists(path): - os.remove(path) return except Exception as e: logger.error(f"Error downloading video: {e}") - await status.edit_text( - "❌ Не удалось скачать видео\n" - "💡 Попробуй другое качество" - ) - for path in [tmp_path, optimized_path]: - if os.path.exists(path): - os.remove(path) + for p in [tmp_path, optimized_path]: + if os.path.exists(p): os.remove(p) + await status.edit_text("❌ Не удалось скачать видео") 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_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" @@ -825,7 +399,7 @@ async def handle_video(callback: CallbackQuery): 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 + size_mb = os.path.getsize(final_path) / (1024 * 1024) await callback.message.answer( f"✅ Отправлено как документ\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML" @@ -833,13 +407,7 @@ async def handle_video(callback: CallbackQuery): 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) - - -# ---------------- STANDARD AUDIO HANDLER ---------------- @dp.callback_query(F.data == "audio") async def handle_audio(callback: CallbackQuery): @@ -851,25 +419,14 @@ async def handle_audio(callback: CallbackQuery): 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") - # Получаем информацию о видео для метаданных - video_info = None - try: - video_info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) - except: - pass - 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") - # Создаем директории если не существуют + # Создаем директории os.makedirs(TMP_DIR, exist_ok=True) os.makedirs(os.path.dirname(final_path), exist_ok=True) @@ -878,7 +435,7 @@ async def handle_audio(callback: CallbackQuery): 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 + size_mb = os.path.getsize(final_path) / (1024 * 1024) await callback.message.answer( f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML" @@ -902,50 +459,50 @@ async def handle_audio(callback: CallbackQuery): cancel_event, progress_cb, ) - - # Проверяем, был ли отменен процесс + if cancel_event.is_set(): await status.edit_text("⛔ Загрузка отменена") - if os.path.exists(tmp_path): - os.remove(tmp_path) + if os.path.exists(tmp_path): os.remove(tmp_path) return - - # Проверяем, создан ли файл + if not os.path.exists(tmp_path): raise Exception("Аудио файл не был создан") - - # Проверяем размер файла + file_size = os.path.getsize(tmp_path) if file_size == 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 = get_video_info_for_audio(video_info) - await asyncio.to_thread(add_metadata_to_audio, tmp_path, tmp_path + "_meta.mp3", metadata) + 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) - - # Перемещаем файл в кэш - if os.path.exists(final_path): - os.remove(final_path) + os.rename(tmp_path, final_path) - + except DownloadCancelled: await status.edit_text("⛔ Загрузка отменена") - if os.path.exists(tmp_path): - os.remove(tmp_path) + if os.path.exists(tmp_path): os.remove(tmp_path) return except Exception as e: logger.error(f"Error downloading audio: {str(e)}") await status.edit_text(f"❌ Ошибка: {str(e)[:100]}") - if os.path.exists(tmp_path): - try: - os.remove(tmp_path) - except: - pass + if os.path.exists(tmp_path): os.remove(tmp_path) return finally: ACTIVE_DOWNLOADS.pop(user_id, None) @@ -954,7 +511,7 @@ async def handle_audio(callback: CallbackQuery): try: await callback.message.answer_audio(FSInputFile(final_path)) - size_mb = os.path.getsize(final_path) / 1024 / 1024 + size_mb = os.path.getsize(final_path) / (1024 * 1024) await callback.message.answer( f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", parse_mode="HTML" @@ -963,43 +520,35 @@ async def handle_audio(callback: CallbackQuery): logger.error(f"Error sending audio: {e}") await status.edit_text("❌ Ошибка при отправке аудио") - cleanup_tmp(TMP_DIR) - -# ---------------- 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 - - 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" @@ -1011,9 +560,9 @@ 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("❌ Ошибка при анализе плейлиста") @@ -1023,28 +572,27 @@ async def handle_playlist_all(callback: CallbackQuery): 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: 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" @@ -1052,17 +600,15 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status): 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) - - # Создаем временную директорию для плейлиста + 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, @@ -1071,50 +617,42 @@ 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] - - # Отправляем файл с номером в подписи await callback.message.answer_document( FSInputFile(file_path), caption=f"🎬 Видео {i}/{len(downloaded_files)}\n📁 {display_name[:50]}" ) sent_count += 1 - - # Небольшая пауза между отправками await asyncio.sleep(1) - 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" @@ -1123,10 +661,9 @@ 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: @@ -1140,7 +677,6 @@ async def download_playlist_confirm(callback, user_id, playlist_info, status): @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") @@ -1148,57 +684,59 @@ 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(), + reply_markup=youtube_quality_keyboard(), parse_mode="HTML" ) - + except Exception as e: logger.error(f"Error getting first video: {e}") await callback.message.answer("❌ Ошибка при получении видео из плейлиста") -# -------------------- entrypoint -------------------- +@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) + await callback.answer("⛔ Загрузка отменена", show_alert=True) + else: + await callback.answer("❌ Нет активной загрузки", show_alert=True) + + +# -------------------- ENTRYPOINT -------------- async def main(): # Очистка временных файлов при старте cleanup_tmp(TMP_DIR) - - # Запускаем задачу очистки кэша в фоне - cleanup_task = asyncio.create_task(scheduled_cache_cleanup()) - + try: await dp.start_polling(bot) - finally: - # Отменяем задачу очистки при выходе - cleanup_task.cancel() - try: - await cleanup_task - except asyncio.CancelledError: - pass + except KeyboardInterrupt: + logger.info("Bot stopped by user") + if __name__ == "__main__": try: asyncio.run(main()) - except KeyboardInterrupt: - logger.info("Bot stopped by user") except Exception as e: logger.error(f"Fatal error: {e}") \ No newline at end of file diff --git a/wireguard/wg_confs/wg0.conf b/wireguard/wg_confs/wg0.conf new file mode 100644 index 0000000..ee3c0b9 --- /dev/null +++ b/wireguard/wg_confs/wg0.conf @@ -0,0 +1,11 @@ +[Interface] +Address = 10.7.0.13/24 +DNS = 1.0.0.1, 1.1.1.1, 1.0.0.1, 1.1.1.1 +PrivateKey = 8GTpMYtpOhWDp6AKXxukwFRsiwvERf2UVCjCrdB+M10= + +[Peer] +PublicKey = ekvrE4SMgydQhkWdWgJazlrILSEAlRbwCI4KcXUQv3A= +PresharedKey = eaCOqeiYdfso4v7/ucrvFzB1MtDnXy1yTiujL08jJIE= +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = 109.107.170.183:51821 +PersistentKeepalive = 25 \ No newline at end of file