diff --git a/.DS_Store b/.DS_Store index fec1682..db8bed2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/bot/cache.py b/bot/cache.py index 285f673..40b5772 100644 --- a/bot/cache.py +++ b/bot/cache.py @@ -1,17 +1,13 @@ +# cache.py import hashlib - +import os 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/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..f08127a 100644 --- a/bot/downloader.py +++ b/bot/downloader.py @@ -1,32 +1,60 @@ +# downloader.py import yt_dlp import threading import os import logging import subprocess -import json import shutil -from typing import List, Optional +import asyncio +from typing import List + +from config import ( + DEFAULT_TIKTOK_VIDEO_QUALITY, + DEFAULT_TIKTOK_AUDIO_QUALITY, + DEFAULT_INSTAGRAM_VIDEO_QUALITY, +) 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 +def optimize_for_telegram(input_path: str, output_path: str) -> None: + """ + Синхронная функция: конвертирует видео в Telegram-safe mp4 + """ + file_size_mb = os.path.getsize(input_path) / (1024 * 1024) + crf = 28 if file_size_mb > 50 else 23 -# ---------------- STANDARD VIDEO ---------------- + 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=600) + if result.returncode != 0: + logger.error(f"FFmpeg error: {result.stderr}") + shutil.copy2(input_path, output_path) + + +# ---------------- YOUTUBE/VK: Video (с выбором качества) ------ def download_video( url: str, quality: str, @@ -38,15 +66,10 @@ def download_video( ydl_opts = { "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, } @@ -54,65 +77,48 @@ def download_video( with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) + # Ищем реальный файл + base = out_path.rsplit('.', 1)[0] + actual_video = None + for ext in ['', '.mp4', '.mov', '.webm', '.mkv']: + candidate = base + ext + if os.path.exists(candidate): + actual_video = candidate + break -# ---------------- ORIGINAL QUALITY ---------------- + if not actual_video: + raise Exception(f"Video file not created for {url}") -def download_original_quality( + # Конвертируем в Telegram-safe mp4 (синхронно) + optimized_path = base + "_telegram.mp4" + optimize_for_telegram(actual_video, optimized_path) # ← без await! + + # Удаляем старый файл и ставим конвертированный + for path in [out_path, actual_video]: + if os.path.exists(path): os.remove(path) + os.rename(optimized_path, out_path) + + +# ---------------- AUDIO (YouTube/VK) ------ +def download_audio( 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}, - }, - "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]) - - -# ---------------- TIKTOK MUSIC ONLY ---------------- - -def download_tiktok_music( - url: str, - out_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - """Скачивает только звук из TikTok""" ydl_opts = { "format": "bestaudio/best", "outtmpl": out_path.replace('.mp3', ''), - "cookiefile": cookies, + "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", - }, - ], + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + }], "quiet": True, "no_warnings": True, - "extractor_args": { - "tiktok": {"skip_impersonation": True}, - }, "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } @@ -120,64 +126,158 @@ def download_tiktok_music( with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) + # Ищем файл + base = out_path.rsplit('.', 1)[0] + actual_audio = None + for ext in ['', '.mp3', '.m4a', '.opus', '.weba']: + candidate = base + ext + if os.path.exists(candidate): + actual_audio = candidate + break -# ---------------- ADD METADATA TO AUDIO ---------------- + if not actual_audio: + raise Exception(f"Audio file not created for {url}") -def add_metadata_to_audio( - input_path: str, - output_path: str, - metadata: dict, + # Конвертируем в mp3 + if actual_audio != out_path: + optimized = base + "_telegram.mp3" + optimize_for_telegram_audio(actual_audio, optimized) # ← отдельная функция + if os.path.exists(out_path): os.remove(out_path) + os.rename(optimized, out_path) + + +# ---------------- TikTok: Video + Audio (автоматически) ------ +def download_tiktok_video_and_audio( + url: str, + video_path: str, + audio_path: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, ): - """Добавляет метаданные к аудио файлу""" - 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) + # Скачиваем видео + video_opts = { + "format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best", + "outtmpl": video_path, + "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(video_opts) as ydl: + ydl.download([url]) + + # Ищем видеофайл + base_video = video_path.rsplit('.', 1)[0] + actual_video = None + for ext in ['', '.mp4', '.mov', '.webm']: + candidate = base_video + ext + if os.path.exists(candidate): + actual_video = candidate + break + if not actual_video: + raise Exception("TikTok video file not created") + + # Конвертируем в Telegram-safe mp4 + optimized_video = base_video + "_telegram.mp4" + optimize_for_telegram(actual_video, optimized_video) + for path in [video_path, actual_video]: + if os.path.exists(path): os.remove(path) + os.rename(optimized_video, video_path) + + # Аудио + 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]) + + base_audio = audio_path.rsplit('.', 1)[0] + actual_audio = None + for ext in ['', '.mp3', '.m4a', '.opus']: + candidate = base_audio + ext + if os.path.exists(candidate): + actual_audio = candidate + break + if not actual_audio: + raise Exception("TikTok audio file not created") + + if actual_audio != audio_path: + optimized_audio = base_audio + "_telegram.mp3" + optimize_for_telegram_audio(actual_audio, optimized_audio) + if os.path.exists(audio_path): os.remove(audio_path) + os.rename(optimized_audio, audio_path) -# ---------------- PLAYLIST DOWNLOAD ---------------- +# ---------------- Instagram: Video (1080p) ------ +def download_instagram_video( + url: str, + out_path: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, +): + ydl_opts = { + "format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}]+bestaudio/best", + "outtmpl": out_path, + "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]) + + base = out_path.rsplit('.', 1)[0] + actual = None + for ext in ['', '.mp4', '.mov', '.webm']: + candidate = base + ext + if os.path.exists(candidate): + actual = candidate + break + if not actual: + raise Exception("Instagram video not created") + + optimized = base + "_telegram.mp4" + optimize_for_telegram(actual, optimized) + if os.path.exists(out_path): os.remove(out_path) + os.rename(optimized, out_path) + + +# ---------------- Audio helper (синхронная) ------ +def optimize_for_telegram_audio(input_path: str, output_path: str) -> None: + """Конвертирует аудио в Telegram-safe mp3""" + cmd = [ + 'ffmpeg', + '-i', input_path, + '-c:a', 'aac', + '-b:a', '192k', + '-loglevel', 'error', + '-y', + output_path + ] + subprocess.run(cmd, capture_output=True, text=True, timeout=600) + + +# ---------------- Playlist ------ def download_playlist_videos( playlist_info: dict, output_dir: str, @@ -185,14 +285,11 @@ def download_playlist_videos( 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, + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, "no_warnings": True, @@ -206,92 +303,20 @@ def download_playlist_videos( 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}") - + info = ydl.extract_info(entry['url'], download=True) + if info: + filename = ydl.prepare_filename(info) + if os.path.exists(filename): + downloaded_files.append(filename) 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") + logger.error(f"Error downloading video {i}: {e}") return downloaded_files - except Exception as e: - logger.error(f"Error downloading playlist: {e}") - raise - - -# ---------------- STANDARD AUDIO ---------------- - -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]) \ No newline at end of file + logger.error(f"Playlist download error: {e}") + raise \ 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..d48115a 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,14 +1,13 @@ +# main.py 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 @@ -18,43 +17,25 @@ from config import ( CACHE_DIR, TMP_DIR, COOKIES_FILE, - RATE_LIMIT_SECONDS, - 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_original_quality, + download_tiktok_video_and_audio, + download_instagram_video, + download_video, + download_audio, download_playlist_videos, - download_tiktok_music, - add_metadata_to_audio, # Только для аудио + optimize_for_telegram, 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 cleanup import cleanup_tmp -# -------------------- init -------------------- - -# Настройка логирования logging.basicConfig(level=logging.INFO) 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) @@ -63,8 +44,6 @@ else: bot = Bot(token=BOT_TOKEN) dp = Dispatcher() - -# Регистрация middleware private_middleware = PrivateMiddleware() dp.message.middleware(private_middleware) dp.callback_query.middleware(private_middleware) @@ -73,126 +52,10 @@ 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 -------------------- - 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} @@ -201,519 +64,195 @@ 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) - 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 = "?" - - text = ( - "⏬ Загрузка\n" - f"{bar} {percent:.0f}%\n" - f"⏱ Осталось: {eta_str} сек" - ) - - await message.edit_text( - text, - reply_markup=cancel_keyboard(), - parse_mode="HTML" - ) + eta_str = str(int(float(eta))) if eta and eta != "?" else "?" + 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 +# ---------------- TikTok + Instagram (автозагрузка) ---------------- +async def process_tiktok_auto(message: Message, user_id: int, url: str): + status = await message.answer("🎵 Загружаю TikTok (видео + аудио)...", parse_mode="HTML") -def make_playlist_progress_cb(loop, message, total_videos: int): - current_video = {"value": 0} - last_update = {"time": 0} + key_video = cache_key(url, "tiktok_video", audio=False) + key_audio = cache_key(url, "tiktok_audio", audio=True) - async def update(d): + video_cache = cache_path(CACHE_DIR, key_video, "mp4") + tmp_video = cache_path(TMP_DIR, key_video, "mp4") + os.makedirs(os.path.dirname(tmp_video), exist_ok=True) + + 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): + 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 + + # Уже готовы Telegram-safe mp4/m3, просто перемещаем + if os.path.exists(video_cache): os.remove(video_cache) + os.rename(tmp_video, video_cache) + if os.path.exists(audio_cache): os.remove(audio_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) +async def process_instagram_auto(message: Message, user_id: int, url: str): + status = await message.answer("📸 Загружаю Instagram (1080p)...", parse_mode="HTML") -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 + key = cache_key(url, "instagram_video", audio=False) + final_cache = cache_path(CACHE_DIR, 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): + 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 -# -------------------- handlers -------------------- + 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 + + 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 ---------------- @dp.message(F.text == "/start") async def start(message: Message): await message.answer( "👋 Привет!\n\n" - "📥 Я скачиваю видео и звук из видео по ссылке.\n\n" + "📥 Скачиваю видео и аудио по ссылке.\n\n" "✨ Новые возможности:\n" - "• 🎬 Оригинальное качество (Instagram, TikTok)\n" + "• 🎬 TikTok: автоматически видео + аудио (Telegram-safe)\n" + "• 📸 Instagram: автоматически видео в 1080p (Telegram-safe)\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 показываем специальное меню + 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 предлагаем оригинальное качество + 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), + "📁 Обнаружен плейлист!\n\n" + "Выберите действие:", + reply_markup=playlist_keyboard(), parse_mode="HTML" ) - else: - # Для других платформ обычное меню - await message.answer( - "🔽 Выбери формат загрузки:", - reply_markup=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 ---------------- + await message.answer( + "🔽 Выбери качество:", + reply_markup=youtube_quality_keyboard(), + parse_mode="HTML" + ) @dp.callback_query(F.data.startswith("q:")) async def handle_video(callback: CallbackQuery): @@ -726,28 +265,20 @@ 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") 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("❌ Ошибка при отправке файла") @@ -768,78 +299,39 @@ 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) + if os.path.exists(tmp_path): os.remove(tmp_path) await status.edit_text("⛔ Загрузка отменена") return - - # Оптимизируем видео для телеграма (кроме оригинального качества) - 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) - else: - # Для оригинального качества не оптимизируем - os.rename(tmp_path, final_path) - + + if os.path.exists(final_path): os.remove(final_path) + os.rename(tmp_path, final_path) + except DownloadCancelled: + if os.path.exists(tmp_path): os.remove(tmp_path) 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) + if os.path.exists(tmp_path): os.remove(tmp_path) + 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( - f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", - parse_mode="HTML" - ) + 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" - ) + 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 AUDIO HANDLER ---------------- @dp.callback_query(F.data == "audio") async def handle_audio(callback: CallbackQuery): @@ -851,38 +343,20 @@ 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") + 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" - ) + 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 audio: {e}") await status.edit_text("❌ Ошибка при отправке аудио") @@ -902,104 +376,62 @@ 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: + if os.path.getsize(tmp_path) == 0: os.remove(tmp_path) raise Exception("Создан пустой аудио файл") - - # Добавляем метаданные к аудио - 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) - 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) + + 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) 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" - ) + 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 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,58 +443,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_playlist_progress_cb(loop, status, video_count) - - # Создаем временную директорию для плейлиста - 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 +488,34 @@ 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 +524,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: @@ -1136,69 +534,55 @@ 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) - 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(), + 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) 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