From 5b0152e47cc2f2b9104a50add38e904441b6af7a Mon Sep 17 00:00:00 2001 From: smolkik-code Date: Wed, 18 Feb 2026 22:34:53 +0700 Subject: [PATCH] bug vix3 --- bot/downloader.py | 282 +++++++++++++++++++++++++++++++--------------- bot/main.py | 47 ++------ 2 files changed, 205 insertions(+), 124 deletions(-) diff --git a/bot/downloader.py b/bot/downloader.py index 7a9eead..3a2f3e2 100644 --- a/bot/downloader.py +++ b/bot/downloader.py @@ -3,8 +3,8 @@ import yt_dlp import threading import os import logging -import shutil import subprocess +import shutil from typing import List from config import ( @@ -26,7 +26,134 @@ def _progress_hook(cancel_event, progress_cb): progress_cb(d) return hook -# ---------------- TIKTOK: Видео + Аудио (автоматически) -------------- +def optimize_for_telegram(input_path: str, output_path: str) -> None: + """ + Конвертирует видео в Telegram-safe формат: + - mp4 + h264 + aac + - четные размеры + - crf: 23 (или 28 >50MB) + - faststart + """ + file_size_mb = os.path.getsize(input_path) / (1024 * 1024) + crf = 28 if file_size_mb > 50 else 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=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, + out_path: str, + cookies: str | None, + cancel_event: threading.Event, + progress_cb, +): + # 1. Скачиваем в любой формат + ydl_opts = { + "format": f"best[height<={quality}]/best", + "outtmpl": out_path, # ← без .mp4! + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "quiet": True, + "no_warnings": True, + "concurrent_fragment_downloads": 4, + "http_chunk_size": 10485760, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + # 2. Ищем реальный файл (без расширения, .mp4, .mov, .webm) + 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 + + if not actual_video: + raise Exception(f"Video file not created for {url}") + + # 3. Конвертируем в Telegram-safe mp4 + optimized_path = base + "_telegram.mp4" + await asyncio.to_thread(optimize_for_telegram, actual_video, optimized_path) + + # 4. Удаляем оригинальный файл и ставим конвертированный + 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": "bestaudio/best", + "outtmpl": out_path.replace('.mp3', ''), # ← без .mp3! + "cookiefile": cookies if cookies and os.path.exists(cookies) else None, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + }], + "quiet": True, + "no_warnings": True, + "concurrent_fragment_downloads": 4, + "http_chunk_size": 10485760, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + ydl.download([url]) + + # Ищем файл (может быть .m4a, .opus, .weba) + 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 + + if not actual_audio: + raise Exception(f"Audio file not created for {url}") + + # Конвертируем в mp3 (если нужно) + if actual_audio != out_path: + optimized = base + "_telegram.mp3" + subprocess.run([ + 'ffmpeg', '-i', actual_audio, '-c:a', 'aac', '-b:a', '192k', + '-loglevel', 'error', '-y', 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, @@ -35,15 +162,14 @@ def download_tiktok_video_and_audio( cancel_event: threading.Event, progress_cb, ): - # 1. Скачиваем видео (1080p) — УБРАН skip_impersonation + # 1. Скачиваем видео video_opts = { "format": f"bestvideo[height<={DEFAULT_TIKTOK_VIDEO_QUALITY}]+bestaudio/best", - "outtmpl": video_path.replace('.mp4', ''), - "merge_output_format": "mp4", + "outtmpl": video_path, # ← без .mp4! "cookiefile": cookies if cookies and os.path.exists(cookies) else None, "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, - "no_warnings": False, # ← ВКЛЮЧИЛ предупреждения для отладки + "no_warnings": False, "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } @@ -51,10 +177,24 @@ def download_tiktok_video_and_audio( with yt_dlp.YoutubeDL(video_opts) as ydl: ydl.download([url]) - if not os.path.exists(video_path): + 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") - # 2. Извлекаем аудио (MP3 192kbps) — УБРАН skip_impersonation + # Конвертируем в Telegram-safe + optimized_video = base_video + "_telegram.mp4" + await asyncio.to_thread(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) + + # 2. Аудио audio_opts = { "format": "bestaudio/best", "outtmpl": audio_path.replace('.mp3', ''), @@ -65,7 +205,7 @@ def download_tiktok_video_and_audio( "preferredquality": DEFAULT_TIKTOK_AUDIO_QUALITY, }], "quiet": True, - "no_warnings": False, # ← ВКЛЮЧИЛ для отладки + "no_warnings": False, "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } @@ -73,10 +213,27 @@ def download_tiktok_video_and_audio( with yt_dlp.YoutubeDL(audio_opts) as ydl: ydl.download([url]) - if not os.path.exists(audio_path): + 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") -# ---------------- INSTAGRAM: Видео в 1080p (автоматически) -------------- + if actual_audio != audio_path: + optimized_audio = base_audio + "_telegram.mp3" + subprocess.run([ + 'ffmpeg', '-i', actual_audio, '-c:a', 'aac', '-b:a', '192k', + '-loglevel', 'error', '-y', optimized_audio + ]) + if os.path.exists(audio_path): os.remove(audio_path) + os.rename(optimized_audio, audio_path) + + +# ---------------- INSTAGRAM: Video (1080p) ------ def download_instagram_video( url: str, out_path: str, @@ -84,68 +241,37 @@ def download_instagram_video( cancel_event: threading.Event, progress_cb, ): - # УБРАН skip_impersonation ydl_opts = { "format": f"bestvideo[height<={DEFAULT_INSTAGRAM_VIDEO_QUALITY}]+bestaudio/best", - "outtmpl": out_path.replace('.mp4', ''), - "merge_output_format": "mp4", - "cookiefile": cookies if cookies and os.path.exists(cookies) else None, - "progress_hooks": [_progress_hook(cancel_event, progress_cb)], - "quiet": True, - "no_warnings": False, # ← ВКЛЮЧИЛ для отладки - "concurrent_fragment_downloads": 4, - "http_chunk_size": 10485760, - } - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - -# ---------------- AUDIO (YouTube/VK) -------------- -def download_audio( - url: str, - out_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - ydl_opts = { - "format": "bestaudio/best", - "outtmpl": out_path.replace('.mp3', ''), - "cookiefile": cookies if cookies and os.path.exists(cookies) else None, - "progress_hooks": [_progress_hook(cancel_event, progress_cb)], - "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "192"}], - "quiet": True, - "no_warnings": True, - "concurrent_fragment_downloads": 4, - "http_chunk_size": 10485760, - } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) - -# ---------------- VIDEO (YouTube/VK — с выбором качества) -------------- -def download_video( - url: str, - quality: str, - out_path: str, - cookies: str | None, - cancel_event: threading.Event, - progress_cb, -): - ydl_opts = { - "format": f"best[height<={quality}]/best", "outtmpl": out_path, - "merge_output_format": "mp4", "cookiefile": cookies if cookies and os.path.exists(cookies) else None, "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, - "no_warnings": True, + "no_warnings": False, "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } + with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) -# ---------------- PLAYLIST (осталось без изменений) -------------- + 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" + await asyncio.to_thread(optimize_for_telegram, actual, optimized) + if os.path.exists(out_path): os.remove(out_path) + os.rename(optimized, out_path) + + +# ---------------- PLAYLIST ------ def download_playlist_videos( playlist_info: dict, output_dir: str, @@ -157,7 +283,6 @@ def download_playlist_videos( 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 if cookies and os.path.exists(cookies) else None, "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, @@ -168,45 +293,24 @@ def download_playlist_videos( "concurrent_fragment_downloads": 4, "http_chunk_size": 10485760, } + try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: entries = playlist_info.get('entries', []) - total = len(entries) - logger.info(f"Downloading playlist: {total} videos") for i, entry in enumerate(entries, 1): if cancel_event.is_set(): raise DownloadCancelled("Cancelled by user") if not entry.get('url'): - logger.warning(f"Entry {i} has no URL") continue try: info = ydl.extract_info(entry['url'], download=True) - if info and os.path.exists(ydl.prepare_filename(info)): - downloaded_files.append(ydl.prepare_filename(info)) + 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}: {e}") - continue return downloaded_files except Exception as e: logger.error(f"Playlist download error: {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 = [] - for key in ['title', 'artist', 'album']: - if metadata.get(key): - metadata_args.extend(['-metadata', f'{key}={str(metadata[key])[:100]}']) - cmd = [ - 'ffmpeg', '-i', input_path, '-c', 'copy', - '-id3v2_version', '3', '-loglevel', 'error', '-y', - *metadata_args, output_path - ] - 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 + raise \ No newline at end of file diff --git a/bot/main.py b/bot/main.py index 831b9fc..d48115a 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,4 +1,4 @@ -# main.py — ИСПРАВЛЕНО: добавлен import cache_key +# main.py import os import asyncio import threading @@ -17,8 +17,6 @@ from config import ( CACHE_DIR, TMP_DIR, COOKIES_FILE, - CACHE_MAX_AGE_DAYS, - CACHE_MAX_SIZE_MB, ) from keyboards import youtube_quality_keyboard, cancel_keyboard, playlist_keyboard from downloader import ( @@ -27,18 +25,17 @@ from downloader import ( download_video, download_audio, download_playlist_videos, + 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 # ← ИСПРАВЛЕНО: добавлен cache_key +from cache import cache_key, cache_path from cleanup import cleanup_tmp logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# Инициализация if LOCAL_API_URL: api_server = TelegramAPIServer.from_base(LOCAL_API_URL) session = AiohttpSession(api=api_server) @@ -87,8 +84,7 @@ def make_progress_cb(loop, message): asyncio.run_coroutine_threadsafe(update(d), loop) return cb -# ---------------------- AUTO DOWNLOADS (TikTok/Instagram) ------------------ - +# ---------------- TikTok + Instagram (автозагрузка) ---------------- async def process_tiktok_auto(message: Message, user_id: int, url: str): status = await message.answer("🎵 Загружаю TikTok (видео + аудио)...", parse_mode="HTML") @@ -103,7 +99,6 @@ async def process_tiktok_auto(message: Message, user_id: int, url: str): 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: @@ -118,7 +113,6 @@ async def process_tiktok_auto(message: Message, user_id: int, url: str): cleanup_tmp(TMP_DIR) return - # Загрузка cancel_event = threading.Event() ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} loop = asyncio.get_running_loop() @@ -134,19 +128,16 @@ async def process_tiktok_auto(message: Message, user_id: int, url: str): cancel_event, progress_cb, ) - 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 - # Перемещение в кэш - for path in [video_cache, audio_cache]: - if os.path.exists(path): os.remove(path) - - os.makedirs(os.path.dirname(video_cache), exist_ok=True) + # Уже готовы 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") @@ -170,7 +161,6 @@ async def process_instagram_auto(message: Message, user_id: int, url: str): 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: @@ -198,15 +188,12 @@ async def process_instagram_auto(message: Message, user_id: int, url: str): 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.makedirs(os.path.dirname(final_cache), exist_ok=True) os.rename(tmp_path, final_cache) await status.edit_text("📤 Отправляю видео...", parse_mode="HTML") @@ -221,16 +208,15 @@ async def process_instagram_auto(message: Message, user_id: int, url: str): ACTIVE_DOWNLOADS.pop(user_id, None) cleanup_tmp(TMP_DIR) -# ---------------------- HANDLERS ------------------ - +# ---------------- Handlers ---------------- @dp.message(F.text == "/start") async def start(message: Message): await message.answer( "👋 Привет!\n\n" "📥 Скачиваю видео и аудио по ссылке.\n\n" "✨ Новые возможности:\n" - "• 🎬 TikTok: автоматически видео + аудио\n" - "• 📸 Instagram: автоматически видео в 1080p\n" + "• 🎬 TikTok: автоматически видео + аудио (Telegram-safe)\n" + "• 📸 Instagram: автоматически видео в 1080p (Telegram-safe)\n" "• 🎵 Аудио из любого видео\n" "• 📁 Плейлисты YouTube\n" "👉 Просто отправь ссылку.", @@ -245,17 +231,14 @@ async def handle_link(message: Message): platform_info = await asyncio.to_thread(get_platform_info, url) - # TikTok — автоматически if platform_info == "tiktok": await process_tiktok_auto(message, user_id, url) return - # Instagram — автоматически if platform_info == "instagram": await process_instagram_auto(message, user_id, url) return - # Плейлисты if await asyncio.to_thread(is_playlist, url): await message.answer( "📁 Обнаружен плейлист!\n\n" @@ -265,7 +248,6 @@ async def handle_link(message: Message): ) return - # YouTube/VK — выбор качества await message.answer( "🔽 Выбери качество:", reply_markup=youtube_quality_keyboard(), @@ -291,7 +273,6 @@ async def handle_video(callback: CallbackQuery): 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: @@ -318,14 +299,12 @@ 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) await status.edit_text("⛔ Загрузка отменена") return if os.path.exists(final_path): os.remove(final_path) - os.makedirs(os.path.dirname(final_path), exist_ok=True) os.rename(tmp_path, final_path) except DownloadCancelled: @@ -372,7 +351,6 @@ async def handle_audio(callback: CallbackQuery): tmp_path = cache_path(TMP_DIR, key, "mp3") os.makedirs(os.path.dirname(tmp_path), exist_ok=True) - # Проверка кэша if os.path.exists(final_path): await status.edit_text("📤 Отправляю аудио из кэша…", parse_mode="HTML") try: @@ -398,7 +376,6 @@ 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) @@ -410,6 +387,7 @@ async def handle_audio(callback: CallbackQuery): os.remove(tmp_path) raise Exception("Создан пустой аудио файл") + if os.path.exists(final_path): os.remove(final_path) os.rename(tmp_path, final_path) except DownloadCancelled: @@ -433,7 +411,7 @@ async def handle_audio(callback: CallbackQuery): logger.error(f"Error sending audio: {e}") await status.edit_text("❌ Ошибка при отправке аудио") -# ---------------------- PLAYLIST HANDLERS ------------------ +# ---------------- Playlist Handlers ---------------- @dp.callback_query(F.data == "playlist_all") async def handle_playlist_all(callback: CallbackQuery): await callback.answer() @@ -596,7 +574,6 @@ async def cancel_download(callback: CallbackQuery): else: await callback.answer("❌ Нет активной загрузки", show_alert=True) -# ---------------------- ENTRYPOINT ------------------ async def main(): cleanup_tmp(TMP_DIR) try: