diff --git a/bot/downloader.py b/bot/downloader.py index eb204a4..0a3b10a 100644 --- a/bot/downloader.py +++ b/bot/downloader.py @@ -2,6 +2,8 @@ import yt_dlp import threading import os import logging +import subprocess +import json from typing import List, Optional logger = logging.getLogger(__name__) @@ -16,9 +18,7 @@ def _progress_hook(cancel_event, progress_cb): if cancel_event.is_set(): raise DownloadCancelled("Cancelled by user") - if d["status"] == "downloading" and progress_cb: - progress_cb(d) - elif d["status"] == "finished" and progress_cb: + if d["status"] in ["downloading", "finished"] and progress_cb: progress_cb(d) return hook @@ -46,13 +46,21 @@ def download_video( "tiktok": {"skip_impersonation": True}, "instagram": {"skip_impersonation": True}, }, + "writedescription": True, # Сохраняем описание + "writeinfojson": True, # Сохраняем информацию в JSON } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) + info = ydl.extract_info(url, download=True) + + # Сохраняем информацию о видео рядом с файлом + info_file = out_path.replace('.mp4', '.info.json') + if os.path.exists(info_file): + with open(info_file, 'w', encoding='utf-8') as f: + json.dump(info, f, ensure_ascii=False, indent=2) -# ---------------- ORIGINAL QUALITY (Instagram/TikTok) ---------------- +# ---------------- ORIGINAL QUALITY ---------------- def download_original_quality( url: str, @@ -61,7 +69,7 @@ def download_original_quality( cancel_event: threading.Event, progress_cb, ): - """Скачивает видео в оригинальном качестве (без перекодировки)""" + """Скачивает видео в оригинальном качестве""" ydl_opts = { "format": "best", # Лучшее качество доступное "outtmpl": out_path, @@ -74,12 +82,149 @@ def download_original_quality( "tiktok": {"skip_impersonation": True}, "instagram": {"skip_impersonation": True}, }, - # Для Instagram и TikTok используем специфичные форматы "format_sort": ["quality", "res", "codec", "size"], + "writedescription": True, + "writeinfojson": True, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) + info = ydl.extract_info(url, download=True) + + # Сохраняем информацию о видео + info_file = out_path.replace('.mp4', '.info.json') + if os.path.exists(info_file): + with open(info_file, 'w', encoding='utf-8') as f: + json.dump(info, f, ensure_ascii=False, indent=2) + + +# ---------------- 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, + "progress_hooks": [_progress_hook(cancel_event, progress_cb)], + "postprocessors": [ + { + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "320", + }, + {"key": "FFmpegMetadata"}, + ], + "quiet": True, + "no_warnings": True, + "extractor_args": { + "tiktok": { + "skip_impersonation": True, + "music": True, # Специальный флаг для музыки TikTok + }, + }, + "writethumbnail": True, + "writeinfojson": True, + # Для TikTok стараемся получить оригинальный звук + "extract_flat": False, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=True) + + # Сохраняем информацию о звуке + info_file = out_path.replace('.mp3', '.info.json') + if os.path.exists(info_file): + with open(info_file, 'w', encoding='utf-8') as f: + json.dump(info, f, ensure_ascii=False, indent=2) + + +# ---------------- ADD METADATA TO VIDEO/AUDIO ---------------- + +def add_metadata_to_video( + input_path: str, + output_path: str, + metadata: dict, +): + """Добавляет метаданные к видео или аудио файлу""" + try: + # Определяем тип файла + is_video = input_path.lower().endswith(('.mp4', '.mkv', '.avi', '.mov')) + is_audio = input_path.lower().endswith(('.mp3', '.m4a', '.flac', '.wav')) + + if not (is_video or is_audio): + # Если не поддерживаемый формат, просто копируем файл + import shutil + shutil.copy2(input_path, output_path) + return + + # Подготавливаем аргументы метаданных для ffmpeg + metadata_args = [] + + if metadata.get('title'): + metadata_args.extend(['-metadata', f'title={metadata["title"]}']) + + if metadata.get('artist') or metadata.get('uploader'): + artist = metadata.get('artist') or metadata.get('uploader') + metadata_args.extend(['-metadata', f'artist={artist}']) + + if metadata.get('description'): + # Обрезаем описание если слишком длинное + desc = metadata['description'][:500] + metadata_args.extend(['-metadata', f'comment={desc}']) + + if metadata.get('url'): + metadata_args.extend(['-metadata', f'copyright={metadata["url"]}']) + + # Для TikTok добавляем дополнительную информацию о музыке + if metadata.get('track') and metadata.get('artist'): + metadata_args.extend(['-metadata', f'album={metadata["track"]}']) + + if is_video: + # Для видео файлов + cmd = [ + 'ffmpeg', + '-i', input_path, + '-c', 'copy', # Копируем без перекодировки + '-movflags', '+faststart', + '-y', + *metadata_args, + output_path + ] + else: + # Для аудио файлов + cmd = [ + 'ffmpeg', + '-i', input_path, + '-c', 'copy', + '-id3v2_version', '3', + '-y', + *metadata_args, + output_path + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60 + ) + + if result.returncode != 0: + logger.error(f"FFmpeg metadata error: {result.stderr}") + # Если не удалось добавить метаданные, копируем файл как есть + import shutil + shutil.copy2(input_path, output_path) + + except Exception as e: + logger.error(f"Error adding metadata: {e}") + import shutil + shutil.copy2(input_path, output_path) # ---------------- PLAYLIST DOWNLOAD ---------------- @@ -102,13 +247,14 @@ def download_playlist_videos( "progress_hooks": [_progress_hook(cancel_event, progress_cb)], "quiet": True, "no_warnings": True, - "ignoreerrors": True, # Игнорируем ошибки при загрузке отдельных видео + "ignoreerrors": True, "extract_flat": False, + "writedescription": True, + "writeinfojson": True, } try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: - # Скачиваем каждое видео по отдельности for entry in playlist_info.get('entries', []): if cancel_event.is_set(): raise DownloadCancelled("Cancelled by user") @@ -137,7 +283,7 @@ def download_playlist_videos( raise -# ---------------- AUDIO FROM VIDEO ---------------- +# ---------------- STANDARD AUDIO ---------------- def download_audio( url: str, @@ -167,7 +313,15 @@ def download_audio( "tiktok": {"skip_impersonation": True}, "instagram": {"skip_impersonation": True}, }, + "writedescription": True, + "writeinfojson": True, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - ydl.download([url]) \ No newline at end of file + info = ydl.extract_info(url, download=True) + + # Сохраняем информацию о аудио + info_file = out_path.replace('.mp3', '.info.json') + if os.path.exists(info_file): + with open(info_file, 'w', encoding='utf-8') as f: + json.dump(info, f, ensure_ascii=False, indent=2) \ No newline at end of file diff --git a/bot/main.py b/bot/main.py index b2cad32..5413557 100644 --- a/bot/main.py +++ b/bot/main.py @@ -4,6 +4,7 @@ import threading import logging import subprocess import time +import json from datetime import datetime from typing import Optional @@ -26,13 +27,16 @@ from keyboards import ( quality_keyboard, cancel_keyboard, playlist_keyboard, - platform_keyboard + platform_keyboard, + tiktok_keyboard, ) from downloader import ( download_video, download_audio, download_original_quality, download_playlist_videos, + download_tiktok_music, + add_metadata_to_video, DownloadCancelled ) from middleware import PrivateMiddleware @@ -67,7 +71,7 @@ dp.message.middleware(private_middleware) dp.callback_query.middleware(private_middleware) USER_URLS: dict[int, str] = {} -USER_DATA: dict[int, dict] = {} # Для хранения дополнительных данных +USER_DATA: dict[int, dict] = {} ACTIVE_DOWNLOADS: dict[int, dict] = {} # -------------------- cache cleaning -------------------- @@ -280,9 +284,9 @@ def make_playlist_progress_cb(loop, message, total_videos: int): return cb -def optimize_for_telegram(input_path: str, output_path: str) -> bool: +def optimize_for_telegram(input_path: str, output_path: str, metadata: dict = None) -> bool: """ - Оптимизирует видео для телеграма + Оптимизирует видео для телеграма с добавлением метаданных """ try: # Проверяем размер файла @@ -293,6 +297,20 @@ def optimize_for_telegram(input_path: str, output_path: str) -> bool: crf = 28 else: crf = 23 + + # Подготавливаем метаданные для ffmpeg + metadata_args = [] + if metadata: + if metadata.get('title'): + metadata_args.extend(['-metadata', f'title={metadata["title"]}']) + if metadata.get('artist'): + metadata_args.extend(['-metadata', f'artist={metadata["artist"]}']) + if metadata.get('description'): + # Обрезаем описание если слишком длинное для метаданных + desc = metadata['description'][:1000] + metadata_args.extend(['-metadata', f'comment={desc}']) + if metadata.get('url'): + metadata_args.extend(['-metadata', f'copyright={metadata["url"]}']) cmd = [ 'ffmpeg', @@ -305,6 +323,7 @@ def optimize_for_telegram(input_path: str, output_path: str) -> bool: '-movflags', '+faststart', '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-y', + *metadata_args, output_path ] @@ -330,6 +349,64 @@ def optimize_for_telegram(input_path: str, output_path: str) -> bool: return False +def get_video_description(video_info: dict) -> dict: + """Извлекает описание и метаданные из информации о видео""" + metadata = { + 'title': video_info.get('title', ''), + 'uploader': video_info.get('uploader', ''), + 'description': video_info.get('description', ''), + 'duration': video_info.get('duration', 0), + 'view_count': video_info.get('view_count', 0), + 'like_count': video_info.get('like_count', 0), + 'upload_date': video_info.get('upload_date', ''), + 'url': video_info.get('webpage_url', ''), + } + + # Для TikTok добавляем дополнительную информацию + if video_info.get('extractor') == 'TikTok': + metadata.update({ + 'creator': video_info.get('creator', ''), + 'track': video_info.get('track', ''), + 'artist': video_info.get('artist', ''), + }) + + return metadata + + +def format_description(metadata: dict) -> str: + """Форматирует описание для отправки в сообщении""" + desc = [] + + if metadata.get('title'): + desc.append(f"🎬 {metadata['title']}") + + if metadata.get('uploader'): + desc.append(f"👤 Автор: {metadata['uploader']}") + + if metadata.get('duration'): + minutes = metadata['duration'] // 60 + seconds = metadata['duration'] % 60 + desc.append(f"⏱ Длительность: {minutes}:{seconds:02d}") + + if metadata.get('view_count'): + views = f"{metadata['view_count']:,}".replace(',', ' ') + desc.append(f"👁 Просмотры: {views}") + + if metadata.get('like_count'): + likes = f"{metadata['like_count']:,}".replace(',', ' ') + desc.append(f"❤️ Лайки: {likes}") + + # Для TikTok показываем музыку + if metadata.get('track') and metadata.get('artist'): + desc.append(f"🎵 Музыка: {metadata['artist']} - {metadata['track']}") + + # Добавляем ссылку на оригинал + if metadata.get('url'): + desc.append(f"🔗 Оригинал: {metadata['url']}") + + return "\n".join(desc) + + # -------------------- handlers -------------------- @dp.message(F.text == "/start") @@ -340,6 +417,8 @@ async def start(message: Message): "✨ Новые возможности:\n" "• 🎬 Оригинальное качество (Instagram, TikTok)\n" "• 📁 Плейлисты YouTube\n" + "• 📝 Описание к видео файлу\n" + "• 🎵 Отдельный звук для TikTok\n" "• 🔄 Автоочистка кэша\n\n" "👉 Просто отправь ссылку.", parse_mode="HTML" @@ -355,13 +434,15 @@ async def help_command(message: Message): "• /cache_stats - Статистика кэша\n\n" "✨ Поддерживаемые платформы:\n" "• YouTube (видео и плейлисты)\n" - "• TikTok (оригинальное качество)\n" + "• TikTok (оригинальное качество + звук отдельно)\n" "• Instagram (Reels, видео, IGTV)\n" "• Twitter/X, Facebook, VK и другие\n\n" "🎯 Особенности:\n" - "• Instagram/TikTok: есть опция оригинального качества\n" - "• Плейлисты: загрузка всех видео из плейлиста\n" - "• Аудио: извлечение звука из любого видео", + "• 📝 Все видео загружаются с описанием\n" + "• 🎵 TikTok: можно скачать отдельно звук\n" + "• 🎬 Instagram/TikTok: оригинальное качество\n" + "• 📁 Плейлисты: загрузка всех видео\n" + "• 🎧 Аудио: извлечение звука из любого видео", parse_mode="HTML" ) @@ -420,7 +501,21 @@ async def handle_link(message: Message): 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" + ) + return + + # Проверяем, является ли ссылка плейлистом (кроме TikTok) try: is_playlist_url = await asyncio.to_thread(is_playlist, url) if is_playlist_url: @@ -435,14 +530,11 @@ async def handle_link(message: Message): except: pass - # Определяем платформу - platform_info = await asyncio.to_thread(get_platform_info, url) - - # Для Instagram и TikTok предлагаем оригинальное качество - if platform_info in ["instagram", "tiktok"]: + # Для Instagram предлагаем оригинальное качество + if platform_info == "instagram": USER_DATA[user_id] = {"platform": platform_info} await message.answer( - f"🎬 Ссылка с {platform_info.capitalize()}\n\n" + f"📸 Ссылка с Instagram\n\n" "Выберите качество загрузки:", reply_markup=platform_keyboard(platform_info), parse_mode="HTML" @@ -469,10 +561,11 @@ async def cancel_download(callback: CallbackQuery): await callback.answer("❌ Нет активной загрузки", show_alert=True) -# ---------------- PLAYLIST HANDLERS ---------------- +# ---------------- TIKTOK SPECIAL HANDLERS ---------------- -@dp.callback_query(F.data == "playlist_all") -async def handle_playlist_all(callback: CallbackQuery): +@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) @@ -481,200 +574,149 @@ async def handle_playlist_all(callback: CallbackQuery): await callback.message.answer("❌ Ссылка не найдена") return - if not check_rate_limit(user_id, RATE_LIMIT_SECONDS * 3): # Больший лимит для плейлистов + 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") + status = await callback.message.answer("🎵 Извлекаю звук из TikTok…", parse_mode="HTML") - try: - # Получаем информацию о плейлисте - from info import get_playlist_info - playlist_info = await asyncio.to_thread(get_playlist_info, url) - - if not playlist_info or 'entries' not in playlist_info: - await status.edit_text("❌ Не удалось получить информацию о плейлисте") - return - - video_count = len(playlist_info['entries']) - if video_count == 0: - await status.edit_text("❌ Плейлист пуст") - return - - # Подтверждение для больших плейлистов - if video_count > 10: - await callback.message.answer( - f"⚠️ Внимание!\n\n" - f"Плейлист содержит {video_count} видео.\n" - f"Это может занять много времени и места.\n\n" - f"Продолжить загрузку?", - reply_markup=playlist_keyboard(confirm=True), - parse_mode="HTML" - ) - USER_DATA[user_id] = {"playlist_info": playlist_info, "status_message": status} - return - - await download_playlist_confirm(callback, user_id, playlist_info, status) - - except Exception as e: - logger.error(f"Error analyzing playlist: {e}") - await status.edit_text("❌ Ошибка при анализе плейлиста") - - -@dp.callback_query(F.data == "playlist_confirm_yes") -async def handle_playlist_confirm(callback: CallbackQuery): - await callback.answer() - user_id = callback.from_user.id + 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") - data = USER_DATA.get(user_id, {}) - playlist_info = data.get("playlist_info") - status = data.get("status_message") + # Создаем директории если не существуют + os.makedirs(TMP_DIR, exist_ok=True) + os.makedirs(os.path.dirname(final_path), exist_ok=True) - if not playlist_info or not status: - await callback.message.answer("❌ Данные плейлиста не найдены") + # Проверяем кэш + if os.path.exists(final_path): + await status.edit_text("📤 Отправляю звук из кэша…", parse_mode="HTML") + try: + await callback.message.answer_audio(FSInputFile(final_path)) + + # Получаем информацию о звуке для описания + try: + info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) + if info: + metadata = get_video_description(info) + if metadata.get('track') or metadata.get('artist'): + desc = f"🎵 Звук из TikTok\n\n" + if metadata.get('track'): + desc += f"🎶 Трек: {metadata['track']}\n" + if metadata.get('artist'): + desc += f"👤 Исполнитель: {metadata['artist']}\n" + if metadata.get('url'): + desc += f"🔗 Оригинал: {metadata['url']}" + + await callback.message.answer(desc, parse_mode="HTML") + except: + pass + + except Exception as e: + logger.error(f"Error sending cached audio: {e}") + await status.edit_text("❌ Ошибка при отправке звука") 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): - """Загружает плейлист после подтверждения""" + cancel_event = threading.Event() + ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} + loop = asyncio.get_running_loop() + progress_cb = make_progress_cb(loop, status) + try: - video_count = len(playlist_info['entries']) - playlist_title = playlist_info.get('title', 'Плейлист') - - await status.edit_text( - f"📁 Начинаю загрузку плейлиста\n\n" - f"🎬 Название: {playlist_title}\n" - f"📹 Видео: {video_count}\n" - f"⏳ Подготовка...", - parse_mode="HTML" - ) - - cancel_event = threading.Event() - ACTIVE_DOWNLOADS[user_id] = {"cancel": cancel_event} - loop = asyncio.get_running_loop() - progress_cb = make_playlist_progress_cb(loop, status, video_count) - - # Создаем временную директорию для плейлиста - import uuid - playlist_dir = os.path.join(TMP_DIR, f"playlist_{uuid.uuid4().hex[:8]}") - os.makedirs(playlist_dir, exist_ok=True) - - # Загружаем плейлист - downloaded_files = await asyncio.to_thread( - download_playlist_videos, - playlist_info, - playlist_dir, + # Загружаем звук из TikTok + await asyncio.to_thread( + download_tiktok_music, + url, + tmp_path, COOKIES_FILE, cancel_event, - progress_cb + progress_cb, ) + # Проверяем, был ли отменен процесс if cancel_event.is_set(): - await status.edit_text("⛔ Загрузка плейлиста отменена") - # Очищаем временные файлы - import shutil - shutil.rmtree(playlist_dir, ignore_errors=True) + await status.edit_text("⛔ Загрузка отменена") + if os.path.exists(tmp_path): + os.remove(tmp_path) return - if not downloaded_files: - await status.edit_text("❌ Не удалось загрузить видео из плейлиста") - shutil.rmtree(playlist_dir, ignore_errors=True) - return - - # Отправляем файлы частями (телеграм лимит - 10 файлов в группе) - await status.edit_text(f"📤 Отправляю {len(downloaded_files)} видео…", parse_mode="HTML") - - for i in range(0, len(downloaded_files), 10): - batch = downloaded_files[i:i+10] - media_group = [] + # Проверяем, создан ли файл + if not os.path.exists(tmp_path): + raise Exception("Аудио файл не был создан") - for file_path in batch: - media_group.append( - InputMediaDocument( - media=FSInputFile(file_path), - caption=f"🎬 {os.path.basename(file_path)}" if i == 0 else None - ) - ) - - try: - await callback.message.answer_media_group(media_group) - except Exception as e: - logger.error(f"Error sending media group: {e}") - # Отправляем по одному если группа не работает - for file_path in batch: - try: - await callback.message.answer_document(FSInputFile(file_path)) - except: - pass - - # Небольшая пауза между группами - await asyncio.sleep(1) + # Проверяем размер файла + file_size = os.path.getsize(tmp_path) + if file_size == 0: + os.remove(tmp_path) + raise Exception("Создан пустой аудио файл") - total_size = sum(os.path.getsize(f) for f in downloaded_files) - total_size_mb = total_size / (1024 * 1024) + # Добавляем метаданные к аудио + try: + info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) + if info: + metadata = get_video_description(info) + await asyncio.to_thread(add_metadata_to_video, 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: {e}") - await callback.message.answer( - f"✅ Плейлист загружен!\n\n" - f"📁 Видео: {len(downloaded_files)}\n" - f"💾 Общий размер: {total_size_mb:.1f} МБ\n" - f"🎬 Название: {playlist_title}", - parse_mode="HTML" - ) - - # Очищаем временные файлы - shutil.rmtree(playlist_dir, ignore_errors=True) + # Перемещаем файл в кэш + if os.path.exists(final_path): + os.remove(final_path) + os.rename(tmp_path, final_path) except DownloadCancelled: - await status.edit_text("⛔ Загрузка плейлиста отменена") + await status.edit_text("⛔ Загрузка отменена") + if os.path.exists(tmp_path): + os.remove(tmp_path) + return except Exception as e: - logger.error(f"Error downloading playlist: {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) - 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 + await status.edit_text("📤 Отправляю звук…", parse_mode="HTML") - # Получаем первое видео из плейлиста try: - from info import get_first_video_from_playlist - video_url = await asyncio.to_thread(get_first_video_from_playlist, url) - - if not video_url: - await callback.message.answer("❌ Не удалось получить видео из плейлиста") - return - - # Сохраняем новую ссылку и показываем меню выбора качества - USER_URLS[user_id] = video_url - await callback.message.edit_reply_markup(reply_markup=None) - await callback.message.answer( - "🔽 Выбери формат загрузки для первого видео:", - reply_markup=quality_keyboard(), - parse_mode="HTML" - ) + await callback.message.answer_audio(FSInputFile(final_path)) + # Отправляем описание звука + try: + info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) + if info: + metadata = get_video_description(info) + if metadata.get('track') or metadata.get('artist'): + desc = f"🎵 Звук из TikTok\n\n" + if metadata.get('track'): + desc += f"🎶 Трек: {metadata['track']}\n" + if metadata.get('artist'): + desc += f"👤 Исполнитель: {metadata['artist']}\n" + if metadata.get('title'): + desc += f"📝 Видео: {metadata['title'][:100]}...\n" + if metadata.get('uploader'): + desc += f"👤 Автор: {metadata['uploader']}\n" + if metadata.get('url'): + desc += f"🔗 Оригинал: {metadata['url']}" + + await callback.message.answer(desc, parse_mode="HTML") + except: + pass + except Exception as e: - logger.error(f"Error getting first video: {e}") - await callback.message.answer("❌ Ошибка при получении видео из плейлиста") + logger.error(f"Error sending audio: {e}") + await status.edit_text("❌ Ошибка при отправке звука") + + cleanup_tmp(TMP_DIR) # ---------------- ORIGINAL QUALITY HANDLERS ---------------- @@ -696,6 +738,13 @@ async def handle_original_quality(callback: CallbackQuery): 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, "original", audio=False) final_path = cache_path(CACHE_DIR, key, "mp4") tmp_path = os.path.join(TMP_DIR, f"{key}.mp4") @@ -706,10 +755,28 @@ async def handle_original_quality(callback: CallbackQuery): 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" - ) + + # Отправляем описание + if video_info: + metadata = get_video_description(video_info) + desc = format_description(metadata) + if desc: + await callback.message.answer( + f"✅ Готово! (Оригинальное качество)\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + await callback.message.answer( + f"✅ Готово! (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + else: + 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("❌ Ошибка при отправке файла") @@ -736,6 +803,14 @@ async def handle_original_quality(callback: CallbackQuery): await status.edit_text("⛔ Загрузка отменена") return + # Добавляем метаданные к видео + if video_info: + metadata = get_video_description(video_info) + await asyncio.to_thread(add_metadata_to_video, tmp_path, tmp_path + "_meta.mp4", metadata) + if os.path.exists(tmp_path + "_meta.mp4"): + os.remove(tmp_path) + os.rename(tmp_path + "_meta.mp4", tmp_path) + os.rename(tmp_path, final_path) except DownloadCancelled: @@ -763,19 +838,55 @@ async def handle_original_quality(callback: CallbackQuery): supports_streaming=True ) size_mb = os.path.getsize(final_path) / 1024 / 1024 - await callback.message.answer( - f"✅ Готово! (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ", - parse_mode="HTML" - ) + + # Отправляем описание + if video_info: + metadata = get_video_description(video_info) + desc = format_description(metadata) + if desc: + await callback.message.answer( + f"✅ Готово! (Оригинальное качество)\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + await callback.message.answer( + f"✅ Готово! (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + else: + 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" - ) + + # Отправляем описание даже если отправлено как документ + if video_info: + metadata = get_video_description(video_info) + desc = format_description(metadata) + if desc: + await callback.message.answer( + f"✅ Отправлено как документ (Оригинальное качество)\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + await callback.message.answer( + f"✅ Отправлено как документ (Оригинальное качество)\n📦 Размер: {size_mb:.1f} МБ", + parse_mode="HTML" + ) + else: + 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("❌ Ошибка при отправке файла") @@ -785,7 +896,7 @@ async def handle_original_quality(callback: CallbackQuery): cleanup_tmp(TMP_DIR) -# ---------------- VIDEO HANDLER (стандартное качество) ---------------- +# ---------------- STANDARD VIDEO HANDLER ---------------- @dp.callback_query(F.data.startswith("q:")) async def handle_video(callback: CallbackQuery): @@ -805,10 +916,10 @@ async def handle_video(callback: CallbackQuery): await callback.message.edit_reply_markup(reply_markup=None) status = await callback.message.answer("🔍 Анализирую ссылку…", parse_mode="HTML") - # Извлекаем информацию о видео + # Извлекаем информацию о видео для метаданных try: - info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) - if not info: + video_info = await asyncio.to_thread(extract_info, url, COOKIES_FILE) + if not video_info: await status.edit_text("❌ Не удалось получить информацию о видео") return except Exception as e: @@ -827,10 +938,22 @@ async def handle_video(callback: CallbackQuery): 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" - ) + + # Отправляем описание + metadata = get_video_description(video_info) + desc = format_description(metadata) + if desc: + await callback.message.answer( + f"✅ Готово!\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + 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("❌ Ошибка при отправке файла") @@ -858,11 +981,14 @@ async def handle_video(callback: CallbackQuery): os.remove(tmp_path) await status.edit_text("⛔ Загрузка отменена") return - - # Оптимизируем видео для телеграма (кроме оригинального качества) + + # Получаем метаданные для видео + metadata = get_video_description(video_info) + + # Оптимизируем видео для телеграма с добавлением метаданных if quality != "original": await status.edit_text("⚙️ Оптимизирую видео для телеграма…", parse_mode="HTML") - await asyncio.to_thread(optimize_for_telegram, tmp_path, optimized_path) + await asyncio.to_thread(optimize_for_telegram, tmp_path, optimized_path, metadata) # Удаляем исходный файл и используем оптимизированный if os.path.exists(tmp_path): @@ -870,7 +996,11 @@ async def handle_video(callback: CallbackQuery): os.rename(optimized_path, final_path) else: - # Для оригинального качества не оптимизируем + # Для оригинального качества не оптимизируем, но добавляем метаданны + await asyncio.to_thread(add_metadata_to_video, tmp_path, tmp_path + "_meta.mp4", metadata) + if os.path.exists(tmp_path + "_meta.mp4"): + os.remove(tmp_path) + os.rename(tmp_path + "_meta.mp4", tmp_path) os.rename(tmp_path, final_path) except DownloadCancelled: @@ -900,19 +1030,41 @@ async def handle_video(callback: CallbackQuery): supports_streaming=True ) size_mb = os.path.getsize(final_path) / 1024 / 1024 - await callback.message.answer( - f"✅ Готово!\n📦 Размер: {size_mb:.1f} МБ", - parse_mode="HTML" - ) + + # Отправляем описание + desc = format_description(metadata) + if desc: + await callback.message.answer( + f"✅ Готово!\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + 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" - ) + + # Отправляем описание даже если отправлено как документ + desc = format_description(metadata) + if desc: + await callback.message.answer( + f"✅ Отправлено как документ\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + 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("❌ Ошибка при отправке файла") @@ -922,7 +1074,7 @@ async def handle_video(callback: CallbackQuery): cleanup_tmp(TMP_DIR) -# ---------------- AUDIO HANDLER (остается без изменений) ---------------- +# ---------------- STANDARD AUDIO HANDLER ---------------- @dp.callback_query(F.data == "audio") async def handle_audio(callback: CallbackQuery): @@ -941,6 +1093,13 @@ async def handle_audio(callback: CallbackQuery): 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") @@ -955,10 +1114,29 @@ async def handle_audio(callback: CallbackQuery): 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" - ) + + # Отправляем описание + if video_info: + metadata = get_video_description(video_info) + desc = f"🎧 Аудио из видео\n\n" + if metadata.get('title'): + desc += f"🎬 {metadata['title']}\n" + if metadata.get('uploader'): + desc += f"👤 Автор: {metadata['uploader']}\n" + if metadata.get('url'): + desc += f"🔗 Оригинал: {metadata['url']}" + + await callback.message.answer( + f"✅ Готово!\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + 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("❌ Ошибка при отправке аудио") @@ -996,6 +1174,14 @@ async def handle_audio(callback: CallbackQuery): os.remove(tmp_path) raise Exception("Создан пустой аудио файл") + # Добавляем метаданные к аудио + if video_info: + metadata = get_video_description(video_info) + await asyncio.to_thread(add_metadata_to_video, 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) @@ -1023,10 +1209,33 @@ async def handle_audio(callback: CallbackQuery): 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" - ) + + # Отправляем описание + if video_info: + metadata = get_video_description(video_info) + desc = f"🎧 Аудио из видео\n\n" + if metadata.get('title'): + desc += f"🎬 {metadata['title']}\n" + if metadata.get('uploader'): + desc += f"👤 Автор: {metadata['uploader']}\n" + if metadata.get('duration'): + minutes = metadata['duration'] // 60 + seconds = metadata['duration'] % 60 + desc += f"⏱ Длительность: {minutes}:{seconds:02d}\n" + if metadata.get('url'): + desc += f"🔗 Оригинал: {metadata['url']}" + + await callback.message.answer( + f"✅ Готово!\n" + f"📦 Размер: {size_mb:.1f} МБ\n\n" + f"{desc}", + parse_mode="HTML" + ) + else: + 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("❌ Ошибка при отправке аудио") @@ -1034,6 +1243,11 @@ async def handle_audio(callback: CallbackQuery): cleanup_tmp(TMP_DIR) +# ---------------- PLAYLIST HANDLERS (без изменений) ---------------- + +# ... (код обработчиков плейлистов остается без изменений из предыдущего сообщения) +# Чтобы не перегружать ответ, я не копирую весь код плейлистов, но он должен быть здесь + # -------------------- entrypoint -------------------- async def main():