From 839cd57f6f16687bcb7f7ce12f9fda1e0211106b Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 3 May 2026 01:56:31 +0300 Subject: [PATCH 01/10] fix: audio-only format, m4a/mp3 support, source URL in caption --- bot.py | 8 +-- youtube-downloader/app.py | 70 +++++++++++++++----------- youtube-downloader/youtube_cookies.txt | 10 ++-- 3 files changed, 50 insertions(+), 38 deletions(-) diff --git a/bot.py b/bot.py index 855f530..7818e79 100644 --- a/bot.py +++ b/bot.py @@ -562,18 +562,18 @@ async def process_queue_item(item: QueueItem): video_file = open(video_path, 'rb') caption = get_text(item.locale, 'caption', bot_username=TELEGRAM_BOT_USERNAME) + caption += f"\n\n{item.url}" # Определяем имя файла для отправки video_filename = Path(video_path).name - # Отправляем как документ, чтобы Telegram НЕ сжимал видео - # (reply_video сжимает, что приводит к потере качества и одинаковому размеру) + # Отправляем как документ — Telegram сам определит тип по расширению await item.original_message.reply_document( document=video_file, filename=video_filename, caption=caption, - read_timeout=600, # 10 минут на ответ от Telegram - write_timeout=600, # 10 минут на отправку файла + read_timeout=600, + write_timeout=600, connect_timeout=60, pool_timeout=60 ) diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index f7333ec..b6988aa 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -295,12 +295,20 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None combined_fallback = ['best[ext=mp4]/best', 'best'] requested_height = None # высота, запрошенная пользователем + is_audio_only = False if format_id: is_specific_code = not ('[' in format_id or ']' in format_id) + # Если format_id запрашивает только аудио, не подмешиваем видео-форматы + # и не валидируем наличие видео-потока + first_selector = format_id.split('/')[0] + is_audio_only = 'bestaudio' in first_selector requested_height = _extract_height_from_format_id(format_id) - if requested_height is not None: + if is_audio_only: + format_options = [format_id] + logger.info(f"[DOWNLOAD] Аудио-only режим, format_id: {format_id}") + elif requested_height is not None: # Конкретный format_id (из /formats) ставим ПЕРВЫМ — # он точно указывает выбранные пользователем format codes. # Height-ограниченный селектор идёт как fallback @@ -354,17 +362,16 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано с форматом {format_option}") - # Проверяем, что файл содержит видео-поток, а не только аудио - # (yt-dlp c allow_unplayable_formats может скачать av01 формат - # и отказаться от мержа, вернув только аудио) - downloaded = _find_latest_downloaded() - if downloaded and not _file_has_video_stream(downloaded): - logger.warning(f"[DOWNLOAD] Файл {downloaded.name} не содержит видео-потока (только аудио). Удаляем и пробуем следующий формат...") - try: - downloaded.unlink() - except Exception: - pass - continue + # Проверяем, что файл содержит видео-поток (только для видео-форматов) + if not is_audio_only: + downloaded = _find_latest_downloaded() + if downloaded and not _file_has_video_stream(downloaded): + logger.warning(f"[DOWNLOAD] Файл {downloaded.name} не содержит видео-потока (только аудио). Удаляем и пробуем следующий формат...") + try: + downloaded.unlink() + except Exception: + pass + continue download_success = True break @@ -392,14 +399,15 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None ydl.download([url]) logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies") - downloaded = _find_latest_downloaded() - if downloaded and not _file_has_video_stream(downloaded): - logger.warning(f"[DOWNLOAD] Файл {downloaded.name} без видео-потока. Удаляем и пробуем следующий формат...") - try: - downloaded.unlink() - except Exception: - pass - continue + if not is_audio_only: + downloaded = _find_latest_downloaded() + if downloaded and not _file_has_video_stream(downloaded): + logger.warning(f"[DOWNLOAD] Файл {downloaded.name} без видео-потока. Удаляем и пробуем следующий формат...") + try: + downloaded.unlink() + except Exception: + pass + continue download_success = True cookies_valid = False # Отключаем cookies для следующих попыток @@ -668,10 +676,10 @@ def get_youtube_formats(url: str) -> list[dict]: 'filesize_mb': round(total_size / 1024 / 1024, 1) if total_size else None, }) - # Добавляем аудиодорожку + # Добавляем аудиодорожку (M4A в приоритете — Telegram поддерживает только MP3/M4A для reply_audio) if best_audio_info['size']: result.append({ - 'format_id': 'bestaudio/best', + 'format_id': 'bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio/best', 'label': f"Audio only ({best_audio_info['ext']})", 'quality': 'audio', 'ext': best_audio_info['ext'], @@ -871,15 +879,19 @@ def download_stream(): # Безопасное имя файла без кириллицы для заголовка safe_filename = video_path.name.encode('ascii', 'ignore').decode('ascii') or 'youtube_video.mp4' - if not safe_filename.endswith(('.mp4', '.webm', '.mkv')): + if not safe_filename.endswith(('.mp4', '.webm', '.mkv', '.m4a', '.mp3')): safe_filename = 'youtube_video.mp4' - # Определяем content-type - content_type = 'video/mp4' - if video_path.suffix == '.webm': - content_type = 'video/webm' - elif video_path.suffix == '.mkv': - content_type = 'video/x-matroska' + # Определяем content-type по реальному расширению файла + ext = video_path.suffix.lower() + content_type_map = { + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', + '.mp4': 'video/mp4', + '.m4a': 'audio/mp4', + '.mp3': 'audio/mpeg', + } + content_type = content_type_map.get(ext, 'video/mp4') logger.info(f"[REQUEST {request_id}] Отправляем файл: {safe_filename}, Content-Type: {content_type}, размер: {len(video_data)} байт") diff --git a/youtube-downloader/youtube_cookies.txt b/youtube-downloader/youtube_cookies.txt index cb68911..393ef26 100644 --- a/youtube-downloader/youtube_cookies.txt +++ b/youtube-downloader/youtube_cookies.txt @@ -27,14 +27,14 @@ .youtube.com TRUE / FALSE 1776287761000 ST-yve142 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 1807824503515 __Secure-1PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA .youtube.com TRUE / TRUE 1807824503517 __Secure-3PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA -.youtube.com TRUE / TRUE 1793313256 VISITOR_INFO1_LIVE vFr43YvHJaE -.youtube.com TRUE / TRUE 1793313256 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D +.youtube.com TRUE / TRUE 1793314544 VISITOR_INFO1_LIVE vFr43YvHJaE +.youtube.com TRUE / TRUE 1793314544 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D .youtube.com TRUE / FALSE 1776288519000 ST-tladcw session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f6=40000000&hl=en .youtube.com TRUE / FALSE 1776288527000 ST-xuwub9 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1809297256 SIDCC AKEyXzVsS5YLUQD8z9C1v-mL2JIyS_lqX6qpnZKQ_AFrB5WfKI8t61IDvWwihKLswvR3ya_Y2JOP -.youtube.com TRUE / TRUE 1809297256 __Secure-1PSIDCC AKEyXzXDaKBNexbPjEPwCB8IDGZRrPTCTOVNDWgBRtsKv5XcaCCg5JxpeRXlk2gX4lidlrONyC52 -.youtube.com TRUE / TRUE 1809297256 __Secure-3PSIDCC AKEyXzUF0D1vjEN7XqU2ReXIRFuti0YZjmliSwyRcCUSq5rUlGmYoYmzjSngu8HtDmEiioigkUI9 +.youtube.com TRUE / FALSE 1809298544 SIDCC AKEyXzVa7lSTdOSv8wBZg33qEiiwbNCETtCNC9xssuu_BPPxNep2Lc2negI0NHWARFHb_MwD8V_c +.youtube.com TRUE / TRUE 1809298544 __Secure-1PSIDCC AKEyXzX8DfBF1L6BIhnm19j-ZBSyE16TpQVvn6vIBB_zA5dma1mjRuTuWTlS3ut3feYm1YMhme8q +.youtube.com TRUE / TRUE 1809298544 __Secure-3PSIDCC AKEyXzUfC0IlqutC5s9_cf3HrI_S4F8ui7NJPayGnr2TMOS5u0EuoUVZaKgJm6vklINRtl0oO9hR .youtube.com TRUE / FALSE 1776288585000 ST-3opvp5 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 0 YSC KTvrS45hA30 .instagram.com TRUE / TRUE 1801240452128 datr hGdNaS-QqakSYV8X2eqVTIyA From 59b1c54668a3c8f5155336349308461e558870eb Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 3 May 2026 02:39:27 +0300 Subject: [PATCH 02/10] fix: allow_unplayable_formats removed, reply_video, auto-select 480p, remove file sizes --- bot.py | 85 ++++++++++++++++++++++++++++++++++----- youtube-downloader/app.py | 6 --- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/bot.py b/bot.py index 7818e79..805a9dd 100644 --- a/bot.py +++ b/bot.py @@ -117,7 +117,7 @@ TEXTS = { 'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)", 'queue_position': "🕐 Ваше видео #{position} в очереди\nВаш запрос очень важен для нас!", 'queue_first': "⬇️ Скачиваю видео...", - 'select_quality': "Выберите качество видео:", + 'select_quality': "Выберите качество видео:\n(через 10 сек — автоскачивание лучшего)", 'quality_cancelled': "❌ Выбор отменён", 'fetching_formats': "🔍 Получаю доступные форматы...", }, @@ -174,7 +174,7 @@ TEXTS = { 'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB, max = 50)", 'queue_position': "🕐 Your video is #{position} in queue\nYour request is very important to us!", 'queue_first': "⬇️ Downloading video...", - 'select_quality': "Select video quality:", + 'select_quality': "Select video quality:\n(10 sec auto-download of best quality)", 'quality_cancelled': "❌ Cancelled", 'fetching_formats': "🔍 Fetching available formats...", } @@ -567,11 +567,12 @@ async def process_queue_item(item: QueueItem): # Определяем имя файла для отправки video_filename = Path(video_path).name - # Отправляем как документ — Telegram сам определит тип по расширению - await item.original_message.reply_document( - document=video_file, + # Отправляем как видео со streaming — встроенный плеер Telegram + await item.original_message.reply_video( + video=video_file, filename=video_filename, caption=caption, + supports_streaming=True, read_timeout=600, write_timeout=600, connect_timeout=60, @@ -1021,12 +1022,7 @@ async def show_quality_selection(status_message: Message, formats: list[dict], l """ keyboard = [] for idx, fmt in enumerate(formats): - label = fmt.get('label', fmt.get('quality', 'Unknown')) - filesize = fmt.get('filesize_mb') - if filesize: - button_text = f"{label} ({filesize:.0f} MB)" - else: - button_text = label + button_text = fmt.get('label', fmt.get('quality', 'Unknown')) keyboard.append([InlineKeyboardButton( text=button_text, callback_data=f"quality:{idx}" @@ -1055,6 +1051,10 @@ async def handle_format_selection(update: Update, context: ContextTypes.DEFAULT_ # Получаем сохраненные данные data = context.user_data.pop(f'quality_{chat_id}', None) + # Отменяем авто-выбор качества + auto_task = context.user_data.pop(f'quality_auto_{chat_id}', None) + if auto_task: + auto_task.cancel() if not data: await query.edit_message_text("Session expired, please send the link again") return @@ -1104,6 +1104,66 @@ async def handle_format_selection(update: Update, context: ContextTypes.DEFAULT_ ) +async def _auto_select_after_delay(context: ContextTypes.DEFAULT_TYPE, chat_id: int, delay: int = 10): + """Автовыбор лучшего качества через delay секунд, если пользователь не выбрал""" + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return # пользователь выбрал вручную + + data = context.user_data.pop(f'quality_{chat_id}', None) + if not data: + return # уже обработано + + context.user_data.pop(f'quality_auto_{chat_id}', None) # чистим + + formats_list = data.get('formats_list', []) + if not formats_list: + return + + locale = data['locale'] + status_message = data['status_message'] + + # Автовыбор: ищем 480p или ближайшее ниже + preferred_qualities = ['480p', '360p', '240p', '144p'] + selected = None + for pq in preferred_qualities: + for fmt in formats_list: + if fmt.get('quality', '') == pq: + selected = fmt + break + if selected: + break + if not selected: + selected = formats_list[0] # fallback на лучшее + + format_id = selected.get('format_id', '') + quality_label = selected.get('quality', selected.get('label', 'best')) + + logger.info(f"Автовыбор качества для chat_id={chat_id}: {quality_label}") + + await status_message.edit_text(get_text(locale, 'processing')) + + item = QueueItem( + original_message=data['original_message'], + status_message=status_message, + url=data['url'], + chat_id=chat_id, + chat_type=data['chat_type'], + locale=locale, + format_id=format_id + ) + + position = await add_to_queue(item) + + if position == 1: + await status_message.edit_text(get_text(locale, 'queue_first')) + else: + await status_message.edit_text( + get_text(locale, 'queue_position', position=position) + ) + + async def download_video(url: str, chat_id: int, locale: str, max_retries: int = 3, format_id: str | None = None) -> str: """Главная функция скачивания - вызывает нужную функцию в зависимости от источника""" source = detect_video_source(url) @@ -1186,6 +1246,9 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): 'formats_list': formats, # для lookup по индексу в callback } await show_quality_selection(status_message, formats, locale) + # Автовыбор лучшего качества через 10 сек + auto_task = asyncio.create_task(_auto_select_after_delay(context, chat_id, 10)) + context.user_data[f'quality_auto_{chat_id}'] = auto_task return # Если не удалось получить форматы, скачиваем как обычно (без выбора качества) await status_message.edit_text(get_text(locale, 'processing')) diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index b6988aa..87223b9 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -334,12 +334,7 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None ydl_opts_download.update({ 'format': format_option, 'outtmpl': _safe_filename(video_title), - # fragment_retries — для DASH форматов (видео без аудио), - # YouTube может разрывать фрагменты; увеличиваем retries 'fragment_retries': 3, - # allow_unplayable_formats — позволяет скачивать форматы, - # которые YouTube помечает как "недоступные" для сторонних клиентов - 'allow_unplayable_formats': True, }) use_cookies_this_attempt = cookies_valid @@ -392,7 +387,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None 'format': format_option, 'outtmpl': _safe_filename(video_title), 'fragment_retries': 3, - 'allow_unplayable_formats': True, }) try: with yt_dlp.YoutubeDL(ydl_opts_download_no_cookies) as ydl: From c1d8a8235d5c2b27d5dae22b93d7e813406809a1 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 3 May 2026 03:22:26 +0300 Subject: [PATCH 03/10] fix: prefer Russian audio track, add FFmpegFixupStretched, remove broken h264_metadata bsf --- youtube-downloader/app.py | 27 ++++++++++++++++++++------ youtube-downloader/youtube_cookies.txt | 10 +++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index 87223b9..d1f243f 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -150,6 +150,8 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) 'user_agent': user_agent, 'socket_timeout': 60, 'extractor_retries': 3, + # Предпочитаем русскую озвучку — YouTube иногда подсовывает авто-дубляж на английском + 'format_sort': ['lang:ru'], 'http_headers': { 'User-Agent': user_agent, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', @@ -285,9 +287,9 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None # Это важно, т.к. format_id из get_youtube_formats() может не совпадать # с format_id при повторном extract_info() в download_youtube_video(). default_format_options = [ - 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', + 'bestvideo[ext=mp4]+bestaudio[language=ru][ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', 'best[ext=mp4]/best', - 'bestvideo+bestaudio/best', + 'bestvideo+bestaudio[language=ru]/bestvideo+bestaudio/best', 'best', ] @@ -335,6 +337,8 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None 'format': format_option, 'outtmpl': _safe_filename(video_title), 'fragment_retries': 3, + # Явно включаем фикс растянутого/сплющенного aspect ratio + 'postprocessors': [{'key': 'FFmpegFixupStretched'}], }) use_cookies_this_attempt = cookies_valid @@ -650,17 +654,28 @@ def get_youtube_formats(url: str) -> list[dict]: logger.info(f"[FORMATS] {display_label} (height={best_video_height}): video_size={video_size}, has_audio={has_audio}, total={total_size}, format_id={video_format_id}") - # format_selector без /best в конце — чтобы yt-dlp не молча скатывался на другой размер + # format_selector: предпочитаем русскую озвучку (YouTube Shorts часто авто-дублирует) if has_audio: - format_selector = f"{video_format_id}/bestvideo[height<={best_video_height}]+bestaudio/best[height<={best_video_height}]" + format_selector = ( + f"{video_format_id}/" + f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" + f"bestvideo[height<={best_video_height}]+bestaudio/" + f"best[height<={best_video_height}]" + ) elif best_audio_info['format_id']: format_selector = ( f"{video_format_id}+{best_audio_info['format_id']}/" + f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" f"bestvideo[height<={best_video_height}]+bestaudio/" f"best[height<={best_video_height}]" ) else: - format_selector = f"{video_format_id}+bestaudio/bestvideo[height<={best_video_height}]+bestaudio/best[height<={best_video_height}]" + format_selector = ( + f"{video_format_id}+bestaudio[language=ru]/" + f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" + f"bestvideo[height<={best_video_height}]+bestaudio/" + f"best[height<={best_video_height}]" + ) result.append({ 'format_id': format_selector, @@ -673,7 +688,7 @@ def get_youtube_formats(url: str) -> list[dict]: # Добавляем аудиодорожку (M4A в приоритете — Telegram поддерживает только MP3/M4A для reply_audio) if best_audio_info['size']: result.append({ - 'format_id': 'bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio/best', + 'format_id': 'bestaudio[language=ru][ext=m4a]/bestaudio[language=ru][ext=mp3]/bestaudio[language=ru]/bestaudio/best', 'label': f"Audio only ({best_audio_info['ext']})", 'quality': 'audio', 'ext': best_audio_info['ext'], diff --git a/youtube-downloader/youtube_cookies.txt b/youtube-downloader/youtube_cookies.txt index 393ef26..1994e10 100644 --- a/youtube-downloader/youtube_cookies.txt +++ b/youtube-downloader/youtube_cookies.txt @@ -27,14 +27,14 @@ .youtube.com TRUE / FALSE 1776287761000 ST-yve142 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 1807824503515 __Secure-1PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA .youtube.com TRUE / TRUE 1807824503517 __Secure-3PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA -.youtube.com TRUE / TRUE 1793314544 VISITOR_INFO1_LIVE vFr43YvHJaE -.youtube.com TRUE / TRUE 1793314544 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D +.youtube.com TRUE / TRUE 1793319671 VISITOR_INFO1_LIVE vFr43YvHJaE +.youtube.com TRUE / TRUE 1793319671 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D .youtube.com TRUE / FALSE 1776288519000 ST-tladcw session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f6=40000000&hl=en .youtube.com TRUE / FALSE 1776288527000 ST-xuwub9 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1809298544 SIDCC AKEyXzVa7lSTdOSv8wBZg33qEiiwbNCETtCNC9xssuu_BPPxNep2Lc2negI0NHWARFHb_MwD8V_c -.youtube.com TRUE / TRUE 1809298544 __Secure-1PSIDCC AKEyXzX8DfBF1L6BIhnm19j-ZBSyE16TpQVvn6vIBB_zA5dma1mjRuTuWTlS3ut3feYm1YMhme8q -.youtube.com TRUE / TRUE 1809298544 __Secure-3PSIDCC AKEyXzUfC0IlqutC5s9_cf3HrI_S4F8ui7NJPayGnr2TMOS5u0EuoUVZaKgJm6vklINRtl0oO9hR +.youtube.com TRUE / FALSE 1809303671 SIDCC AKEyXzUEfCEbgyGrH1tNORzoWjcQ4T0V72l_bkp7S12EcJE1BUmxvYDz5rhspZYtTtFg7hzpWkbN +.youtube.com TRUE / TRUE 1809303671 __Secure-1PSIDCC AKEyXzWZMgqSjtmJ_uHCIE-o8z-H3jLbJznGybAMvRcbKNzCej_YDlmQ-ScNrcaJ58mYfK6iaOei +.youtube.com TRUE / TRUE 1809303671 __Secure-3PSIDCC AKEyXzU4244NG2Eo-friqIol4JdiWYR7Ew-fg1o9TggXgU7NJqk_X_6o7WvvME6so9LxwmuM3lmj .youtube.com TRUE / FALSE 1776288585000 ST-3opvp5 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 0 YSC KTvrS45hA30 .instagram.com TRUE / TRUE 1801240452128 datr hGdNaS-QqakSYV8X2eqVTIyA From e2ead9db52adae776d65f1a22705bed158c70141 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 3 May 2026 03:27:21 +0300 Subject: [PATCH 04/10] tweak: simplify auto-download text --- bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot.py b/bot.py index 805a9dd..15d52af 100644 --- a/bot.py +++ b/bot.py @@ -117,7 +117,7 @@ TEXTS = { 'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)", 'queue_position': "🕐 Ваше видео #{position} в очереди\nВаш запрос очень важен для нас!", 'queue_first': "⬇️ Скачиваю видео...", - 'select_quality': "Выберите качество видео:\n(через 10 сек — автоскачивание лучшего)", + 'select_quality': "Выберите качество видео:\n(через 10 сек — автоскачивание)", 'quality_cancelled': "❌ Выбор отменён", 'fetching_formats': "🔍 Получаю доступные форматы...", }, @@ -174,7 +174,7 @@ TEXTS = { 'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB, max = 50)", 'queue_position': "🕐 Your video is #{position} in queue\nYour request is very important to us!", 'queue_first': "⬇️ Downloading video...", - 'select_quality': "Select video quality:\n(10 sec auto-download of best quality)", + 'select_quality': "Select video quality:\n(10 sec auto-download)", 'quality_cancelled': "❌ Cancelled", 'fetching_formats': "🔍 Fetching available formats...", } From 60a0373d7f1ba3a0773830b83adcdb355bb8eebf Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 3 May 2026 17:43:24 +0300 Subject: [PATCH 05/10] fix: remove client-side format cache, prefer first audio track over largest --- bot.py | 27 +---- youtube-downloader/app.py | 134 ++++++++++++++++--------- youtube-downloader/youtube_cookies.txt | 10 +- 3 files changed, 90 insertions(+), 81 deletions(-) diff --git a/bot.py b/bot.py index 15d52af..2711125 100644 --- a/bot.py +++ b/bot.py @@ -22,10 +22,6 @@ HTTP_TIMEOUT = httpx.Timeout(connect=None, read=None, write=None, pool=None) # Таймаут для запроса форматов (не такой критичный, но не должен висеть вечно) FORMATS_TIMEOUT = httpx.Timeout(connect=15, read=30, write=15, pool=15) -# Клиентский кэш форматов: {normalized_url: (timestamp, formats)} -_formats_cache: dict[str, tuple[float, list[dict]]] = {} -_FORMATS_CACHE_TTL = 30 * 60 # 30 минут - # Настройка логирования logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', @@ -971,29 +967,10 @@ async def download_tiktok_video(url: str, chat_id: int, max_retries: int = 3) -> # ============================================================================ -def _normalize_youtube_url_for_cache(url: str) -> str: - """Нормализует URL для кэша: оставляет только video ID""" - import re - m = re.search(r'(youtu\.be/|youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})', url) - if m: - return f"https://www.youtube.com/watch?v={m.group(2)}" - return url - - async def get_formats_from_service(url: str) -> list[dict] | None: """Получает список доступных форматов для YouTube URL через сервис youtube-downloader""" logger.info(f"Получение форматов для YouTube: {url}") - cache_key = _normalize_youtube_url_for_cache(url) - now = time.time() - - if cache_key in _formats_cache: - cached_time, cached_formats = _formats_cache[cache_key] - if now - cached_time < _FORMATS_CACHE_TTL: - logger.info(f"Форматы взяты из кэша ({len(cached_formats)} шт., возраст {now - cached_time:.0f}с)") - return cached_formats - del _formats_cache[cache_key] - try: async with httpx.AsyncClient(timeout=FORMATS_TIMEOUT) as client: response = await client.post( @@ -1003,9 +980,7 @@ async def get_formats_from_service(url: str) -> list[dict] | None: ) if response.status_code == 200: data = response.json() - formats = data.get('formats', []) - _formats_cache[cache_key] = (time.time(), formats) - return formats + return data.get('formats', []) logger.warning(f"Не удалось получить форматы: {response.status_code}") return None except Exception as e: diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index d1f243f..82daab3 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -150,8 +150,6 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) 'user_agent': user_agent, 'socket_timeout': 60, 'extractor_retries': 3, - # Предпочитаем русскую озвучку — YouTube иногда подсовывает авто-дубляж на английском - 'format_sort': ['lang:ru'], 'http_headers': { 'User-Agent': user_agent, 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', @@ -175,6 +173,68 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) return opts +def _find_video_file() -> Path | None: + """Находит видеофайл среди загрузок. Если видео+аудио раздельные — мержит ffmpeg. + Возвращает путь к готовому видеофайлу или None если видео нет.""" + files = list(DOWNLOADS_DIR.glob('*')) + if not files: + return None + files.sort(key=lambda x: x.stat().st_mtime, reverse=True) + + video_file = None + audio_file = None + + for f in files: + if f.suffix in ('.part', '.ytdl'): + continue + if _file_has_video_stream(f): + has_audio = _file_has_audio_stream(f) + if has_audio: + return f # уже смерженный или combined — сразу возвращаем + if video_file is None: + video_file = f + elif not audio_file and _file_has_audio_stream(f): + audio_file = f + + if video_file is None: + return None + + if audio_file: + # Мержим видео + аудио через ffmpeg + import subprocess + merged = DOWNLOADS_DIR / f"{video_file.stem}_merged{video_file.suffix}" + logger.info(f"[MERGE] Мержим {video_file.name} + {audio_file.name} → {merged.name}") + result = subprocess.run( + ['ffmpeg', '-y', '-i', str(video_file), '-i', str(audio_file), + '-c', 'copy', '-map', '0:v:0', '-map', '1:a:0', str(merged)], + capture_output=True, text=True, timeout=120 + ) + if result.returncode != 0: + logger.error(f"[MERGE] Ошибка ffmpeg: {result.stderr[-300:]}") + return video_file # fallback — только видео + + video_file.unlink(missing_ok=True) + audio_file.unlink(missing_ok=True) + return merged + + return video_file + + +def _file_has_audio_stream(filepath: Path) -> bool: + """Проверяет через ffprobe, содержит ли файл аудио-поток.""" + import subprocess + try: + result = subprocess.run( + ['ffprobe', '-v', 'error', '-select_streams', 'a:0', + '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', + str(filepath)], + capture_output=True, text=True, timeout=15 + ) + return result.stdout.strip() == 'audio' + except Exception: + return False + + def _find_latest_downloaded() -> Path | None: """Возвращает самый свежий файл в папке загрузок.""" files = list(DOWNLOADS_DIR.glob('*')) @@ -287,9 +347,9 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None # Это важно, т.к. format_id из get_youtube_formats() может не совпадать # с format_id при повторном extract_info() в download_youtube_video(). default_format_options = [ - 'bestvideo[ext=mp4]+bestaudio[language=ru][ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', + 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', 'best[ext=mp4]/best', - 'bestvideo+bestaudio[language=ru]/bestvideo+bestaudio/best', + 'bestvideo+bestaudio/best', 'best', ] @@ -337,7 +397,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None 'format': format_option, 'outtmpl': _safe_filename(video_title), 'fragment_retries': 3, - # Явно включаем фикс растянутого/сплющенного aspect ratio 'postprocessors': [{'key': 'FFmpegFixupStretched'}], }) @@ -360,18 +419,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}") logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано с форматом {format_option}") - - # Проверяем, что файл содержит видео-поток (только для видео-форматов) - if not is_audio_only: - downloaded = _find_latest_downloaded() - if downloaded and not _file_has_video_stream(downloaded): - logger.warning(f"[DOWNLOAD] Файл {downloaded.name} не содержит видео-потока (только аудио). Удаляем и пробуем следующий формат...") - try: - downloaded.unlink() - except Exception: - pass - continue - download_success = True break except Exception as download_error: @@ -396,17 +443,6 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None with yt_dlp.YoutubeDL(ydl_opts_download_no_cookies) as ydl: ydl.download([url]) logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies") - - if not is_audio_only: - downloaded = _find_latest_downloaded() - if downloaded and not _file_has_video_stream(downloaded): - logger.warning(f"[DOWNLOAD] Файл {downloaded.name} без видео-потока. Удаляем и пробуем следующий формат...") - try: - downloaded.unlink() - except Exception: - pass - continue - download_success = True cookies_valid = False # Отключаем cookies для следующих попыток break @@ -600,10 +636,9 @@ def get_youtube_formats(url: str) -> list[dict]: if vcodec != 'none' and height > 0: available_heights.add(height) - if vcodec == 'none' and acodec != 'none': - fs = _get_filesize(f) - if fs > best_audio_info['size']: - best_audio_info = {'size': fs, 'ext': f.get('ext', 'm4a'), 'format_id': format_id} + if vcodec == 'none' and acodec != 'none' and best_audio_info['format_id'] is None: + # Берём первый попавшийся аудиоформат — yt-dlp отдаёт оригинал первым + best_audio_info = {'size': _get_filesize(f), 'ext': f.get('ext', 'm4a'), 'format_id': format_id} logger.info(f"[FORMATS] Доступные разрешения: {sorted(available_heights)}") logger.info(f"[FORMATS] Лучший аудиопоток: {best_audio_info['size']} bytes, {best_audio_info['ext']}, format_id={best_audio_info['format_id']}") @@ -620,16 +655,27 @@ def get_youtube_formats(url: str) -> list[dict]: best_video = None best_video_height = 0 + is_best_dash = False for f in formats: vcodec = f.get('vcodec', 'none') height = _parse_height(f) - if vcodec == 'none' or height <= 0: + if vcodec == 'none' or height <= 0 or height > max_height: continue - if height <= max_height and height > best_video_height: + + is_dash = (f.get('acodec', 'none') == 'none') + pick = False + + if height > best_video_height: + pick = True + elif height == best_video_height and is_dash and not is_best_dash: + pick = True + + if pick: best_video = f best_video_height = height + is_best_dash = is_dash if not best_video: continue @@ -654,28 +700,16 @@ def get_youtube_formats(url: str) -> list[dict]: logger.info(f"[FORMATS] {display_label} (height={best_video_height}): video_size={video_size}, has_audio={has_audio}, total={total_size}, format_id={video_format_id}") - # format_selector: предпочитаем русскую озвучку (YouTube Shorts часто авто-дублирует) if has_audio: - format_selector = ( - f"{video_format_id}/" - f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" - f"bestvideo[height<={best_video_height}]+bestaudio/" - f"best[height<={best_video_height}]" - ) + format_selector = f"{video_format_id}/best[height<={best_video_height}]/best" elif best_audio_info['format_id']: format_selector = ( f"{video_format_id}+{best_audio_info['format_id']}/" - f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" f"bestvideo[height<={best_video_height}]+bestaudio/" f"best[height<={best_video_height}]" ) else: - format_selector = ( - f"{video_format_id}+bestaudio[language=ru]/" - f"bestvideo[height<={best_video_height}]+bestaudio[language=ru]/" - f"bestvideo[height<={best_video_height}]+bestaudio/" - f"best[height<={best_video_height}]" - ) + format_selector = f"{video_format_id}+bestaudio/best[height<={best_video_height}]/best" result.append({ 'format_id': format_selector, @@ -685,10 +719,10 @@ def get_youtube_formats(url: str) -> list[dict]: 'filesize_mb': round(total_size / 1024 / 1024, 1) if total_size else None, }) - # Добавляем аудиодорожку (M4A в приоритете — Telegram поддерживает только MP3/M4A для reply_audio) + # Добавляем аудиодорожку if best_audio_info['size']: result.append({ - 'format_id': 'bestaudio[language=ru][ext=m4a]/bestaudio[language=ru][ext=mp3]/bestaudio[language=ru]/bestaudio/best', + 'format_id': 'bestaudio/best', 'label': f"Audio only ({best_audio_info['ext']})", 'quality': 'audio', 'ext': best_audio_info['ext'], diff --git a/youtube-downloader/youtube_cookies.txt b/youtube-downloader/youtube_cookies.txt index 1994e10..6bb31ca 100644 --- a/youtube-downloader/youtube_cookies.txt +++ b/youtube-downloader/youtube_cookies.txt @@ -27,14 +27,14 @@ .youtube.com TRUE / FALSE 1776287761000 ST-yve142 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 1807824503515 __Secure-1PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA .youtube.com TRUE / TRUE 1807824503517 __Secure-3PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA -.youtube.com TRUE / TRUE 1793319671 VISITOR_INFO1_LIVE vFr43YvHJaE -.youtube.com TRUE / TRUE 1793319671 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D +.youtube.com TRUE / TRUE 1793371363 VISITOR_INFO1_LIVE vFr43YvHJaE +.youtube.com TRUE / TRUE 1793371363 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D .youtube.com TRUE / FALSE 1776288519000 ST-tladcw session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f6=40000000&hl=en .youtube.com TRUE / FALSE 1776288527000 ST-xuwub9 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1809303671 SIDCC AKEyXzUEfCEbgyGrH1tNORzoWjcQ4T0V72l_bkp7S12EcJE1BUmxvYDz5rhspZYtTtFg7hzpWkbN -.youtube.com TRUE / TRUE 1809303671 __Secure-1PSIDCC AKEyXzWZMgqSjtmJ_uHCIE-o8z-H3jLbJznGybAMvRcbKNzCej_YDlmQ-ScNrcaJ58mYfK6iaOei -.youtube.com TRUE / TRUE 1809303671 __Secure-3PSIDCC AKEyXzU4244NG2Eo-friqIol4JdiWYR7Ew-fg1o9TggXgU7NJqk_X_6o7WvvME6so9LxwmuM3lmj +.youtube.com TRUE / FALSE 1809355363 SIDCC AKEyXzXpgpaN5o2kMTOlzAUXbOF4YCfxl41G6ZutKAOlEzpeIdskBxux3PemZB5T0T-hVrRplzEw +.youtube.com TRUE / TRUE 1809355363 __Secure-1PSIDCC AKEyXzVOgdvJviP9-nUDUnv0MXGY5L73_iQ1heqaYPuKh8JEpSnrLPN7dDb40XFFWlASf3sYC5Td +.youtube.com TRUE / TRUE 1809355363 __Secure-3PSIDCC AKEyXzUfDASoJeAIdtNkO6vz8MF2NHsJWHdYWfhJlNp6RDnIIzD3XIv2eE7uff2BsY-JaNw5ANNX .youtube.com TRUE / FALSE 1776288585000 ST-3opvp5 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 0 YSC KTvrS45hA30 .instagram.com TRUE / TRUE 1801240452128 datr hGdNaS-QqakSYV8X2eqVTIyA From 5d3cd92a0327b6986cc04cf6793a390cf90a446c Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sat, 9 May 2026 16:58:12 +0300 Subject: [PATCH 06/10] fix: add vk.ru domain support for VK videos --- bot.py | 2 +- vk-downloader/app.py | 4 ++-- youtube-downloader/youtube_cookies.txt | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bot.py b/bot.py index 2711125..81d47ec 100644 --- a/bot.py +++ b/bot.py @@ -386,7 +386,7 @@ def detect_video_source(url: str) -> str: return 'youtube' elif 'instagram.com' in domain: return 'instagram' - elif 'vk.com' in domain or 'vkontakte.ru' in domain: + elif 'vk.com' in domain or 'vk.ru' in domain or 'vkontakte.ru' in domain: return 'vk' elif 'yapfiles.ru' in domain: return 'yapfiles' diff --git a/vk-downloader/app.py b/vk-downloader/app.py index 5e08033..1e47538 100644 --- a/vk-downloader/app.py +++ b/vk-downloader/app.py @@ -119,7 +119,7 @@ def download(): logger.info(f"Получен запрос на скачивание: {url}") # Проверяем, что это VK URL - if 'vk.com' not in url and 'vkontakte.ru' not in url: + if 'vk.com' not in url and 'vk.ru' not in url and 'vkontakte.ru' not in url: return jsonify({'error': 'Only VK URLs are supported'}), 400 # Скачиваем видео @@ -151,7 +151,7 @@ def download_stream(): logger.info(f"Получен запрос на скачивание (stream): {url}") # Проверяем, что это VK URL - if 'vk.com' not in url and 'vkontakte.ru' not in url: + if 'vk.com' not in url and 'vk.ru' not in url and 'vkontakte.ru' not in url: return jsonify({'error': 'Only VK URLs are supported'}), 400 # Скачиваем видео diff --git a/youtube-downloader/youtube_cookies.txt b/youtube-downloader/youtube_cookies.txt index 6bb31ca..d5f4b61 100644 --- a/youtube-downloader/youtube_cookies.txt +++ b/youtube-downloader/youtube_cookies.txt @@ -22,19 +22,19 @@ .youtube.com TRUE / TRUE 1840832909 __Secure-1PSID g.a0009ghwIHcIZqqcY1WV989v420rAlDDepZEj46RPYROUv0etocZUxys_3LZCIbQ68z8wQ2c_wACgYKAa0SARISFQHGX2MijinAP4ZtZI0DQThrXUqIpBoVAUF8yKpJ3DA2uat4crjoKJ6Yo6zB0076 .youtube.com TRUE / TRUE 1840832909 __Secure-3PSID g.a0009ghwIHcIZqqcY1WV989v420rAlDDepZEj46RPYROUv0etocZGrDGBah2HczPmxnQjYdK1gACgYKAQQSARISFQHGX2MiACXkSAqve--bS36VrXAmBhoVAUF8yKrUoj5-724_fZGNhKeh8uds0076 .youtube.com TRUE / TRUE 1791136352432 __Secure-BUCKET CMoC -.youtube.com TRUE / TRUE 1793312896 __Secure-YNID 18.YT=kZLd9ecftHf18l5BUM_PFt6TXFNyMV5Nrka7bly97iA8f-psa9ha5DWmRmQSUzQRIfbiEoZ-RXeTw9IwgSWh5KLafRfZ9G8RgoXgoCnFvqeZrYclpbVjM3oc-97z9Q8Dt9n1BBJ37qbHuUjRqHafR2RXkJDlPa3EKqkmA7DIUSLwwu6XhIuJZ8aSIo36ci-KXL2w6XO1qaDVQG6FK7k5J3714s86d45YL0h_vwtlVVayUEtgLX4UL-kAqBaOkUt-ZuhhzfayX2z-JUVu_U2fl8lhNUfbKDbVgeFT1eLGQIrGprZAeQP9QK-y383E2ZAW3tSVB-OT2-WST2I8tN557w -.youtube.com TRUE / TRUE 1793312896 __Secure-ROLLOUT_TOKEN CKPS2eDK6Lu50QEQwdv1spXZkQMY4vDstdOblAM%3D +.youtube.com TRUE / TRUE 1793886853 __Secure-YNID 18.YT=HI8YHeAKQNimdb5LgRIak_GASZ4Wh5RTxg2Wxl3jX_nQk7nUzmgM8MBzMMFzsXHCbpxvvhwa60OjexrdexVxJX1wLjnuUJIiMli1BVTumhhrAbZJ4jh1piZ6XcxgkeGJa7b1NmLbOKo8PviUQiLyHjbs5sFYwufNov8shQZdO2CM6yiBL0feOdLihR9bw8HOIxpbJczRhEpYEbvpYnI3frOeds1xbCEpf3Abr7yxs6XxrZEpxa3djITU1nYva2Gjug-72qEaOm7uhS4V-A-mkqIheEswKC9X3P1OPyMPK6DS6-95mqkeI8lkDcyhb0jqR5gidkMH8ZotxNpNSmPB7w +.youtube.com TRUE / TRUE 1793886853 __Secure-ROLLOUT_TOKEN CKPS2eDK6Lu50QEQwdv1spXZkQMYvYeHyq2slAM%3D .youtube.com TRUE / FALSE 1776287761000 ST-yve142 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 1807824503515 __Secure-1PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA .youtube.com TRUE / TRUE 1807824503517 __Secure-3PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA -.youtube.com TRUE / TRUE 1793371363 VISITOR_INFO1_LIVE vFr43YvHJaE -.youtube.com TRUE / TRUE 1793371363 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D +.youtube.com TRUE / TRUE 1793886874 VISITOR_INFO1_LIVE vFr43YvHJaE +.youtube.com TRUE / TRUE 1793886874 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D .youtube.com TRUE / FALSE 1776288519000 ST-tladcw session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f6=40000000&hl=en .youtube.com TRUE / FALSE 1776288527000 ST-xuwub9 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1809355363 SIDCC AKEyXzXpgpaN5o2kMTOlzAUXbOF4YCfxl41G6ZutKAOlEzpeIdskBxux3PemZB5T0T-hVrRplzEw -.youtube.com TRUE / TRUE 1809355363 __Secure-1PSIDCC AKEyXzVOgdvJviP9-nUDUnv0MXGY5L73_iQ1heqaYPuKh8JEpSnrLPN7dDb40XFFWlASf3sYC5Td -.youtube.com TRUE / TRUE 1809355363 __Secure-3PSIDCC AKEyXzUfDASoJeAIdtNkO6vz8MF2NHsJWHdYWfhJlNp6RDnIIzD3XIv2eE7uff2BsY-JaNw5ANNX +.youtube.com TRUE / FALSE 1809870874 SIDCC AKEyXzXxD9dGVTRHii-ZSRXa5S1jG0nc_r5gPRZM_Q7R_SLG6inpYAArIdKqekxyCRG2fYQBknBS +.youtube.com TRUE / TRUE 1809870874 __Secure-1PSIDCC AKEyXzWA_Zl95hX_7PFnMrNh13Lx3gpntyOhJBaDO_WSRz37gqrtGtfMYVlovN3fCHG4evRFjRgO +.youtube.com TRUE / TRUE 1809870874 __Secure-3PSIDCC AKEyXzUd2Cpu9M26M0TfDtVLBG_-eqaREqqYreCv-9GHeMq4qPIt97VsHDJUTWkws5O_8vxSQjpC .youtube.com TRUE / FALSE 1776288585000 ST-3opvp5 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn .youtube.com TRUE / TRUE 0 YSC KTvrS45hA30 .instagram.com TRUE / TRUE 1801240452128 datr hGdNaS-QqakSYV8X2eqVTIyA From a9d1ffc864ad6ef430d28ff9cea3b385c74f7653 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Mon, 25 May 2026 16:46:35 +0000 Subject: [PATCH 07/10] fix: prevent VK downloader from blocking queue --- bot.py | 31 ++++++++++++++++++++++++++++--- vk-downloader/Dockerfile | 7 ++++--- vk-downloader/docker-compose.yml | 7 ++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/bot.py b/bot.py index 81d47ec..25f9e3b 100644 --- a/bot.py +++ b/bot.py @@ -15,9 +15,10 @@ from telegram.request import HTTPXRequest from dataclasses import dataclass from typing import Optional -# Таймаут для HTTP запросов -# Все таймауты убраны - видео может качаться и отправляться очень долго -HTTP_TIMEOUT = httpx.Timeout(connect=None, read=None, write=None, pool=None) +# Таймаут для HTTP запросов к downloader-сервисам. +# Без ограничений один зависший микросервис навсегда блокирует единственный +# queue_worker и вся очередь перестает двигаться. +HTTP_TIMEOUT = httpx.Timeout(connect=10, read=300, write=30, pool=30) # Таймаут для запроса форматов (не такой критичный, но не должен висеть вечно) FORMATS_TIMEOUT = httpx.Timeout(connect=15, read=30, write=15, pool=15) @@ -110,6 +111,7 @@ TEXTS = { 'caption': "Видео скачано с @{bot_username}", 'error': "❌ Произошла ошибка при обработке видео:\n{error}", 'error_unknown_source': "Пардон, не умеем работать с этим источником", + 'error_vk_not_video': "❌ Это ссылка VK, но не на видео. Пришлите ссылку вида vk.com/video... или vk.com/clip...", 'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)", 'queue_position': "🕐 Ваше видео #{position} в очереди\nВаш запрос очень важен для нас!", 'queue_first': "⬇️ Скачиваю видео...", @@ -167,6 +169,7 @@ TEXTS = { 'caption': "Video downloaded via @{bot_username}", 'error': "❌ Error processing video:\n{error}", 'error_unknown_source': "Sorry, this source is not supported", + 'error_vk_not_video': "❌ This is a VK link, but not a video. Send a vk.com/video... or vk.com/clip... link.", 'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB, max = 50)", 'queue_position': "🕐 Your video is #{position} in queue\nYour request is very important to us!", 'queue_first': "⬇️ Downloading video...", @@ -396,6 +399,23 @@ def detect_video_source(url: str) -> str: return 'unknown' +def is_vk_video_url(url: str) -> bool: + """Проверяет, что VK URL ведёт именно на видео/клип, а не на группу/профиль.""" + parsed = urlparse(url) + domain = parsed.netloc.lower() + if not ('vk.com' in domain or 'vk.ru' in domain or 'vkontakte.ru' in domain): + return False + + path = parsed.path.lower().strip('/') + query = parsed.query.lower() + return ( + path.startswith('video') + or path.startswith('clip') + or 'z=video' in query + or 'z=clip' in query + ) + + def extract_urls_from_text(text: str) -> list[str]: """Извлекает все URL из текста сообщения""" url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+' @@ -1201,6 +1221,11 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): if chat_type == 'private': await update.message.reply_text(get_text(locale, 'unsupported_source')) return + + if source == 'vk' and not is_vk_video_url(url): + if chat_type == 'private': + await update.message.reply_text(get_text(locale, 'error_vk_not_video')) + return # Отправляем сообщение о начале обработки status_message = await update.message.reply_text(get_text(locale, 'processing')) diff --git a/vk-downloader/Dockerfile b/vk-downloader/Dockerfile index d6693ec..84931f8 100644 --- a/vk-downloader/Dockerfile +++ b/vk-downloader/Dockerfile @@ -20,7 +20,8 @@ RUN mkdir -p downloads ENV PYTHONUNBUFFERED=1 -# Gunicorn: 1 worker (последовательная обработка), без таймаута -# Порт берется из переменной окружения PORT (по умолчанию 5000) -CMD sh -c "gunicorn --workers=1 --timeout=0 --bind=0.0.0.0:${PORT:-5000} app:app" +# Gunicorn: несколько worker-ов и конечные таймауты, чтобы один зависший +# клиент или запрос не блокировал весь VK downloader. +# Порт берется из переменной окружения PORT (по умолчанию 5000). +CMD sh -c "gunicorn --workers=${GUNICORN_WORKERS:-2} --threads=${GUNICORN_THREADS:-4} --timeout=${GUNICORN_TIMEOUT:-360} --graceful-timeout=${GUNICORN_GRACEFUL_TIMEOUT:-30} --keep-alive=${GUNICORN_KEEP_ALIVE:-5} --bind=${BIND_HOST:-0.0.0.0}:${PORT:-5000} app:app" diff --git a/vk-downloader/docker-compose.yml b/vk-downloader/docker-compose.yml index c32e6f2..08d368f 100644 --- a/vk-downloader/docker-compose.yml +++ b/vk-downloader/docker-compose.yml @@ -3,8 +3,13 @@ services: build: . container_name: vk_downloader_service restart: unless-stopped - network_mode: host + ports: + - "127.0.0.1:5555:5555" volumes: - ./downloads:/app/downloads environment: - PORT=5555 + - GUNICORN_WORKERS=2 + - GUNICORN_THREADS=4 + - GUNICORN_TIMEOUT=360 + - GUNICORN_KEEP_ALIVE=5 From c4d4a77229ff9066bfce0afcf51f5bbb233c1dae Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 4 Jun 2026 21:30:37 +0000 Subject: [PATCH 08/10] fix(downloader): chain fallback aria2c-curl-native + timeouts --- youtube-downloader/Dockerfile | 23 +--- youtube-downloader/app.py | 226 ++++++++++++++++++++-------------- 2 files changed, 135 insertions(+), 114 deletions(-) diff --git a/youtube-downloader/Dockerfile b/youtube-downloader/Dockerfile index e42cd23..1c5e61a 100644 --- a/youtube-downloader/Dockerfile +++ b/youtube-downloader/Dockerfile @@ -1,28 +1,9 @@ FROM python:3.11-slim - -# Устанавливаем зависимости для yt-dlp (включая Node.js для JS runtime) -RUN apt-get update && apt-get install -y \ - ffmpeg \ - wget \ - nodejs \ - npm \ - && rm -rf /var/lib/apt/lists/* - +RUN apt-get update && apt-get install -y ffmpeg wget curl aria2 nodejs npm && rm -rf /var/lib/apt/lists/* WORKDIR /app - -# Копируем requirements и устанавливаем зависимости COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt - -# Копируем код приложения COPY . . - -# Создаем директорию для загрузок RUN mkdir -p downloads - ENV PYTHONUNBUFFERED=1 - -# Gunicorn: 1 worker (последовательная обработка), без таймаута -# Порт берется из переменной окружения PORT (по умолчанию 5000) -CMD sh -c "gunicorn --workers=1 --timeout=0 --bind=0.0.0.0:${PORT:-5000} app:app" - +CMD sh -c "gunicorn --workers=1 --timeout=600 --preload --bind=0.0.0.0:\${PORT:-5000} app:app" diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index 82daab3..821d170 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -12,6 +12,12 @@ from flask_cors import CORS import yt_dlp import uuid import re +import copy + +# Цепочка загрузчиков: пробуем по очереди. +# aria2c/curl используют системный OpenSSL (обходит Python SSL handshake timeout), +# native — последний рубеж, работает без внешних зависимостей. +DOWNLOADER_CHAIN = ['aria2c', 'curl', 'native'] # Настройка логирования logging.basicConfig( @@ -123,32 +129,39 @@ def _extract_height_from_format_id(format_id: str) -> int | None: return None -def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) -> dict: +def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None, force_android: bool = False) -> dict: """Формирует базовые опции yt-dlp, общие для info и download Стратегия выбора player_client: - - Cookies есть → используем web клиенты (требуют n-challenge решения), - включаем js_runtimes + remote_components для решения n-challenge через Node.js. - - Cookies нет → используем android клиент (не поддерживает cookies, - но не требует n-challenge, хотя даёт меньше форматов). + - Всегда используем android + mweb клиенты — они работают без n-challenge + и не требуют валидных кук (YouTube всё чаще блокирует web клиенты). + - Если есть валидные куки — подключаем их как дополнение для расширенного + набора форматов, но основным остаётся android/mweb. + - force_android=True — только android (fallback при проблемах с mweb). """ - extractor_args = {} cookies_available = cookies_file_path is not None and cookies_file_path.exists() - if not cookies_available: - # Без cookies используем android — он не требует n-challenge - extractor_args = { - 'youtube': { - 'player_client': ['android'], - 'skip': ['translated_subs', 'hls'], - }, - } + # Стратегия player_client: + # - Если есть cookies + Node.js — используем web (основной) + android (fallback). + # Web клиент решает n-challenge через Node.js и не подвержен SABR-эксперименту. + # - Если нет cookies — android + mweb без зависимостей от JS runtime. + if cookies_available: + player_clients = ['web', 'android'] + else: + player_clients = ['android'] if force_android else ['android', 'mweb'] + + extractor_args = { + 'youtube': { + 'player_client': player_clients, + 'skip': ['translated_subs', 'hls'], + }, + } opts = { 'quiet': False, 'no_warnings': False, 'user_agent': user_agent, - 'socket_timeout': 60, + 'socket_timeout': 30, # уменьшен с 60 для раннего выявления зависаний 'extractor_retries': 3, 'http_headers': { 'User-Agent': user_agent, @@ -158,11 +171,9 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) 'Connection': 'keep-alive', 'Referer': 'https://www.youtube.com/', }, + 'extractor_args': extractor_args, } - if extractor_args: - opts['extractor_args'] = extractor_args - if cookies_available: opts['cookiefile'] = str(cookies_file_path.absolute()) # Включаем n-challenge решение через Node.js + EJS скрипт с GitHub @@ -260,8 +271,9 @@ def _file_has_video_stream(filepath: Path) -> bool: return True # в случае ошибки считаем, что видео есть -def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> Path: - """Скачивает видео с YouTube - используем cookies для обхода блокировок""" +def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> tuple[Path, str]: + """Скачивает видео с YouTube - используем cookies для обхода блокировок. + Возвращает (путь_к_файлу, использованный_загрузчик).""" logger.info(f"[DOWNLOAD] Начало скачивания: {url}") cookies_file = os.getenv('YOUTUBE_COOKIES_FILE', 'youtube_cookies.txt') @@ -391,77 +403,105 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None format_options = default_format_options download_success = False + used_downloader = None # для трекинга, какой загрузчик сработал for format_option in format_options: - ydl_opts_download = _make_base_ydl_opts(user_agent, cookies_file_path if cookies_valid else None) - ydl_opts_download.update({ - 'format': format_option, - 'outtmpl': _safe_filename(video_title), - 'fragment_retries': 3, - 'postprocessors': [{'key': 'FFmpegFixupStretched'}], - }) - - use_cookies_this_attempt = cookies_valid - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries}: начинаем скачивание (Shorts: {is_shorts}, формат: {format_option}, cookies: {use_cookies_this_attempt})") - - try: - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: запуск yt-dlp для скачивания с форматом {format_option}") - with yt_dlp.YoutubeDL(ydl_opts_download) as ydl: - result_info = ydl.download([url]) - - # Логируем информацию о том, что реально скачалось - if result_info: - for entry in result_info: - if entry: - actual_format_id = entry.get('format_id', 'unknown') - actual_height = entry.get('height', 'unknown') - actual_ext = entry.get('ext', 'unknown') - actual_filesize = entry.get('filesize') or entry.get('filesize_approx') or 'unknown' - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}") - - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано с форматом {format_option}") - download_success = True - break - except Exception as download_error: - error_str = str(download_error) - error_lower = error_str.lower() - last_download_errors.append(f"[{format_option}] {error_str[:300]}") - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при скачивании формата {format_option}: {error_str}") - logger.error(f"[DOWNLOAD] Полный traceback:\n{traceback.format_exc()}") - - # Если ошибка с cookies, пробуем без них - if use_cookies_this_attempt and ('cookies' in error_lower or 'bot' in error_lower or 'sign in' in error_lower or 'authentication' in error_lower): - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: обнаружена ошибка связанная с cookies для формата {format_option}: {error_str[:200]}") - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: пробуем без cookies...") - # Пересоздаём opts без cookies - ydl_opts_download_no_cookies = _make_base_ydl_opts(user_agent, None) - ydl_opts_download_no_cookies.update({ - 'format': format_option, - 'outtmpl': _safe_filename(video_title), - 'fragment_retries': 3, - }) - try: - with yt_dlp.YoutubeDL(ydl_opts_download_no_cookies) as ydl: - ydl.download([url]) - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies") - download_success = True - cookies_valid = False # Отключаем cookies для следующих попыток - break - except Exception as retry_error: - retry_str = str(retry_error) - last_download_errors.append(f"[{format_option} без cookies] {retry_str[:300]}") - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка даже без cookies: {retry_error}") - logger.error(f"[DOWNLOAD] Полный traceback без cookies:\n{traceback.format_exc()}") - # Если и без cookies не получилось, пробуем следующий формат - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: не удалось скачать без cookies, пробуем следующий формат...") - continue - # Если ошибка формата, пробуем следующий формат - elif 'format is not available' in error_lower or 'requested format' in error_lower: - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: формат {format_option} недоступен, пробуем следующий...") - continue - else: - # Другая ошибка - пробуем следующий формат - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при скачивании формата {format_option}: {error_str[:200]}, пробуем следующий...") - continue + for downloader in DOWNLOADER_CHAIN: + ydl_opts_download = _make_base_ydl_opts(user_agent, cookies_file_path if cookies_valid else None) + ydl_opts_download.update({ + 'format': format_option, + 'outtmpl': _safe_filename(video_title), + 'fragment_retries': 3, + 'postprocessors': [{'key': 'FFmpegFixupStretched'}], + 'downloader': downloader, + # Таймауты для внешних загрузчиков (socket_timeout на них не действует) + 'downloader_args': { + 'curl': ['--connect-timeout', '15', '--max-time', '120'], + 'aria2c': ['--connect-timeout=15', '--timeout=120', '--max-tries=1'], + }, + }) + + use_cookies_this_attempt = cookies_valid + logger.info(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries}: формат={format_option}, загрузчик={downloader}, cookies={use_cookies_this_attempt}") + + try: + with yt_dlp.YoutubeDL(ydl_opts_download) as ydl: + result_info = ydl.download([url]) + + # Логируем, что реально скачалось + if result_info: + for entry in result_info: + if entry: + actual_format_id = entry.get('format_id', 'unknown') + actual_height = entry.get('height', 'unknown') + actual_ext = entry.get('ext', 'unknown') + actual_filesize = entry.get('filesize') or entry.get('filesize_approx') or 'unknown' + logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}") + + logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: УСПЕХ — загрузчик={downloader}, формат={format_option}") + download_success = True + used_downloader = downloader + break # выходим из цикла загрузчиков + except Exception as download_error: + error_str = str(download_error) + error_lower = error_str.lower() + last_download_errors.append(f"[{downloader}|{format_option}] {error_str[:250]}") + logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: загрузчик={downloader} ошибка: {error_str[:200]}") + + # Классификация ошибки: сеть/SSL → пробуем следующий загрузчик + is_network_error = any(kw in error_lower for kw in [ + 'ssl', 'handshake', 'timeout', 'connection', + 'eof', 'reset', 'broken pipe', 'no route', + ]) + + # Ошибка формата → следующий формат (не загрузчик) + is_format_error = 'format is not available' in error_lower or 'requested format' in error_lower + + # Ошибка cookies → пробуем без cookies + is_cookies_error = use_cookies_this_attempt and any(kw in error_lower for kw in [ + 'cookies', 'bot', 'sign in', 'authentication', + ]) + + if is_format_error: + logger.warning(f"[DOWNLOAD] Формат {format_option} недоступен → следующий формат") + break # выходим из цикла загрузчиков, идём к следующему формату + + if is_cookies_error: + logger.warning(f"[DOWNLOAD] Ошибка cookies с загрузчиком {downloader} → пробуем без cookies") + ydl_opts_no_cookies = _make_base_ydl_opts(user_agent, None) + ydl_opts_no_cookies.update({ + 'format': format_option, + 'outtmpl': _safe_filename(video_title), + 'fragment_retries': 3, + 'downloader': downloader, + 'downloader_args': { + 'curl': ['--connect-timeout', '15', '--max-time', '120'], + 'aria2c': ['--connect-timeout=15', '--timeout=120', '--max-tries=1'], + }, + }) + try: + with yt_dlp.YoutubeDL(ydl_opts_no_cookies) as ydl: + ydl.download([url]) + logger.info(f"[DOWNLOAD] УСПЕХ без cookies — загрузчик={downloader}, формат={format_option}") + download_success = True + used_downloader = downloader + cookies_valid = False + break # выходим из цикла загрузчиков + except Exception as retry_error: + retry_str = str(retry_error) + last_download_errors.append(f"[{downloader}|без cookies] {retry_str[:250]}") + logger.error(f"[DOWNLOAD] Ошибка без cookies: {retry_str[:200]}") + continue # пробуем следующий загрузчик + + if is_network_error and downloader != DOWNLOADER_CHAIN[-1]: + logger.warning(f"[DOWNLOAD] Сеть/SSL ошибка с {downloader} → следующий загрузчик") + continue # следующий загрузчик в цепочке + + # Последний загрузчик или неизвестная ошибка → следующий формат + logger.warning(f"[DOWNLOAD] Загрузчик {downloader} не сработал, формат {format_option} → следующий формат") + break # выходим из цикла загрузчиков + + if download_success: + break # выходим из цикла форматов if not download_success: # Собираем детальный отчёт об ошибках @@ -472,7 +512,7 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None downloaded_files = list(DOWNLOADS_DIR.glob('*')) if downloaded_files: downloaded_files.sort(key=lambda x: x.stat().st_mtime, reverse=True) - return downloaded_files[0] + return downloaded_files[0], used_downloader or 'unknown' else: raise Exception("Файл не был найден после скачивания") @@ -910,8 +950,8 @@ def download_stream(): # Скачиваем видео logger.info(f"[REQUEST {request_id}] Начинаем скачивание видео...") - video_path = download_youtube_video(url, format_id=format_id) - logger.info(f"[REQUEST {request_id}] Видео успешно скачано: {video_path}") + video_path, used_downloader = download_youtube_video(url, format_id=format_id) + logger.info(f"[REQUEST {request_id}] Видео успешно скачано: {video_path} (загрузчик: {used_downloader})") # Читаем файл и отправляем file_size = video_path.stat().st_size From ce6505c9bb1e8f2062eeaa4323f1c813558d80a3 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 4 Jun 2026 21:32:13 +0000 Subject: [PATCH 09/10] chore: remove youtube quality selection ui --- bot.py | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/bot.py b/bot.py index 25f9e3b..1a59590 100644 --- a/bot.py +++ b/bot.py @@ -1230,28 +1230,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): # Отправляем сообщение о начале обработки status_message = await update.message.reply_text(get_text(locale, 'processing')) - # Для YouTube - показываем выбор качества перед добавлением в очередь - if source == 'youtube': - await status_message.edit_text(get_text(locale, 'fetching_formats')) - formats = await get_formats_from_service(url) - if formats: - # Сохраняем данные для обработки в колбэке - context.user_data[f'quality_{chat_id}'] = { - 'url': url, - 'locale': locale, - 'chat_id': chat_id, - 'chat_type': chat_type, - 'original_message': update.message, - 'status_message': status_message, - 'formats_list': formats, # для lookup по индексу в callback - } - await show_quality_selection(status_message, formats, locale) - # Автовыбор лучшего качества через 10 сек - auto_task = asyncio.create_task(_auto_select_after_delay(context, chat_id, 10)) - context.user_data[f'quality_auto_{chat_id}'] = auto_task - return - # Если не удалось получить форматы, скачиваем как обычно (без выбора качества) - await status_message.edit_text(get_text(locale, 'processing')) + # Для YouTube — сразу добавляем в очередь (выбор качества убран, т.к. android+mweb даёт только 360p) # Создаём элемент очереди item = QueueItem( From 4d575059b03ee75ce5dbaf9df1769291947aae30 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Mon, 8 Jun 2026 16:17:02 +0000 Subject: [PATCH 10/10] =?UTF-8?q?fix(youtube-downloader):=20subprocess=20y?= =?UTF-8?q?t-dlp=20CLI=20=E2=80=94=20bypass=20=5Fssl=20handshake=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- youtube-downloader/Dockerfile | 2 +- youtube-downloader/app.py | 1029 +++++++----------------- youtube-downloader/youtube_cookies.txt | 111 ++- 3 files changed, 356 insertions(+), 786 deletions(-) diff --git a/youtube-downloader/Dockerfile b/youtube-downloader/Dockerfile index 1c5e61a..52a2a8b 100644 --- a/youtube-downloader/Dockerfile +++ b/youtube-downloader/Dockerfile @@ -6,4 +6,4 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN mkdir -p downloads ENV PYTHONUNBUFFERED=1 -CMD sh -c "gunicorn --workers=1 --timeout=600 --preload --bind=0.0.0.0:\${PORT:-5000} app:app" +CMD sh -c "gunicorn --workers=1 --timeout=600 --preload --max-requests=1 --bind=0.0.0.0:\${PORT:-5000} app:app" diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index 821d170..2754212 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -1,23 +1,20 @@ """ YouTube Video Downloader Service Отдельный микросервис для скачивания видео с YouTube + +Версия 2: subprocess-based yt-dlp CLI (обход SSL бага в gunicorn pre-fork) """ import os import time import logging import traceback +import subprocess +import json as json_lib from pathlib import Path from flask import Flask, request, jsonify from flask_cors import CORS -import yt_dlp import uuid import re -import copy - -# Цепочка загрузчиков: пробуем по очереди. -# aria2c/curl используют системный OpenSSL (обходит Python SSL handshake timeout), -# native — последний рубеж, работает без внешних зависимостей. -DOWNLOADER_CHAIN = ['aria2c', 'curl', 'native'] # Настройка логирования logging.basicConfig( @@ -27,7 +24,7 @@ logging.basicConfig( logger = logging.getLogger(__name__) app = Flask(__name__) -CORS(app) # Разрешаем CORS для взаимодействия с основным ботом +CORS(app) # Директория для временных файлов DOWNLOADS_DIR = Path('downloads') @@ -49,191 +46,33 @@ def _cleanup_downloads(): pass -def _is_valid_cookies_file(cookies_path: Path) -> bool: - """Проверяет, что файл cookies существует и содержит данные (не только заголовки)""" - logger.info(f"[COOKIES CHECK] Проверка файла cookies: {cookies_path.absolute()}") - - if not cookies_path.exists(): - logger.warning(f"[COOKIES CHECK] Файл не существует: {cookies_path.absolute()}") - return False - - try: - file_size = cookies_path.stat().st_size - logger.info(f"[COOKIES CHECK] Размер файла: {file_size} байт") - - with open(cookies_path, 'r', encoding='utf-8', errors='ignore') as f: - all_lines = f.readlines() - lines = [line.strip() for line in all_lines if line.strip() and not line.strip().startswith('#')] - - logger.info(f"[COOKIES CHECK] Всего строк в файле: {len(all_lines)}, валидных строк (не комментариев): {len(lines)}") - - # Логируем первые 3 строки для диагностики (без чувствительных данных) - if lines: - preview_lines = [] - for i, line in enumerate(lines[:3]): - # Маскируем значения cookie для безопасности - if '\t' in line: - parts = line.split('\t') - if len(parts) > 6: - masked_line = '\t'.join(parts[:6]) + '\t***MASKED***' - preview_lines.append(f" Строка {i+1}: {masked_line[:100]}") - else: - preview_lines.append(f" Строка {i+1}: {line[:100]}") - logger.info(f"[COOKIES CHECK] Превью первых строк:\n" + "\n".join(preview_lines)) - - # Проверяем, что есть хотя бы одна строка с данными cookie - is_valid = len(lines) > 0 - logger.info(f"[COOKIES CHECK] Результат проверки: {'VALID' if is_valid else 'INVALID'}") - return is_valid - except Exception as e: - logger.error(f"[COOKIES CHECK] Ошибка при проверке файла cookies: {e}") - logger.error(f"[COOKIES CHECK] Traceback:\n{traceback.format_exc()}") - return False - - -def _parse_height(format_dict: dict) -> int: - """Извлекает реальную высоту из формата: height/width/format_note/resolution""" - h = format_dict.get('height') - w = format_dict.get('width') - # Для вертикальных видео (Shorts) height и width могут быть перепутаны — - # берём меньшее значение как честный показатель разрешения - if h and w and isinstance(h, (int, float)) and isinstance(w, (int, float)): - real_h = min(int(h), int(w)) - if real_h > 0: - return real_h - if h and isinstance(h, (int, float)) and h > 0: - return int(h) - if w and isinstance(w, (int, float)) and w > 0: - return int(w) - # Если вообще нет размеров — парсим format_note (например "360p" или "1080x1920") - note = str(format_dict.get('format_note', '') or '') - match = re.search(r'(\d+)\s*p', note) - if match: - return int(match.group(1)) - match = re.search(r'(\d+)\s*x\s*(\d+)', note, re.IGNORECASE) - if match: - return min(int(match.group(1)), int(match.group(2))) - # Парсим поле resolution (например "1920x1080") - res = str(format_dict.get('resolution', '') or '') - match = re.search(r'(\d+)\s*x\s*(\d+)', res, re.IGNORECASE) - if match: - return min(int(match.group(1)), int(match.group(2))) - return 0 - - -def _extract_height_from_format_id(format_id: str) -> int | None: - """Извлекает ограничение по высоте из format_id (например 'best[height<=360]' -> 360)""" - match = re.search(r'height<[=]?\s*(\d+)', format_id) - if match: - return int(match.group(1)) - return None - - -def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None, force_android: bool = False) -> dict: - """Формирует базовые опции yt-dlp, общие для info и download - - Стратегия выбора player_client: - - Всегда используем android + mweb клиенты — они работают без n-challenge - и не требуют валидных кук (YouTube всё чаще блокирует web клиенты). - - Если есть валидные куки — подключаем их как дополнение для расширенного - набора форматов, но основным остаётся android/mweb. - - force_android=True — только android (fallback при проблемах с mweb). - """ - cookies_available = cookies_file_path is not None and cookies_file_path.exists() - - # Стратегия player_client: - # - Если есть cookies + Node.js — используем web (основной) + android (fallback). - # Web клиент решает n-challenge через Node.js и не подвержен SABR-эксперименту. - # - Если нет cookies — android + mweb без зависимостей от JS runtime. - if cookies_available: - player_clients = ['web', 'android'] - else: - player_clients = ['android'] if force_android else ['android', 'mweb'] - - extractor_args = { - 'youtube': { - 'player_client': player_clients, - 'skip': ['translated_subs', 'hls'], - }, - } - - opts = { - 'quiet': False, - 'no_warnings': False, - 'user_agent': user_agent, - 'socket_timeout': 30, # уменьшен с 60 для раннего выявления зависаний - 'extractor_retries': 3, - 'http_headers': { - 'User-Agent': user_agent, - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'en-us,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate', - 'Connection': 'keep-alive', - 'Referer': 'https://www.youtube.com/', - }, - 'extractor_args': extractor_args, - } - - if cookies_available: - opts['cookiefile'] = str(cookies_file_path.absolute()) - # Включаем n-challenge решение через Node.js + EJS скрипт с GitHub - # yt-dlp скачает challenge solver скрипт из официального репозитория - opts['js_runtimes'] = {'node': {}} - opts['remote_components'] = ['ejs:github'] - - return opts - - -def _find_video_file() -> Path | None: - """Находит видеофайл среди загрузок. Если видео+аудио раздельные — мержит ffmpeg. - Возвращает путь к готовому видеофайлу или None если видео нет.""" - files = list(DOWNLOADS_DIR.glob('*')) +def _find_latest_downloaded() -> Path | None: + """Возвращает самый свежий файл в папке загрузок (не .part/.ytdl).""" + files = [f for f in DOWNLOADS_DIR.glob('*') + if f.suffix not in ('.part', '.ytdl')] if not files: return None files.sort(key=lambda x: x.stat().st_mtime, reverse=True) - - video_file = None - audio_file = None - - for f in files: - if f.suffix in ('.part', '.ytdl'): - continue - if _file_has_video_stream(f): - has_audio = _file_has_audio_stream(f) - if has_audio: - return f # уже смерженный или combined — сразу возвращаем - if video_file is None: - video_file = f - elif not audio_file and _file_has_audio_stream(f): - audio_file = f - - if video_file is None: - return None - - if audio_file: - # Мержим видео + аудио через ffmpeg - import subprocess - merged = DOWNLOADS_DIR / f"{video_file.stem}_merged{video_file.suffix}" - logger.info(f"[MERGE] Мержим {video_file.name} + {audio_file.name} → {merged.name}") + return files[0] + + +def _file_has_video_stream(filepath: Path) -> bool: + """Проверяет через ffprobe, содержит ли файл видео-поток.""" + try: result = subprocess.run( - ['ffmpeg', '-y', '-i', str(video_file), '-i', str(audio_file), - '-c', 'copy', '-map', '0:v:0', '-map', '1:a:0', str(merged)], - capture_output=True, text=True, timeout=120 + ['ffprobe', '-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', + str(filepath)], + capture_output=True, text=True, timeout=15 ) - if result.returncode != 0: - logger.error(f"[MERGE] Ошибка ffmpeg: {result.stderr[-300:]}") - return video_file # fallback — только видео - - video_file.unlink(missing_ok=True) - audio_file.unlink(missing_ok=True) - return merged - - return video_file + return result.stdout.strip() == 'video' + except Exception as e: + logger.warning(f"[VALIDATE] Не удалось проверить видео-поток в {filepath.name}: {e}") + return True def _file_has_audio_stream(filepath: Path) -> bool: """Проверяет через ffprobe, содержит ли файл аудио-поток.""" - import subprocess try: result = subprocess.run( ['ffprobe', '-v', 'error', '-select_streams', 'a:0', @@ -246,500 +85,213 @@ def _file_has_audio_stream(filepath: Path) -> bool: return False -def _find_latest_downloaded() -> Path | None: - """Возвращает самый свежий файл в папке загрузок.""" - files = list(DOWNLOADS_DIR.glob('*')) +def _find_video_file() -> Path | None: + """Находит видеофайл среди загрузок. Если видео+аудио раздельные — мержит ffmpeg.""" + files = [f for f in DOWNLOADS_DIR.glob('*') if f.suffix not in ('.part', '.ytdl')] if not files: return None files.sort(key=lambda x: x.stat().st_mtime, reverse=True) - return files[0] + video_file = None + audio_file = None -def _file_has_video_stream(filepath: Path) -> bool: - """Проверяет через ffprobe, содержит ли файл видео-поток.""" - import subprocess - try: + for f in files: + if _file_has_video_stream(f): + if _file_has_audio_stream(f): + return f # combined stream + if video_file is None: + video_file = f + elif not audio_file and _file_has_audio_stream(f): + audio_file = f + + if video_file is None: + return None + + if audio_file: + merged = DOWNLOADS_DIR / f"{video_file.stem}_merged{video_file.suffix}" + logger.info(f"[MERGE] Мержим {video_file.name} + {audio_file.name} -> {merged.name}") result = subprocess.run( - ['ffprobe', '-v', 'error', '-select_streams', 'v:0', - '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', - str(filepath)], - capture_output=True, text=True, timeout=15 + ['ffmpeg', '-y', '-i', str(video_file), '-i', str(audio_file), + '-c', 'copy', '-map', '0:v:0', '-map', '1:a:0', str(merged)], + capture_output=True, text=True, timeout=120 ) - return result.stdout.strip() == 'video' - except Exception as e: - logger.warning(f"[VALIDATE] Не удалось проверить видео-поток в {filepath.name}: {e}") - return True # в случае ошибки считаем, что видео есть + if result.returncode == 0: + video_file.unlink(missing_ok=True) + audio_file.unlink(missing_ok=True) + return merged + logger.error(f"[MERGE] Ошибка ffmpeg: {result.stderr[-300:]}") + return video_file + + return video_file -def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> tuple[Path, str]: - """Скачивает видео с YouTube - используем cookies для обхода блокировок. - Возвращает (путь_к_файлу, использованный_загрузчик).""" - logger.info(f"[DOWNLOAD] Начало скачивания: {url}") - - cookies_file = os.getenv('YOUTUBE_COOKIES_FILE', 'youtube_cookies.txt') - cookies_file_path = Path(cookies_file) - logger.info(f"[DOWNLOAD] Путь к файлу cookies из env: {cookies_file}, абсолютный: {cookies_file_path.absolute()}") - - cookies_valid = _is_valid_cookies_file(cookies_file_path) - if not cookies_valid: - logger.warning(f"[DOWNLOAD] Файл cookies не найден или невалиден ({cookies_file_path}). " - f"Работаем без cookies. Для лучшей работы рекомендуется обновить cookies через скрипт get_youtube_cookies.sh") - else: - logger.info(f"[DOWNLOAD] Cookies файл валиден, будет использован: {cookies_file_path.absolute()}") - - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - - last_error = None - last_download_errors = [] # собираем ошибки по форматам для диагностики - for attempt in range(max_retries): - try: - is_shorts = '/shorts/' in url - - # Базовые настройки для получения информации - ydl_opts_info = _make_base_ydl_opts(user_agent, cookies_file_path if cookies_valid else None) - - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: {'cookies включены' if cookies_valid else 'работаем без cookies'}") - - # Пробуем получить информацию о видео - info = None - try: - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: извлечение информации о видео с URL: {url}") - with yt_dlp.YoutubeDL(ydl_opts_info) as ydl: - info = ydl.extract_info(url, download=False) - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: информация о видео успешно получена") - except Exception as info_error: - error_str = str(info_error) - error_lower = error_str.lower() - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при получении информации о видео: {error_str}") - logger.error(f"[DOWNLOAD] Полный traceback:\n{traceback.format_exc()}") - - # Если не получилось с cookies, пробуем без них - # Проверяем различные признаки проблем с cookies: - # - явные ошибки с cookies/bot/sign in - # - ошибки формата (могут быть из-за блокировки YouTube при неполных cookies) - # - "Only images are available" (признак блокировки YouTube) - # - "Missing required Data Sync ID" (неполные cookies) - should_retry_without_cookies = cookies_valid and ( - 'cookies' in error_lower or - 'bot' in error_lower or - 'sign in' in error_lower or - 'authentication' in error_lower or - 'format is not available' in error_lower or - 'only images are available' in error_lower or - 'missing required data sync id' in error_lower or - 'challenge solving failed' in error_lower - ) - - if should_retry_without_cookies: - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: обнаружена ошибка, возможно связанная с cookies: {error_str[:200]}") - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: пробуем без cookies...") - ydl_opts_info.pop('cookiefile', None) - try: - with yt_dlp.YoutubeDL(ydl_opts_info) as ydl: - info = ydl.extract_info(url, download=False) - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно получена информация без cookies!") - cookies_valid = False # Отключаем cookies для скачивания тоже - except Exception as retry_error: - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка даже без cookies: {retry_error}") - logger.error(f"[DOWNLOAD] Полный traceback без cookies:\n{traceback.format_exc()}") - raise - else: - raise - - video_title = info.get('title', 'video') if info else 'video' - logger.info(f"YouTube: получена информация о видео: {video_title}") - - # Настройки для скачивания - # Если передан format_id — это может быть: - # 1) Конкретный format code (число, например "18" или "137+140") — точный выбор качества - # 2) Format selector (например "bestvideo[height<=240]+bestaudio/best") — старый формат - # - # Для конкретных format codes: если формат недоступен, НЕ падаем на best, - # а пробуем format selector для того же разрешения (извлекаем height из запроса пользователя). - # Это важно, т.к. format_id из get_youtube_formats() может не совпадать - # с format_id при повторном extract_info() в download_youtube_video(). - default_format_options = [ - 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', - 'best[ext=mp4]/best', - 'bestvideo+bestaudio/best', - 'best', - ] +# ═══════════════════════════════════════════════════════════════ +# CORE: subprocess-based yt-dlp +# ═══════════════════════════════════════════════════════════════ - # Добавляем fallback на combined форматы (например 18), которые всегда доступны - combined_fallback = ['best[ext=mp4]/best', 'best'] - - requested_height = None # высота, запрошенная пользователем - is_audio_only = False - - if format_id: - is_specific_code = not ('[' in format_id or ']' in format_id) - # Если format_id запрашивает только аудио, не подмешиваем видео-форматы - # и не валидируем наличие видео-потока - first_selector = format_id.split('/')[0] - is_audio_only = 'bestaudio' in first_selector - requested_height = _extract_height_from_format_id(format_id) - - if is_audio_only: - format_options = [format_id] - logger.info(f"[DOWNLOAD] Аудио-only режим, format_id: {format_id}") - elif requested_height is not None: - # Конкретный format_id (из /formats) ставим ПЕРВЫМ — - # он точно указывает выбранные пользователем format codes. - # Height-ограниченный селектор идёт как fallback - # (c исключением av01, чтобы yt-dlp не выбрал unplayable формат 400). - format_options = [ - format_id, - f"bestvideo[height<={requested_height}][vcodec!=av01]+bestaudio/best[height<={requested_height}]", - ] - logger.info(f"[DOWNLOAD] Размерное ограничение: {requested_height}p, format_id: {format_id}") - format_options.extend(default_format_options) - format_options.extend(combined_fallback) - else: - format_options = [format_id] - format_options.extend(default_format_options) - - logger.info(f"[DOWNLOAD] Итоговый список format_options ({len(format_options)} шт.): {format_options}") - else: - format_options = default_format_options - - download_success = False - used_downloader = None # для трекинга, какой загрузчик сработал - for format_option in format_options: - for downloader in DOWNLOADER_CHAIN: - ydl_opts_download = _make_base_ydl_opts(user_agent, cookies_file_path if cookies_valid else None) - ydl_opts_download.update({ - 'format': format_option, - 'outtmpl': _safe_filename(video_title), - 'fragment_retries': 3, - 'postprocessors': [{'key': 'FFmpegFixupStretched'}], - 'downloader': downloader, - # Таймауты для внешних загрузчиков (socket_timeout на них не действует) - 'downloader_args': { - 'curl': ['--connect-timeout', '15', '--max-time', '120'], - 'aria2c': ['--connect-timeout=15', '--timeout=120', '--max-tries=1'], - }, - }) +YTDLP_CMD = 'yt-dlp' +DOWNLOAD_TIMEOUT = 300 +INFO_TIMEOUT = 60 - use_cookies_this_attempt = cookies_valid - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries}: формат={format_option}, загрузчик={downloader}, cookies={use_cookies_this_attempt}") +PLAYER_CLIENTS = 'web,android' +EXTRACTOR_ARGS = 'youtube:player_client=web,android:skip=translated_subs,hls' - try: - with yt_dlp.YoutubeDL(ydl_opts_download) as ydl: - result_info = ydl.download([url]) - # Логируем, что реально скачалось - if result_info: - for entry in result_info: - if entry: - actual_format_id = entry.get('format_id', 'unknown') - actual_height = entry.get('height', 'unknown') - actual_ext = entry.get('ext', 'unknown') - actual_filesize = entry.get('filesize') or entry.get('filesize_approx') or 'unknown' - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}") +def _build_ytdlp_base_cmd() -> list: + """Базовые аргументы yt-dlp CLI.""" + cookies_file = Path(os.getenv('YOUTUBE_COOKIES_FILE', '/app/youtube_cookies.txt')) + cmd = [ + YTDLP_CMD, + '--socket-timeout', '15', + '--extractor-args', EXTRACTOR_ARGS, + '--js-runtimes', 'node', + '--remote-components', 'ejs:github', + '--no-playlist', + ] + if cookies_file.exists() and cookies_file.stat().st_size > 0: + cmd += ['--cookies', str(cookies_file.absolute())] + return cmd - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: УСПЕХ — загрузчик={downloader}, формат={format_option}") - download_success = True - used_downloader = downloader - break # выходим из цикла загрузчиков - except Exception as download_error: - error_str = str(download_error) - error_lower = error_str.lower() - last_download_errors.append(f"[{downloader}|{format_option}] {error_str[:250]}") - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: загрузчик={downloader} ошибка: {error_str[:200]}") - # Классификация ошибки: сеть/SSL → пробуем следующий загрузчик - is_network_error = any(kw in error_lower for kw in [ - 'ssl', 'handshake', 'timeout', 'connection', - 'eof', 'reset', 'broken pipe', 'no route', - ]) +def _run_ytdlp(args: list, timeout: int = DOWNLOAD_TIMEOUT) -> subprocess.CompletedProcess: + """Запускает yt-dlp CLI как subprocess (чистый SSL стек).""" + logger.info(f"[YTDLP] {' '.join(args)}") + return subprocess.run(args, capture_output=True, text=True, timeout=timeout) - # Ошибка формата → следующий формат (не загрузчик) - is_format_error = 'format is not available' in error_lower or 'requested format' in error_lower - # Ошибка cookies → пробуем без cookies - is_cookies_error = use_cookies_this_attempt and any(kw in error_lower for kw in [ - 'cookies', 'bot', 'sign in', 'authentication', - ]) +# ═══════════════════════════════════════════════════════════════ +# YouTube formatter parser (shared with old codebase) +# ═══════════════════════════════════════════════════════════════ - if is_format_error: - logger.warning(f"[DOWNLOAD] Формат {format_option} недоступен → следующий формат") - break # выходим из цикла загрузчиков, идём к следующему формату +def _parse_height(format_dict: dict) -> int: + """Извлекает реальную высоту из формата.""" + h = format_dict.get('height') + w = format_dict.get('width') + if h and w and isinstance(h, (int, float)) and isinstance(w, (int, float)): + return min(int(h), int(w)) + if h and isinstance(h, (int, float)) and h > 0: + return int(h) + if w and isinstance(w, (int, float)) and w > 0: + return int(w) + note = str(format_dict.get('format_note', '') or '') + match = re.search(r'(\d+)\s*p', note) + if match: + return int(match.group(1)) + match = re.search(r'(\d+)\s*x\s*(\d+)', note, re.IGNORECASE) + if match: + return min(int(match.group(1)), int(match.group(2))) + res = str(format_dict.get('resolution', '') or '') + match = re.search(r'(\d+)\s*x\s*(\d+)', res, re.IGNORECASE) + if match: + return min(int(match.group(1)), int(match.group(2))) + return 0 - if is_cookies_error: - logger.warning(f"[DOWNLOAD] Ошибка cookies с загрузчиком {downloader} → пробуем без cookies") - ydl_opts_no_cookies = _make_base_ydl_opts(user_agent, None) - ydl_opts_no_cookies.update({ - 'format': format_option, - 'outtmpl': _safe_filename(video_title), - 'fragment_retries': 3, - 'downloader': downloader, - 'downloader_args': { - 'curl': ['--connect-timeout', '15', '--max-time', '120'], - 'aria2c': ['--connect-timeout=15', '--timeout=120', '--max-tries=1'], - }, - }) - try: - with yt_dlp.YoutubeDL(ydl_opts_no_cookies) as ydl: - ydl.download([url]) - logger.info(f"[DOWNLOAD] УСПЕХ без cookies — загрузчик={downloader}, формат={format_option}") - download_success = True - used_downloader = downloader - cookies_valid = False - break # выходим из цикла загрузчиков - except Exception as retry_error: - retry_str = str(retry_error) - last_download_errors.append(f"[{downloader}|без cookies] {retry_str[:250]}") - logger.error(f"[DOWNLOAD] Ошибка без cookies: {retry_str[:200]}") - continue # пробуем следующий загрузчик - - if is_network_error and downloader != DOWNLOADER_CHAIN[-1]: - logger.warning(f"[DOWNLOAD] Сеть/SSL ошибка с {downloader} → следующий загрузчик") - continue # следующий загрузчик в цепочке - - # Последний загрузчик или неизвестная ошибка → следующий формат - logger.warning(f"[DOWNLOAD] Загрузчик {downloader} не сработал, формат {format_option} → следующий формат") - break # выходим из цикла загрузчиков - - if download_success: - break # выходим из цикла форматов - - if not download_success: - # Собираем детальный отчёт об ошибках - errors_summary = "; ".join(last_download_errors[-10:]) # последние 10 ошибок - raise Exception(f"Не удалось скачать видео ни с одним из доступных форматов. Ошибки: {errors_summary}") - - # Находим скачанный файл - downloaded_files = list(DOWNLOADS_DIR.glob('*')) - if downloaded_files: - downloaded_files.sort(key=lambda x: x.stat().st_mtime, reverse=True) - return downloaded_files[0], used_downloader or 'unknown' - else: - raise Exception("Файл не был найден после скачивания") - - except Exception as e: - last_error = e - error_str = str(e) - error_lower = error_str.lower() - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries} не удалась: {error_str}") - logger.error(f"[DOWNLOAD] Полный traceback попытки {attempt + 1}:\n{traceback.format_exc()}") - - # Если ошибка связана с форматом, пробуем другие настройки - if 'format is not available' in error_lower or 'requested format' in error_lower: - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: проблема с форматом, пробуем другие настройки на следующей попытке") - if attempt < max_retries - 1: - sleep_time = (attempt + 1) * 2 - logger.info(f"[DOWNLOAD] Ожидание {sleep_time} секунд перед следующей попыткой...") - time.sleep(sleep_time) - continue - - # Если ошибка связана с cookies и они были использованы, попробуем без cookies на следующей попытке - if 'cookies' in error_lower or 'bot' in error_lower or 'sign in' in error_lower or 'authentication' in error_lower: - if cookies_valid and attempt == 0: - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: обнаружена ошибка связанная с cookies: {error_str[:200]}") - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: на следующей попытке попробуем без cookies") - cookies_valid = False - - if attempt < max_retries - 1: - sleep_time = (attempt + 1) * 2 - logger.info(f"[DOWNLOAD] Ожидание {sleep_time} секунд перед следующей попыткой...") - time.sleep(sleep_time) - - # Включаем в итоговую ошибку сводку по форматам - errors_summary = "; ".join(last_download_errors[-10:]) if last_download_errors else "" - error_msg = str(last_error) if last_error else "Неизвестная ошибка при скачивании с YouTube" - if errors_summary: - error_msg += f" | Ошибки форматов: {errors_summary}" - raise Exception(error_msg) +# ═══════════════════════════════════════════════════════════════ +# Форматы (--dump-json) +# ═══════════════════════════════════════════════════════════════ def get_youtube_formats(url: str) -> list[dict]: - """Получает список доступных форматов видео с YouTube""" + """Получает список доступных форматов через subprocess yt-dlp --dump-json.""" logger.info(f"[FORMATS] Получение списка форматов для: {url}") - - cookies_file = os.getenv('YOUTUBE_COOKIES_FILE', 'youtube_cookies.txt') - cookies_file_path = Path(cookies_file) - user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' - - cookies_valid = _is_valid_cookies_file(cookies_file_path) - if not cookies_valid: - logger.warning(f"[FORMATS] Cookies файл не найден или невалиден. Работаем без cookies.") - - # Пробуем сначала с cookies (если есть), потом без - attempts_configs = [] - - if cookies_valid: - attempts_configs.append({ - 'use_cookies': True, - 'label': 'с cookies' - }) - - attempts_configs.append({ - 'use_cookies': False, - 'label': 'без cookies' - }) - - last_error = None - info = None - - for config in attempts_configs: - try: - logger.info(f"[FORMATS] Попытка: {config['label']}") - - # Для /formats используем те же улучшенные опции (player_client, retries и т.д.), - # но с quiet=True чтобы не засорять логи - ydl_opts = _make_base_ydl_opts( - user_agent, - cookies_file_path if config['use_cookies'] else None - ) - ydl_opts['quiet'] = True - ydl_opts['no_warnings'] = True - ydl_opts['socket_timeout'] = 30 - - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=False) - - logger.info(f"[FORMATS] Успешно получена информация {config['label']}") - break # Успех - выходим из цикла - - except Exception as e: - error_str = str(e) - last_error = e - logger.warning(f"[FORMATS] Ошибка {config['label']}: {error_str[:200]}") - - # Если это была попытка с cookies, и ошибка похожа на проблему с cookies - - # продолжаем дальше (следующая попытка будет без cookies) - if config['use_cookies'] and ( - 'cookiefile' in error_str.lower() - or 'requested format' in error_str.lower() - or 'http error' in error_str.lower() - or 'only images are available' in error_str.lower() - or 'n challenge' in error_str.lower() - or 'challenge solving' in error_str.lower() - ): - logger.info(f"[FORMATS] Ошибка с cookies, пробуем без cookies...") - continue - continue - - if info is None: - logger.error(f"[FORMATS] Все попытки получения информации не удались: {last_error}") - raise last_error or Exception("Не удалось получить информацию о видео") - + + cmd = _build_ytdlp_base_cmd() + ['--dump-json', '--quiet', '--no-warnings', url] + try: + result = _run_ytdlp(cmd, timeout=INFO_TIMEOUT) + except Exception as e: + logger.error(f"[FORMATS] Ошибка subprocess: {e}") + raise Exception(f"Не удалось получить информацию о видео: {e}") + + if result.returncode != 0: + err = result.stderr.strip()[-500:] + logger.error(f"[FORMATS] yt-dlp failed: {err}") + raise Exception(f"yt-dlp error: {err}") + + try: + info = json_lib.loads(result.stdout) + except json_lib.JSONDecodeError as e: + raise Exception(f"Failed to parse --dump-json: {e}") + formats = info.get('formats', []) - logger.info(f"[FORMATS] Всего форматов: {len(formats)}") - - duration = info.get('duration') # длительность видео в секундах - logger.info(f"[FORMATS] Длительность видео: {duration} сек") - + duration = info.get('duration') + logger.info(f"[FORMATS] Всего форматов: {len(formats)}, длительность: {duration}с") + def _get_filesize(f: dict) -> int: - """Пытается получить размер файла в байтах: filesize -> filesize_approx -> оценка по битрейту""" size = f.get('filesize') or f.get('filesize_approx') or 0 if size: return size - - # Если размер неизвестен, оцениваем по битрейту и длительности if duration: - # Для форматов, которые содержат и видео и аудио, используем tbr tbr = f.get('tbr') or 0 if tbr: return int(tbr * 1024 / 8 * duration) - - # Для видео-без-аудио: vbr видео + abr аудио vbr = f.get('vbr') or 0 abr = f.get('abr') or 0 if vbr or abr: return int((vbr + abr) * 1024 / 8 * duration) - return 0 - - # Стандартные разрешения для группировки (от большего к меньшему) + quality_tiers = [ - (2160, '4K'), - (1440, '1440p'), - (1080, '1080p'), - (720, '720p'), - (480, '480p'), - (360, '360p'), - (240, '240p'), - (144, '144p'), + (2160, '4K'), (1440, '1440p'), (1080, '1080p'), (720, '720p'), + (480, '480p'), (360, '360p'), (240, '240p'), (144, '144p'), ] - - # Собираем уникальные высоты из форматов с видео + available_heights = set() best_audio_info = {'size': 0, 'ext': 'm4a', 'format_id': None} - + for f in formats: vcodec = f.get('vcodec', 'none') acodec = f.get('acodec', 'none') height = _parse_height(f) - format_id = f.get('format_id', '') - if vcodec != 'none' and height > 0: available_heights.add(height) - if vcodec == 'none' and acodec != 'none' and best_audio_info['format_id'] is None: - # Берём первый попавшийся аудиоформат — yt-dlp отдаёт оригинал первым - best_audio_info = {'size': _get_filesize(f), 'ext': f.get('ext', 'm4a'), 'format_id': format_id} - - logger.info(f"[FORMATS] Доступные разрешения: {sorted(available_heights)}") - logger.info(f"[FORMATS] Лучший аудиопоток: {best_audio_info['size']} bytes, {best_audio_info['ext']}, format_id={best_audio_info['format_id']}") - + best_audio_info = {'size': _get_filesize(f), 'ext': f.get('ext', 'm4a'), + 'format_id': f.get('format_id', '')} + + max_actual_height = max(available_heights) if available_heights else 2160 result = [] used_heights = set() - - # Определяем реальную максимальную высоту видео - max_actual_height = max(available_heights) if available_heights else 2160 - + for max_height, label in quality_tiers: if max_height > max_actual_height: - continue # не показываем 4K для видео с макс высотой 1080p - + continue + best_video = None best_video_height = 0 is_best_dash = False - + for f in formats: vcodec = f.get('vcodec', 'none') height = _parse_height(f) - if vcodec == 'none' or height <= 0 or height > max_height: continue - is_dash = (f.get('acodec', 'none') == 'none') pick = False - if height > best_video_height: pick = True elif height == best_video_height and is_dash and not is_best_dash: pick = True - if pick: best_video = f best_video_height = height is_best_dash = is_dash - - if not best_video: - continue - - if best_video_height in used_heights: + + if not best_video or best_video_height in used_heights: continue used_heights.add(best_video_height) - + video_size = _get_filesize(best_video) has_audio = best_video.get('acodec', 'none') != 'none' total_size = video_size + (best_audio_info['size'] if not has_audio else 0) - video_ext = best_video.get('ext', 'mp4') video_format_id = best_video.get('format_id', '') - - # Честный лейбл из реальной высоты + format_note = best_video.get('format_note', '') or '' if format_note and str(best_video_height) in format_note: display_label = format_note else: display_label = f"{best_video_height}p" - - logger.info(f"[FORMATS] {display_label} (height={best_video_height}): video_size={video_size}, has_audio={has_audio}, total={total_size}, format_id={video_format_id}") - + if has_audio: format_selector = f"{video_format_id}/best[height<={best_video_height}]/best" elif best_audio_info['format_id']: @@ -750,7 +302,7 @@ def get_youtube_formats(url: str) -> list[dict]: ) else: format_selector = f"{video_format_id}+bestaudio/best[height<={best_video_height}]/best" - + result.append({ 'format_id': format_selector, 'label': f"{display_label} ({video_ext})", @@ -758,8 +310,7 @@ def get_youtube_formats(url: str) -> list[dict]: 'ext': video_ext, 'filesize_mb': round(total_size / 1024 / 1024, 1) if total_size else None, }) - - # Добавляем аудиодорожку + if best_audio_info['size']: result.append({ 'format_id': 'bestaudio/best', @@ -768,153 +319,166 @@ def get_youtube_formats(url: str) -> list[dict]: 'ext': best_audio_info['ext'], 'filesize_mb': round(best_audio_info['size'] / 1024 / 1024, 1) if best_audio_info['size'] else None, }) - - # --------------------------------------------------------------- - # Если реальных форматов совсем нет — генерируем оценочные - # (бывает при очень плохих cookies, когда даже format_note пустой) - # --------------------------------------------------------------- + + # Fallsback: если форматов нет — оценочные if len(result) == 0: logger.info(f"[FORMATS] Реальных форматов не найдено, генерируем оценочные") - - # Пытаемся определить реальную максимальную высоту из всех полей - max_possible_height = 0 - for f in formats: - height = _parse_height(f) - if height > max_possible_height: - max_possible_height = height - if max_possible_height == 0: - # Если ничего не смогли определить — используем format_note напрямую - for f in formats: - note = str(f.get('format_note', '') or '') - numbers = re.findall(r'(\d+)', note) - for num in numbers: - n = int(num) - if 100 < n < 10000 and n > max_possible_height: - max_possible_height = n - if max_possible_height == 0: - max_possible_height = 2160 - - available_tiers = [(h, l) for h, l in quality_tiers if h <= max_possible_height] - - TYPICAL_VIDEO_BITRATES: dict[int, int] = { - 2160: 40000, 1440: 20000, 1080: 10000, 720: 5000, - 480: 2500, 360: 1200, 240: 600, 144: 300, - } - AUDIO_BITRATE = 128 - - result = [] - + max_possible_height = max_actual_height if duration: - for max_height, label in available_tiers: - video_kbps = TYPICAL_VIDEO_BITRATES.get(max_height, 1000) - total_kbps = video_kbps + AUDIO_BITRATE - estimated_bytes = total_kbps * 1000 / 8 * duration - estimated_mb = round(estimated_bytes / 1024 / 1024, 1) - - format_selector = f"bestvideo[height<={max_height}]+bestaudio/best[height<={max_height}]" - + typical_bitrates = {2160: 40000, 1440: 20000, 1080: 10000, 720: 5000, + 480: 2500, 360: 1200, 240: 600, 144: 300} + for max_height, label in quality_tiers: + if max_height > max_possible_height: + continue + video_kbps = typical_bitrates.get(max_height, 1000) + total_kbps = video_kbps + 128 + bytes_est = total_kbps * 1000 / 8 * duration result.append({ - 'format_id': format_selector, + 'format_id': f"bestvideo[height<={max_height}]+bestaudio/best[height<={max_height}]", 'label': f"{label} (mp4)", 'quality': label, 'ext': 'mp4', - 'filesize_mb': estimated_mb, - }) - logger.info(f"[FORMATS] Оценка: {label}: ~{estimated_mb} МБ (битрейт {video_kbps} кбит/с)") - - audio_bytes = AUDIO_BITRATE * 1000 / 8 * duration - audio_mb = round(audio_bytes / 1024 / 1024, 1) - result.append({ - 'format_id': 'bestaudio/best', - 'label': f"Audio only (m4a)", - 'quality': 'audio', - 'ext': 'm4a', - 'filesize_mb': audio_mb, - }) - else: - for max_height, label in available_tiers: - format_selector = f"bestvideo[height<={max_height}]+bestaudio/best[height<={max_height}]" - result.append({ - 'format_id': format_selector, - 'label': label, - 'quality': label, - 'ext': 'mp4', - 'filesize_mb': None, + 'filesize_mb': round(bytes_est / 1024 / 1024, 1), }) + audio_bytes = 128 * 1000 / 8 * duration result.append({ 'format_id': 'bestaudio/best', 'label': 'Audio only (m4a)', 'quality': 'audio', 'ext': 'm4a', - 'filesize_mb': None, + 'filesize_mb': round(audio_bytes / 1024 / 1024, 1), }) - + logger.info(f"[FORMATS] Возвращаем {len(result)} форматов") return result -# Простой кэш форматов: {normalized_url: (timestamp, list_of_formats)} -# Форматы YouTube не меняются часто, кэшируем на 30 минут +# ═══════════════════════════════════════════════════════════════ +# Скачивание (subprocess yt-dlp CLI) +# ═══════════════════════════════════════════════════════════════ + +def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> tuple[Path, str]: + """Скачивает видео через subprocess yt-dlp CLI. + Возвращает (путь_к_файлу, 'cli').""" + logger.info(f"[DOWNLOAD] Начало скачивания: {url} (format={format_id})") + + if not format_id: + # Fallback chain через yt-dlp format selector + format_id = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best' + + safe_tmpl = str(DOWNLOADS_DIR / f'%(title)s_%(id)s.%(ext)s') + + for attempt in range(max_retries): + _cleanup_downloads() + + cmd = _build_ytdlp_base_cmd() + [ + '--downloader', 'aria2c', + '--downloader-args', + 'aria2c:--connect-timeout=15 --timeout=120 --max-tries=1', + '-f', format_id, + '-o', safe_tmpl, + url, + ] + + try: + result = _run_ytdlp(cmd, timeout=DOWNLOAD_TIMEOUT) + except subprocess.TimeoutExpired: + logger.error(f"[DOWNLOAD] yt-dlp timeout ({DOWNLOAD_TIMEOUT}s)") + if attempt < max_retries - 1: + time.sleep((attempt + 1) * 2) + continue + raise Exception(f"Превышен таймаут скачивания ({DOWNLOAD_TIMEOUT}с)") + + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'Destination:' in line: + logger.info(f"[DOWNLOAD] {line.strip()}") + + file = _find_latest_downloaded() + if file: + logger.info(f"[DOWNLOAD] Скачан файл: {file.name} ({file.stat().st_size} bytes)") + return file, 'cli' + + logger.error("[DOWNLOAD] Файл не найден после успешного yt-dlp") + raise Exception("Файл не найден после скачивания") + + # Обработка ошибок + stderr = result.stderr.strip()[-800:] + logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: yt-dlp failed: {stderr[:300]}") + + # Try without cookies on cookies-related errors + if ('cookies' in stderr.lower() or 'bot' in stderr.lower() or 'sign in' in stderr.lower()) \ + and '--cookies' in ' '.join(cmd): + logger.warning("[DOWNLOAD] Пробуем без cookies") + cmd_no_cookies = [a for i, a in enumerate(cmd) if a != '--cookies' and cmd[i-1] != '--cookies'] + try: + result2 = _run_ytdlp(cmd_no_cookies, timeout=DOWNLOAD_TIMEOUT) + if result2.returncode == 0: + file = _find_latest_downloaded() + if file: + return file, 'cli-no-cookies' + except Exception: + pass + + if attempt < max_retries - 1: + time.sleep((attempt + 1) * 2) + + raise Exception(f"Не удалось скачать видео после {max_retries} попыток") + + +# ═══════════════════════════════════════════════════════════════ +# Кэш форматов +# ═══════════════════════════════════════════════════════════════ + _formats_cache: dict[str, tuple[float, list[dict]]] = {} -_FORMATS_CACHE_TTL = 30 * 60 # 30 минут в секундах +_FORMATS_CACHE_TTL = 30 * 60 # 30 минут def _normalize_youtube_url(url: str) -> str: - """Нормализует YouTube URL: убирает tracking параметры (?si=...), - приводит к единому виду для кэширования.""" - import re - # Оставляем только video ID из youtu.be или youtube.com - # youtu.be/VIDEO_ID?si=... -> youtu.be/VIDEO_ID m = re.search(r'(youtu\.be/|youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})', url) if m: prefix, video_id = m.group(1), m.group(2) return f"https://www.youtube.com/watch?v={video_id}" - return url # если не распознали, кэшируем как есть + return url +# ═══════════════════════════════════════════════════════════════ +# Flask endpoints +# ═══════════════════════════════════════════════════════════════ + @app.route('/health', methods=['GET']) def health(): - """Health check endpoint""" return jsonify({'status': 'ok', 'service': 'youtube-downloader'}), 200 @app.route('/formats', methods=['POST']) def formats(): - """Возвращает список доступных форматов для YouTube URL""" request_id = str(uuid.uuid4())[:8] logger.info(f"[FORMATS {request_id}] ========== ЗАПРОС ФОРМАТОВ ==========") - + try: data = request.get_json() if not data or 'url' not in data: return jsonify({'error': 'URL is required'}), 400 - + url = data['url'] - if 'youtube.com' not in url and 'youtu.be' not in url: return jsonify({'error': 'Only YouTube URLs are supported'}), 400 - - # Нормализуем URL и проверяем кэш + cache_key = _normalize_youtube_url(url) now = time.time() - + if cache_key in _formats_cache: cached_time, cached_formats = _formats_cache[cache_key] if now - cached_time < _FORMATS_CACHE_TTL: - logger.info(f"[FORMATS {request_id}] Возвращаем из кэша ({len(cached_formats)} форматов, возраст {now - cached_time:.0f}с)") + logger.info(f"[FORMATS {request_id}] Кэш: {len(cached_formats)} форматов ({now - cached_time:.0f}с)") return jsonify({'formats': cached_formats}), 200 - else: - logger.info(f"[FORMATS {request_id}] Кэш устарел ({now - cached_time:.0f}с > {_FORMATS_CACHE_TTL}с), обновляем...") - del _formats_cache[cache_key] - + del _formats_cache[cache_key] + format_list = get_youtube_formats(url) - - # Сохраняем в кэш _formats_cache[cache_key] = (time.time(), format_list) - logger.info(f"[FORMATS {request_id}] Сохранено в кэш {len(format_list)} форматов для {cache_key}") - return jsonify({'formats': format_list}), 200 - + except Exception as e: logger.error(f"[FORMATS {request_id}] Ошибка: {e}") logger.error(traceback.format_exc()) @@ -923,100 +487,69 @@ def formats(): @app.route('/download/stream', methods=['POST']) def download_stream(): - """Скачивает видео с YouTube и возвращает бинарные данные""" request_id = str(uuid.uuid4())[:8] logger.info(f"[REQUEST {request_id}] ========== НОВЫЙ ЗАПРОС ==========") - logger.info(f"[REQUEST {request_id}] Метод: {request.method}") - logger.info(f"[REQUEST {request_id}] URL: {request.url}") - logger.info(f"[REQUEST {request_id}] Remote Address: {request.remote_addr}") - logger.info(f"[REQUEST {request_id}] Headers: {dict(request.headers)}") - + try: data = request.get_json() - logger.info(f"[REQUEST {request_id}] Body (JSON): {data}") - if not data or 'url' not in data: - logger.warning(f"[REQUEST {request_id}] Ошибка: URL не предоставлен в запросе") return jsonify({'error': 'URL is required'}), 400 - + url = data['url'] - format_id = data.get('format_id') # Опциональный параметр - logger.info(f"[REQUEST {request_id}] Получен запрос на скачивание (stream): {url}, format_id: {format_id}") - - # Проверяем, что это YouTube URL + format_id = data.get('format_id') + logger.info(f"[REQUEST {request_id}] Скачивание: {url}, format_id={format_id}") + if 'youtube.com' not in url and 'youtu.be' not in url: - logger.warning(f"[REQUEST {request_id}] Ошибка: URL не является YouTube URL: {url}") return jsonify({'error': 'Only YouTube URLs are supported'}), 400 - - # Скачиваем видео - logger.info(f"[REQUEST {request_id}] Начинаем скачивание видео...") + video_path, used_downloader = download_youtube_video(url, format_id=format_id) - logger.info(f"[REQUEST {request_id}] Видео успешно скачано: {video_path} (загрузчик: {used_downloader})") - - # Читаем файл и отправляем + logger.info(f"[REQUEST {request_id}] Видео скачано: {video_path} ({used_downloader})") + file_size = video_path.stat().st_size - logger.info(f"[REQUEST {request_id}] Размер файла: {file_size} байт") - with open(video_path, 'rb') as f: video_data = f.read() - - # Безопасное имя файла без кириллицы для заголовка + safe_filename = video_path.name.encode('ascii', 'ignore').decode('ascii') or 'youtube_video.mp4' - if not safe_filename.endswith(('.mp4', '.webm', '.mkv', '.m4a', '.mp3')): + if not any(safe_filename.endswith(ext) for ext in ('.mp4', '.webm', '.mkv', '.m4a', '.mp3')): safe_filename = 'youtube_video.mp4' - - # Определяем content-type по реальному расширению файла + ext = video_path.suffix.lower() content_type_map = { - '.webm': 'video/webm', - '.mkv': 'video/x-matroska', - '.mp4': 'video/mp4', - '.m4a': 'audio/mp4', - '.mp3': 'audio/mpeg', + '.webm': 'video/webm', '.mkv': 'video/x-matroska', + '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg', } content_type = content_type_map.get(ext, 'video/mp4') - - logger.info(f"[REQUEST {request_id}] Отправляем файл: {safe_filename}, Content-Type: {content_type}, размер: {len(video_data)} байт") - - # Удаляем временный файл + video_path.unlink() - logger.info(f"[REQUEST {request_id}] Временный файл удален") logger.info(f"[REQUEST {request_id}] ========== ЗАПРОС УСПЕШНО ЗАВЕРШЕН ==========") - + return video_data, 200, { 'Content-Type': content_type, 'Content-Disposition': f'attachment; filename="{safe_filename}"' } - + except Exception as e: error_str = str(e) - error_lower = error_str.lower() - logger.error(f"[REQUEST {request_id}] ========== ОШИБКА В ЗАПРОСЕ ==========") - logger.error(f"[REQUEST {request_id}] Ошибка при скачивании: {error_str}") - logger.error(f"[REQUEST {request_id}] Полный traceback:\n{traceback.format_exc()}") - - # Улучшаем сообщение об ошибке, если проблема с cookies - if 'cookies' in error_lower or 'bot' in error_lower or 'sign in' in error_lower or 'authentication' in error_lower: - logger.error(f"[REQUEST {request_id}] Обнаружена ошибка связанная с cookies!") + logger.error(f"[REQUEST {request_id}] ========== ОШИБКА ==========") + logger.error(f"[REQUEST {request_id}] {error_str}") + logger.error(traceback.format_exc()) + + if any(kw in error_str.lower() for kw in ('cookies', 'bot', 'sign in', 'authentication')): error_msg = ( f"{error_str}\n\n" "💡 Совет: Cookies устарели или недействительны. " - "Обновите cookies, запустив скрипт:\n" + "Обновите cookies через скрипт:\n" " ./youtube-downloader/get_youtube_cookies.sh\n" "Затем перезапустите сервис." ) else: error_msg = error_str - - logger.error(f"[REQUEST {request_id}] Возвращаем 500 ошибку клиенту") - logger.error(f"[REQUEST {request_id}] ========== КОНЕЦ ОБРАБОТКИ ОШИБКИ ==========") - + return jsonify({'error': error_msg}), 500 if __name__ == '__main__': - port = int(os.getenv('PORT', 5000)) # Внутренний порт контейнера + port = int(os.getenv('PORT', 5000)) host = os.getenv('HOST', '0.0.0.0') logger.info(f"Запуск YouTube Downloader сервиса на {host}:{port}") app.run(host=host, port=port, debug=False) - diff --git a/youtube-downloader/youtube_cookies.txt b/youtube-downloader/youtube_cookies.txt index d5f4b61..a2b37ef 100644 --- a/youtube-downloader/youtube_cookies.txt +++ b/youtube-downloader/youtube_cookies.txt @@ -1,42 +1,6 @@ # Netscape HTTP Cookie File # This file is generated by yt-dlp. Do not edit. -.youtube.com TRUE / TRUE 1766682211722 GPS 1 -.youtube.com TRUE / TRUE 1801240447479 LOGIN_INFO AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g:QUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1766680454000 ST-l3hjtt session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1766680833000 ST-c5kgne itct=CKEDEIf2BBgEIhMI7ciKxJXZkQMVkHX2CB1YMhVxWg9GRXdoYXRfdG9fd2F0Y2iaAQUIJBCOHsoBBBx5ENY%3D&csn=-28VKqawlbGtJA_i&session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn&endpoint=%7B%22clickTrackingParams%22%3A%22CKEDEIf2BBgEIhMI7ciKxJXZkQMVkHX2CB1YMhVxWg9GRXdoYXRfdG9fd2F0Y2iaAQUIJBCOHsoBBBx5ENY%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FL_ssdybrIEg%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22L_ssdybrIEg%22%2C%22playerParams%22%3A%228AEBoAMByAMkuAQFogYVAV9WnuN4cdq_CrzWOW9SPJmZqqj_kAcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FL_ssdybrIEg%2Fframe0.jpg%22%2C%22width%22%3A720%2C%22height%22%3A1280%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CKUDELC1BCITCO3IisSV2ZEDFZB19ggdWDIVcQ%3D%3D%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUwAroBGFVDaEhvTmNZREV3TkVDNHlxTE1zd1Rfdw%253D%253D%22%2C%22sequenceProvider%22%3A%22REEL_WATCH_SEQUENCE_PROVIDER_RPC%22%2C%22sequenceParams%22%3A%22CgtMX3NzZHlicklFZyoCGAVQGWgA%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%7D%7D -.youtube.com TRUE / FALSE 1766680920000 ST-12qny8p itct=CLsDEIf2BBgAIhMI5Mfy_5bZkQMVdKYnAh2_LhBIWg9GRXdoYXRfdG9fd2F0Y2iaAQUIJBCOHsoBBBx5ENY%3D&csn=jdKcWNZwnjJNcfRK&session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn&endpoint=%7B%22clickTrackingParams%22%3A%22CLsDEIf2BBgAIhMI5Mfy_5bZkQMVdKYnAh2_LhBIWg9GRXdoYXRfdG9fd2F0Y2iaAQUIJBCOHsoBBBx5ENY%3D%22%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2Fshorts%2FN0qGefDGh1g%22%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_SHORTS%22%2C%22rootVe%22%3A37414%7D%7D%2C%22reelWatchEndpoint%22%3A%7B%22videoId%22%3A%22N0qGefDGh1g%22%2C%22playerParams%22%3A%228AEBoAMByAMkuAQFogYVAV9WnuPoQ-rLcmTTs6scHZ-JYaKVkAcC%22%2C%22thumbnail%22%3A%7B%22thumbnails%22%3A%5B%7B%22url%22%3A%22https%3A%2F%2Fi.ytimg.com%2Fvi%2FN0qGefDGh1g%2Fframe0.jpg%22%2C%22width%22%3A1080%2C%22height%22%3A1920%7D%5D%2C%22isOriginalAspectRatio%22%3Atrue%7D%2C%22overlay%22%3A%7B%22reelPlayerOverlayRenderer%22%3A%7B%22style%22%3A%22REEL_PLAYER_OVERLAY_STYLE_SHORTS%22%2C%22trackingParams%22%3A%22CL8DELC1BCITCOTH8v-W2ZEDFXSmJwIdvy4QSA%3D%3D%22%2C%22reelPlayerNavigationModel%22%3A%22REEL_PLAYER_NAVIGATION_MODEL_UNSPECIFIED%22%7D%7D%2C%22params%22%3A%22CAUwAroBGFVDUXVDdkExSmpFVzhZRmpEM2hKOVppUQ%253D%253D%22%2C%22sequenceProvider%22%3A%22REEL_WATCH_SEQUENCE_PROVIDER_RPC%22%2C%22sequenceParams%22%3A%22CgtOMHFHZWZER2gxZyoCGAVQGWgA%22%2C%22loggingContext%22%3A%7B%22vssLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%2C%22qoeLoggingContext%22%3A%7B%22serializedContextData%22%3A%22CgIIDA%253D%253D%22%7D%7D%2C%22ustreamerConfig%22%3A%22CAw%3D%22%7D%7D -.youtube.com TRUE / TRUE 1766681517000 CONSISTENCY APeVyi9lOfhC2Ta5yM1yn4DTAYRRHcOo9i7wdXBcPbloVqCxTId1mvCwO3dFKSNLh3UHggSmH5xpiF33YG_7Agc-dpZgOmVYBH_698K8ZqGlitQrYuYLSbgf_TU -.youtube.com TRUE / FALSE 1767884327000 ST-1supwba session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1771971074000 ST-bvum61 csn=4GzEyTOTHuK_0tEJ&itct=CKgEEIf2BBgCIhMI3dzc8ZHzkgMVXw6iAx39fglgWg9GRXdoYXRfdG9fd2F0Y2iaAQUIJBCOHsoBBLFi_DM%3D&session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1771971074000 ST-1dsf764 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1772057340000 ST-hcbf8d session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1772093274000 ST-1b disableCache=true&session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn&endpoint=%7B%22browseEndpoint%22%3A%7B%22browseId%22%3A%22FEwhat_to_watch%22%7D%2C%22commandMetadata%22%3A%7B%22webCommandMetadata%22%3A%7B%22url%22%3A%22%2F%22%2C%22rootVe%22%3A3854%2C%22webPageType%22%3A%22WEB_PAGE_TYPE_BROWSE%22%7D%7D%7D -.youtube.com TRUE / FALSE 1840832909 HSID AyQ5v_SYe7XVSwk4B -.youtube.com TRUE / TRUE 1840832909 SSID A6URSCEMDAehLdZmX -.youtube.com TRUE / FALSE 1840832909 APISID 8dbTFmLBSXBgxwR5/Aqxn9OCBXLwhMCr-P -.youtube.com TRUE / TRUE 1840832909 SAPISID T-VywQwW6YYwPZ05/AVcBJlwHBEyhqZuI6 -.youtube.com TRUE / TRUE 1840832909 __Secure-1PAPISID T-VywQwW6YYwPZ05/AVcBJlwHBEyhqZuI6 -.youtube.com TRUE / TRUE 1840832909 __Secure-3PAPISID T-VywQwW6YYwPZ05/AVcBJlwHBEyhqZuI6 -.youtube.com TRUE / FALSE 1840832909 SID g.a0009ghwIHcIZqqcY1WV989v420rAlDDepZEj46RPYROUv0etocZfpJzYL10nsGcwt3tO1SfpQACgYKAcoSARISFQHGX2Mi8GOe9epkX1gj-mPmGiEkqhoVAUF8yKqrUaL07JB8aPrxKDzH094X0076 -.youtube.com TRUE / TRUE 1840832909 __Secure-1PSID g.a0009ghwIHcIZqqcY1WV989v420rAlDDepZEj46RPYROUv0etocZUxys_3LZCIbQ68z8wQ2c_wACgYKAa0SARISFQHGX2MijinAP4ZtZI0DQThrXUqIpBoVAUF8yKpJ3DA2uat4crjoKJ6Yo6zB0076 -.youtube.com TRUE / TRUE 1840832909 __Secure-3PSID g.a0009ghwIHcIZqqcY1WV989v420rAlDDepZEj46RPYROUv0etocZGrDGBah2HczPmxnQjYdK1gACgYKAQQSARISFQHGX2MiACXkSAqve--bS36VrXAmBhoVAUF8yKrUoj5-724_fZGNhKeh8uds0076 -.youtube.com TRUE / TRUE 1791136352432 __Secure-BUCKET CMoC -.youtube.com TRUE / TRUE 1793886853 __Secure-YNID 18.YT=HI8YHeAKQNimdb5LgRIak_GASZ4Wh5RTxg2Wxl3jX_nQk7nUzmgM8MBzMMFzsXHCbpxvvhwa60OjexrdexVxJX1wLjnuUJIiMli1BVTumhhrAbZJ4jh1piZ6XcxgkeGJa7b1NmLbOKo8PviUQiLyHjbs5sFYwufNov8shQZdO2CM6yiBL0feOdLihR9bw8HOIxpbJczRhEpYEbvpYnI3frOeds1xbCEpf3Abr7yxs6XxrZEpxa3djITU1nYva2Gjug-72qEaOm7uhS4V-A-mkqIheEswKC9X3P1OPyMPK6DS6-95mqkeI8lkDcyhb0jqR5gidkMH8ZotxNpNSmPB7w -.youtube.com TRUE / TRUE 1793886853 __Secure-ROLLOUT_TOKEN CKPS2eDK6Lu50QEQwdv1spXZkQMYvYeHyq2slAM%3D -.youtube.com TRUE / FALSE 1776287761000 ST-yve142 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / TRUE 1807824503515 __Secure-1PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA -.youtube.com TRUE / TRUE 1807824503517 __Secure-3PSIDTS sidts-CjUBWhotCSAL5EMsgNfc0JD8UVvU5vyCYbx9ZFc0Nnry9Qc7YHRzl6a7o8Zm6bPYHoFyKALKlBAA -.youtube.com TRUE / TRUE 1793886874 VISITOR_INFO1_LIVE vFr43YvHJaE -.youtube.com TRUE / TRUE 1793886874 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgMg%3D%3D -.youtube.com TRUE / FALSE 1776288519000 ST-tladcw session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 0 PREF tz=UTC&f7=100&f6=40000000&hl=en -.youtube.com TRUE / FALSE 1776288527000 ST-xuwub9 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / FALSE 1809870874 SIDCC AKEyXzXxD9dGVTRHii-ZSRXa5S1jG0nc_r5gPRZM_Q7R_SLG6inpYAArIdKqekxyCRG2fYQBknBS -.youtube.com TRUE / TRUE 1809870874 __Secure-1PSIDCC AKEyXzWA_Zl95hX_7PFnMrNh13Lx3gpntyOhJBaDO_WSRz37gqrtGtfMYVlovN3fCHG4evRFjRgO -.youtube.com TRUE / TRUE 1809870874 __Secure-3PSIDCC AKEyXzUd2Cpu9M26M0TfDtVLBG_-eqaREqqYreCv-9GHeMq4qPIt97VsHDJUTWkws5O_8vxSQjpC -.youtube.com TRUE / FALSE 1776288585000 ST-3opvp5 session_logininfo=AFmmF2swRQIgZPfEOdmfC8u5sHvE1aOagKEvp5rRUe5hRUeLiYmxLDwCIQDqFIR59yZ_aBb5BLYSpK7LGdJ6YZqnh32USuOyMZTC5g%3AQUQ3MjNmd0ZzX01fTjViQ2kzMDJEWG5Ed09zMGF1TlhJcm81YWt3WWdKS2RCZkY3Z2NmMVhudUF4MFVZdFlHd0YtaEU0R3VHNHQ3VmFSZHdfR1RIcnBJNUtXeWhKWVVScE1ZcXNJdzRfdkFGVi1lZzY2dWxCcVVGZ0FPSjNzVmFjTVg1YTBYS0xBajEzU1REM3dnbUc5U3E3NHVtLVRLLXRn -.youtube.com TRUE / TRUE 0 YSC KTvrS45hA30 .instagram.com TRUE / TRUE 1801240452128 datr hGdNaS-QqakSYV8X2eqVTIyA .instagram.com TRUE / TRUE 1798216452128 ig_did 2C886E85-30B9-4495-B882-D9F545DF28E4 .instagram.com TRUE / TRUE 1801240453000 mid aU1nhAAEAAGuKRzTGE9SdmhLzZ5Z @@ -49,4 +13,77 @@ .instagram.com TRUE / TRUE 1784064574939 ds_user_id 42059678244 .paddle.com TRUE / TRUE 1767873458801 __cf_bm kyvXKCFXO7PSDduU6JPNr6Ir2Erz.SSEgs9otf4WVVI-1767871658-1.0.1.1-zr41azuyHRb5xBt5aQUyjvYKNeH6aPQ1bzhDzHbuVvUQJrNn4GWWn2q6vpiKjMOLOaasz5FP7sRzRylD8i4WdvhAvy.co9MGPEFA6xMKO0E firefox.autorefresh.page FALSE / TRUE 1772117935856 app_session eyJpdiI6IjlmckZ0bkdVZUJ4SXgwZUh4Vm9FVXc9PSIsInZhbHVlIjoidmozQTZmWHJ3dlBxdHBETy9ZWUJmTkkvbTFGdDM4dlhRS0VvSnBSM2RNZ3dPaStSbUJXTTNCMVRnZE84UW9XUXB5NVlzUVZ1MEYxZHg5OTdtMjhUSWg1UCtXV1h0UTlkS1lGblFKQXRzK3dDQ1RUMUxzd0tKUFZ2UmpQaSs4OEIiLCJtYWMiOiJjZjY4YmVhMWMwOTRiNzI1MzkwNjdhOTllZGYyMWE2NGI1NDVmODFkZmU5ZDk5NmMxMWYyNTA3YjExMDY2M2ViIiwidGFnIjoiIn0%3D -.facebook.com TRUE / TRUE 1784064559497 fr 0AcR44m5KTXADbTc8.AWehfS67NxYY9SrAf1c-itas_z26hWpd9OyZ9HwNKd41qG_Mxag.Bp4AMv..AAA.0.0.Bp4AMv.AWdIcrGHWEZWg8TB5d4kBwxLyHA +.google.com TRUE / TRUE 1793956296355 AEC AaJma5ujEHtwwu426QfYJYS1u___e4k45nnyXypfttr-bHMS4gkXvLQHHpI +.google.com TRUE / TRUE 1793956296355 __Secure-BUCKET CKgD +.google.com TRUE / FALSE 1778415106513 GOOGLE_ABUSE_EXEMPTION ID=7464237b22409821:TM=1778404306:C=R:IP=89.111.27.33-:S=GjXiSvGnm4v6eztkSSfaKXg +.google.com TRUE / TRUE 1778404617984 __Secure-STRP AEEP7gIoxfZ4dhqrI6bdyKj9-zf8aRnoToi4o65DGWOO1NhXXWHxnr85Vonuw4yu2Krlm4xRKi5kam7KVEdxACQRt2Zv993YC-v2 +.google.com TRUE / FALSE 1793956418000 SEARCH_SAMESITE CgQI56AB +.google.com TRUE / FALSE 1813436107561 HSID AZbmeMqR5tYb_Qvq7 +.google.com TRUE / TRUE 1813436107564 SSID AE4gWoWznYB_ywXCC +.google.com TRUE / FALSE 1813436107564 APISID 29VneHfJm4ayiIDC/AulmGROlxxpjqDQ3I +.google.com TRUE / TRUE 1813436107564 SAPISID ZMuzHsMj98vPCMyJ/ArsbIyY--iqLkvtUV +.google.com TRUE / TRUE 1813436107564 __Secure-1PAPISID ZMuzHsMj98vPCMyJ/ArsbIyY--iqLkvtUV +.google.com TRUE / TRUE 1813436107564 __Secure-3PAPISID ZMuzHsMj98vPCMyJ/ArsbIyY--iqLkvtUV +.google.com TRUE / FALSE 1813436107564 SID g.a000-AhwIKfnU_xfjF03z1a3MWhcI1Hql4mPVyVvqfLa_st6_TfXfFBugDSmLD2HMYMk8CXFewACgYKARESARISFQHGX2MitNuTeeVBSF7CijFkj1Hc7hoVAUF8yKpvT4_7yGAQB3j-kMyKrdPm0076 +.google.com TRUE / TRUE 1813436107564 __Secure-1PSID g.a000-AhwIKfnU_xfjF03z1a3MWhcI1Hql4mPVyVvqfLa_st6_TfXg1H6-6-cMspTM3qWNFrKBQACgYKASkSARISFQHGX2Miikxcnz7qwbhoy4ZmrPmlHRoVAUF8yKr7kVycIEgX3VNAejJQp5d50076 +.google.com TRUE / TRUE 1813436107564 __Secure-3PSID g.a000-AhwIKfnU_xfjF03z1a3MWhcI1Hql4mPVyVvqfLa_st6_TfXp7rOiQ1cmq6CcZfRmoWFKwACgYKAeMSARISFQHGX2MixryF_qgLOtpAj2YrqYUCfhoVAUF8yKpS9-bnBgImbgJi0LyQYd1T0076 +.google.com TRUE / TRUE 1795082095750 NID 531=UFytwvIl0ZqsUHFXvBOjcpU6U8KWUJrqwv-JvL-kGoLQ25Sm5vY3IeiUxx2962W9Ta1lslWlWy7d3CAt1Z3I7uSognzMfftiN5K_dVm1qRMiSfDffUfiOtsFS8eZkczNfBhtUSdTRiCW1QuoU4IsTzj7KUK-AJ7UQpIJi4Cl1VNvyFV5lHI9HonmVPzp3D2UgHlfkh0xBf3WQWBz7wLnl2CFXFXnOm2EldDvnR16KydJ_rjSUnGfc6_EyOlfDi7sZ8Snj2XnpRcBGKk9V8tGkm5ppFc95E0IrxHcdLQJBTQNHu6-ZT3PPGycNBjwhbQy8lQiXz6UPoZmPruBoc0HV5v5GYs_neagJSIF0z2TXpwn318uMaPSFhCHZx3gF_eSJMM8oxtmr5QwjCnC5Pp0IoIvKOz_yyqWVBGZhwepAj5rKiUta8KZHbr-vNl15v-2p1mV2KdpZewBiGkPswjAQwWYg4fMMgBi8WQqD4IvA-9nzA4R2gDj5sOWJ8meTTg5tkq7DdbOP92U-J6Ul71LKq3FwwFK4kQfVwOxWL2UcHgd2bib3fPBpEEhdKPCIv4lUifhCaLp64mSzGYyvgGM20cCkEqXDIWYsxP3UeTRotVgmmHhvCQ_WhVA-0fcVLFFCAHtwxYeUviIcd37bohb3AGDzMhDj15PEgLtxDfM7RgWdbOfTIYOhdwEO5iHEl28RpodCT0qtF9YbYxoZGXPajgbpzU +.google.com TRUE / TRUE 1810890816743 __Secure-1PSIDTS sidts-CjEBhkeRd_-LvZ5Zg9j_DUYlFxYF93J1RiAbWunLifxePeMFWFMlE9sfnGOpXg3vq5nAEAA +.google.com TRUE / TRUE 1810890816745 __Secure-3PSIDTS sidts-CjEBhkeRd_-LvZ5Zg9j_DUYlFxYF93J1RiAbWunLifxePeMFWFMlE9sfnGOpXg3vq5nAEAA +.google.com TRUE / FALSE 1810891252698 SIDCC AKEyXzVzicRDz2q4NBYxew1k2_2EKE1BrRD9e5Yj12zgxFTSjbh2hj_Ac75bUtE_kwWAoJW-Ags +.google.com TRUE / TRUE 1810891252698 __Secure-1PSIDCC AKEyXzWlYR10DYiqeFilF1UwP6z5oV7V1X0Yt-wAcV2IVrtWSNaeGQy7_OK4Pn5TwrmoGf1Si25a +.google.com TRUE / TRUE 1810891252698 __Secure-3PSIDCC AKEyXzXX0qGkkXvppfikPcpH8OyHehXmtSrkHyg2QMDIXInspv7bWcD_6fU9iMKYpvb6yrIIy_Yh +.google.com TRUE /verify TRUE 1794215497053 SNID AEDUgYbS6t3ds0u85ixPJQngAxX0_xZUL7a3aggsVG9siQ-va5ewFW_x4GjzWUSLeLL0bnHmsKFRCFRlkqYO0o8iMY0ylCCYhgM +www.google.com FALSE /recaptcha TRUE 1793956309759 _GRECAPTCHA 09AKhCRwiDW_fB_EMDYSz2ihlZ7oz4N9f3T6ZyAt_DTpbeImhKVAo6hL_9xhdrwc80QvGt8zoqVhVL0onLGup1YBo +www.google.com FALSE / FALSE 1778404917000 DV s2Jea-aA6XUgQIF0XFQkBVjtboUS4VnDPM3RSFa0BQMAAAA +amnezia.org FALSE / FALSE 1794172320000 _pk_ref.1.ec26 %5B%22%22%2C%22%22%2C1778404320%2C%22https%3A%2F%2Fwww.google.com%2F%22%5D +amnezia.org FALSE / FALSE 1812359520000 _pk_id.1.ec26 fca74fa6c0147537.1778404320. +amnezia.org FALSE / FALSE 1778406125000 _pk_ses.1.ec26 1 +accounts.google.com FALSE / TRUE 1780996393000 OTZ 8601673_44_44_123780_40_436260 +accounts.google.com FALSE / TRUE 1812964413928 __Host-GAPS 1:B6GlYJ_WdyuLJYjf5YRaGCcD7rXk5RTq4HN9-W0PTPA0EIjL-B8oGDVLMFQK-ucl4A2fCUMDISVN8SDy1FdMT6KOd3NvJA:clOB3o9ku4U2b_c1 +accounts.google.com FALSE / TRUE 1812964413928 SMSV ADHTe-DsjXpTrtrda4vdYOVDIZ8zRZT0kpi1UNRM3zxuhXtzF1JBrMQzZqt-nWLkSOae4egW0apH7eWQDES_VrEYYfJ26cZyRWvITSmqSf-n1NoTM2X_Fio +accounts.google.com FALSE / TRUE 1812964414088 ACCOUNT_CHOOSER AFx_qI5jVzeLVNAOJ9mR6IXi7LNPDUP6FoIG6ClzTLhkAGykwS7dGx6AihZbZnveMfqeF8xOT6hreJh-5GKE1dls7txTFNNYaWW46Nw-gZAT1P0PnJQExrFwsQsfky2LqwXz7cDw8bX3ln-V1mZRuJkuVqCxy8PyAg +accounts.google.com FALSE / TRUE 1812964440647 LSID o.chat.google.com|o.mail.google.com|o.meet.google.com|s.RU|s.youtube:g.a0009whwIGF9mC8b8qBwIClhgEZFyHr4Rq4bn8l32V9f0zeIDQWFhA6Z-kRv-x1u1dqNuzl-cwACgYKAcUSARISFQHGX2MieEgGVtxXuIUtjbzxfxiMExoVAUF8yKqUTnKuHX2X3kH7Uq5pNVIb0076 +accounts.google.com FALSE / TRUE 1812964440648 __Host-1PLSID o.chat.google.com|o.mail.google.com|o.meet.google.com|s.RU|s.youtube:g.a0009whwIGF9mC8b8qBwIClhgEZFyHr4Rq4bn8l32V9f0zeIDQWFoUHWtKibj2lS56wrQhTpYgACgYKAU8SARISFQHGX2MiZlEuU0vmGwgRq0aGiWQXEBoVAUF8yKrxxr5oINBT6vP7i3mBGb1n0076 +accounts.google.com FALSE / TRUE 1812964440648 __Host-3PLSID o.chat.google.com|o.mail.google.com|o.meet.google.com|s.RU|s.youtube:g.a0009whwIGF9mC8b8qBwIClhgEZFyHr4Rq4bn8l32V9f0zeIDQWFZ_iWMJPFn34sx5dIK-1IqAACgYKAQQSARISFQHGX2Mixfz8oTSpOjugcI6mgJaZOBoVAUF8yKrcYX6ONy4TbG9Q51aFyv2z0076 +mail.google.com FALSE /mail TRUE 1779268414841 COMPASS gmail_ps=CrMBAAlriVffIAHOcbwLKxw82DFHMAFFBBdJRx54IGpsLubMlKvrJFgLAUMeW0uCOXj9FpYDWorA_cgtMyIrIMdQ0Rcsi4161w2R4Z3nyd_uraFfEo1G0i9BV0xsqz0mbJL7nysbJCJ9HKAV0FCMQkzmO8g86BHMDaSzp-1sdCWCbGNjU5POMkySDzM-sn25-iFBgVwtSncpqMJJjZ5T5dT9glVQU21xmV0MIiychMxEj7wXh6QQ_LqG0AYa6gEACWuJV-XHQThSTjU0px5eSprcf2SF6nl7SnMwhTku6s96Bw4ipAxrkg84RGEuWVERB08yO8n2CDk5PbX8xrXwureDzasX2UN09krYVTMOm0ZqQoJIV5gL4afzCQRmD8D-11lXjv1ngTKWi5bxOHHXjNaDG4SSbC2oABv2ykQAOiyY8aUE8GNCjYOTnJgMf3_7XmA5obGaSjEmhIYXICK7OQjdufzLLf3q7wku8_4aknTKJVjO_5a2_rc__bJdSR9s3xQTFnLVYlyn0bO4me3sWUdMd-enAlTMqYBUDv15gmCp0AScVRX3A0owAQ +mail.google.com FALSE / TRUE 1780996415228 __Host-GMAIL_SCH_GMN 1 +mail.google.com FALSE / TRUE 1780996415230 __Host-GMAIL_SCH_GMS 1 +mail.google.com FALSE / TRUE 1780996415230 __Host-GMAIL_SCH_GML 1 +mail.google.com FALSE / TRUE 1779268419976 COMPASS appsfrontendserver=CgAQ07SB0AYaewAJa4lXcZMcX7MMXP_l_kqt_9_sckql7O7vfboUgIxNJVYHEuUkykcNPS_SFDYwO4YHfOI_8tMVe21ppDll3gQDGnr1tmraorrLmosDvQJXfpjvk8-90bBg_gXatn4jGizBVab_jibgd_5D-zhNJhKGcXhL3YKsw3Dl3yABMAE +mail.google.com FALSE / TRUE 1813436107564 OSID g.a000-AhwIJ9Zi54fxErD9GDllWL2nxtfwpoLszg9W8gw23voeOO0SsiSFzoKLeGJ_Pw20l1jHQACgYKAWMSARISFQHGX2MieaVvbDV1rMJZeyZQDlmtrRoVAUF8yKq7uokDCUURG7lQYcCKi-1q0076 +mail.google.com FALSE / TRUE 1813436107564 __Secure-OSID g.a000-AhwIJ9Zi54fxErD9GDllWL2nxtfwpoLszg9W8gw23voeOO0m4h6QqN_dp4YzoWnhoEogQACgYKAXUSARISFQHGX2MibO8sh8oG7EORDGI7S4p0HRoVAUF8yKpnHKRLohB6RZ45z_Y9PHkW0076 +mail.google.com FALSE /sync/u/0 TRUE 1780150904636 COMPASS appsfrontendserver=CgAQ07SB0AYaewAJa4lXcZMcX7MMXP_l_kqt_9_sckql7O7vfboUgIxNJVYHEuUkykcNPS_SFDYwO4YHfOI_8tMVe21ppDll3gQDGnr1tmraorrLmosDvQJXfpjvk8-90bBg_gXatn4jGizBVab_jibgd_5D-zhNJhKGcXhL3YKsw3Dl3yABMAE:bigtop-sync=CsMBAAlriVeewMZI6-LAeBh6WXwenM_9wqc2cBTx6hnc0EN3Zfzlp4ZSvvo9kgvrPG6iFg8_Xczbg98G_pULUQjgIaQPSk5pm77vi_ZgrenL1M2uHDy0Qh7hT9lLLN01ScwjXe3J4AA0mw0UWtXwPApySzHkSGOQdqvAltDqZn0DV4cXXGOwy6JRJLaRfPjRiChvuoaxmquYpGlZ66tpR57U1tU6CHUBgaIvvbDqEXxN-Lots7pnoj4k8f3vIBBQmi58oOSyEN-lvNAGGvoBAAlriVdiIFetSftmMAWJHkiEyeyjo4lQSrKAa-GDhQxMDvUBYpflWK1QoOags3rO5YiJAqNEvFJtLsiLEcNMc1QYJOvP4mNPRSwShO-2L-ncL18ukT4yz_w5KdzVL-7wjo92eIMHrLCE0HriqRNhTVZP5Vze4hTahwQzbppd9eyJ3jx26Aq6GDxEWCxvq7Hg35ROf1L70OeCaGNhNONdVFcfvXJQGdM1-KLHxLg7neptLZYdvIU7tJj-m9x1z7mM76N-mizcqYG7A_wChonYBP_vMo8z9QQneLjo9v_FcfQO4ToD89vxCTXFKAszYWHm7KaByDKk-o-oZTAB +mail.google.com FALSE /mail/u/0 TRUE 1780218898671 COMPASS gmail_ps=CrMBAAlriVffIAHOcbwLKxw82DFHMAFFBBdJRx54IGpsLubMlKvrJFgLAUMeW0uCOXj9FpYDWorA_cgtMyIrIMdQ0Rcsi4161w2R4Z3nyd_uraFfEo1G0i9BV0xsqz0mbJL7nysbJCJ9HKAV0FCMQkzmO8g86BHMDaSzp-1sdCWCbGNjU5POMkySDzM-sn25-iFBgVwtSncpqMJJjZ5T5dT9glVQU21xmV0MIiychMxEj7wXh6QQ6ZW80AYa6gEACWuJV-XHQThSTjU0px5eSprcf2SF6nl7SnMwhTku6s96Bw4ipAxrkg84RGEuWVERB08yO8n2CDk5PbX8xrXwureDzasX2UN09krYVTMOm0ZqQoJIV5gL4afzCQRmD8D-11lXjv1ngTKWi5bxOHHXjNaDG4SSbC2oABv2ykQAOiyY8aUE8GNCjYOTnJgMf3_7XmA5obGaSjEmhIYXICK7OQjdufzLLf3q7wku8_4aknTKJVjO_5a2_rc__bJdSR9s3xQTFnLVYlyn0bO4me3sWUdMd-enAlTMqYBUDv15gmCp0AScVRX3A0owAQ:gmail=CsIBAAlriVccNx06pbIAWSI37Uq4CShCPg0egAH7eAqsKRkCNpg9t5hzIP7hI18i00eUUE4CihgPdAQ5yskCrxM2RdXQXTxPyUo-5H44PqwhBmxLq3ST-iZaFMLiblHTJEUP9mRg8k-tky8_596h9pJ18JACGJsqVOg6Rk_vxmGHQ_nMHWQKcDGUJJ_aHWlWLExmubsSVqjD6E93IaxFbOQK2rCJpnZzzLN62OMSUCegAL1qfYP-35zbhVgipxygGsMd4RUQv7_A0AYa-QEACWuJV5p-CjJvagUuzoNmxqe6Nqnlx7T35uRISQtzDjb3IK7jMf4fwl1E7MdO78Iup5Aa3BlkscMu8SnPKyiPsQzoKTmSMA-8J3qrQNOPP-biMs4QO3XXe_THlNa8-jBz6iICSdoFVokuhjax8Yt9NsoABqrVNVPY0B2M_WUEm3wvggdJ4JB8gqclSRNAC4Pgng6gFAu1zIJb9Qf3WPX_5sq2pwV4TrkBcEHxDouiqZVuLd7tEBwQX9gblXq4see_gIdI_l2NhTV11wRrKzIZQLlP-3xp-vG92P9Ove-bY-4xNToBol3ktJ8LEA4BqJmdL4v7Qb9XRiYwAQ +chat.google.com FALSE / TRUE 1812964416060 OSID g.a0009whwIIXQNdC6qpPY4RRVOXKyc9FGmkI3xHGaVqZhqvqg-AxjfKiBVoPP522JR-Sd7vKvBgACgYKAQcSARISFQHGX2Mi2G3j0oSCBWzco_fpaTag-BoVAUF8yKpzzAUVmYzl68rtcTxoF25e0076 +chat.google.com FALSE / TRUE 1812964416066 __Secure-OSID g.a0009whwIIXQNdC6qpPY4RRVOXKyc9FGmkI3xHGaVqZhqvqg-AxjffAG33KMENiO9cXPZERaHgACgYKAWISARISFQHGX2Mi0_SLBnqoB6-GyawqkhB48xoVAUF8yKpJjXR03PK-RCfGrQHrwwzP0076 +chat.google.com FALSE / TRUE 1780996420000 OTZ 8601674_44_44_123780_40_436260 +chat.google.com FALSE / TRUE 1780215762299 COMPASS dynamite-ui=CgAQ4p270AYaZQAJa4lX0PHy33nrOUkzsKPzJs2IEKDv1E-1Dht6FlJ9P-ZtWAzeV4LoJZ20C2gy0KoyKGWikyN95Q4k92jIzHHx0GSTBZVmuSgM6QG0b1PObHpZUmILLwlvlax5CEyUwRooEFqqIAEwAQ:dynamite-frontend=CgAQ9Jy70AYaZAAJa4lXTSV5k0rMqXDsdd8Sm7zGTDKws42hwXrHQK23723aulxD0HiUtc5B8VbGLgnRQbkxcOHFNcaqi8G91bC28Mu6mh76H0ZPXG4kzN5BZ6SmO1AO3HxuHrGn45wANDE-HoYwAQ +chat.google.com FALSE /u/0/webchannel/ TRUE 1780217746257 COMPASS dynamite-ui=CgAQ0LSB0AYaZQAJa4lX0PHy33nrOUkzsKPzJs2IEKDv1E-1Dht6FlJ9P-ZtWAzeV4LoJZ20C2gy0KoyKGWikyN95Q4k92jIzHHx0GSTBZVmuSgM6QG0b1PObHpZUmILLwlvlax5CEyUwRooEFqqIAEwAQ:dynamite=CgAQoq270AYahwEACWuJVz8m-bFvg7avN7tfl-NhirF79lEv-zZegMHN2AU7VUdlMiC3NqSIQlrFMKbyWeVJqDjZgP8wQ2tYk-lBtqnb4XjVBsMUTXKxC-umAP6o1HjdZV7FUW_L_H8vkLwYOqOmdshEkD4CixDkarcYX-e21aME0fwtzXGz6sXKtNkPW-p624EwAQ +ogs.google.com FALSE / TRUE 1780996422000 OTZ 8601674_44_44_123780_40_436260 +contacts.google.com FALSE / TRUE 1780996427000 OTZ 8601674_44_44_123780_40_436260 +meet.google.com FALSE / TRUE 1812964440744 OSID g.a0009whwIOnMBVgT1YTsd63h54w5OIENqUO6BZs4NHLfGDNGbHi6t4FU8xeTkmULgi16ZYzHNgACgYKASASARISFQHGX2MiA4ELStx6GN3y7Aq3EN3nrxoVAUF8yKqDyxuTCLOfD8ueJ7MgcuiD0076 +meet.google.com FALSE / TRUE 1812964440746 __Secure-OSID g.a0009whwIOnMBVgT1YTsd63h54w5OIENqUO6BZs4NHLfGDNGbHi60J_AeNGuTT1kq7D_v9yLUgACgYKAQwSARISFQHGX2MiAgAb_QG2r06F7gU81wcR_hoVAUF8yKrD_SGVMxcdqWDC6g3asSak0076 +meet.google.com FALSE / TRUE 1779268440910 COMPASS meet-ui=CgAQsJ2B0AYacQAJa4lXSoz8LRFky3cJhYFO5acVt1M88JXUofFd7Bt7GPiuJ1lgy3WKVKJgW9sFuLuioPHnoSUafDcgQ270cmJF_wo3BMiv31U_zXYyzapPiCtiTtn_w8-nHcAUOPb_dFypHoqHzzsuYUHo73gIjBGnMAE +meet.google.com FALSE / TRUE 1780996441000 OTZ 8601674_44_44_123780_40_436260 +.first-am.ru TRUE / TRUE 1778405196000 _sas.ca3141d462a5d666c3e8fee89bb793f2b5f8aa3cfdeea7d629a1d6f0d9bc44d9 SA1.98feab1d-a04a-4eee-ab55-65b8d10a41f1.1778404593.1778404593 +.chatgpt.com TRUE / FALSE 1809508769842 oai-did bdcd7dde-b637-4999-a5ea-02434b01fe60 +.chatgpt.com TRUE / TRUE 1809940897692 cf_clearance 0PgOpUDpzT8RBlYa2TV9tXhLJQbON5A72.Q50yF1G.0-1778404897-1.2.1.1-gKS0ghb4VD_8wHSA3vdEYcoHdXg7guxD8LZRvZjG0Q7Hv.beUtfzzdM2BIyFpZeskFyom1yE3sK0ZhLi5MIF9BTO2eO.c8ccZpIvrVFlhVblO5uGpsusL7UkgCIPcFOQMlUnQTCZv0ujF6zevC2HyxO8sxDBl3kqmbXJeJDa_Fw.dPLTJgAbcGGyc1Kcd8DBbzJzKilWPlUSunJJC2G5a3FoqfJSIMS.UaJegkhxo_ibpGkgA2QmZZ5fEOyb7AnNazeSYdvx5a.iiA6DbtsXfQWAvr7UTVLYN7EVWMQpQ8yqfJLydcev4viT2Nf.q787miHij54J1XhMJFX1_fwpew +.chatgpt.com TRUE / TRUE 1809940900983 oai-sc 0gAAAAABqAE4kYiLfUjbRc2xSXOQ_80W52mgsGlShW65bpX8DpvSHLCkJbgPrKZKafXAiqbOJmkST9SBsstyp48su9yZjqNMW7jLcgf2C0ZN5jaopMCyqLqlk0Hci46y6QPBh_dnXHRBWcABYVNZEmk_fAlK2P91lUq7MSzomAINUUqKjsBoeGCHIYcKGkuKtAKPxRm1foTuxslexAiyp1usAs7QB-rjQm0pV0mob9-cunOEACPa30_M +.chatgpt.com TRUE / FALSE 1793956902763 oai_consent_analytics true +.chatgpt.com TRUE / FALSE 1793956902763 oai_consent_marketing true +.chatgpt.com TRUE / FALSE 1793956902764 oai-allow-ne true +.chatgpt.com TRUE / TRUE 1778864508851 __cf_bm XHODcczujoJbi3ArOgBxMkAVHm53K.wzhsAgC6H7J2w-1778862709.1585546-1.0.1.1-mJPiuKAlCE6Pp.3u5Qm0mTqoUIuMFBGLep8SELbqy1zw_JWBsh7p9QMB7qQ286bP4ZjBl9TMx8X7HXMqdM2xvMCJYokZOQV5JaohselVrlsUtEBZz3.fwLrGNGIdrNGW +chatgpt.com FALSE / FALSE 1779009569842 oai-chat-web-route "ChMxMC4xMjguMjI5LjEwNzozMDAwEPm5oQE=" +chatgpt.com FALSE / FALSE 1793956899000 g_state {"i_l":0,"i_ll":1778404899504,"i_b":"ELKRdJBKNN/mBa5HeveE9Qk2Ct6jgVz42UvSBHh06Cs","i_e":{"enable_itp_optimization":0},"i_et":1778404778504} +chatgpt.com FALSE / TRUE 1778866309246 __cflb 0H28vzvP5FJafnkHxj4bgBpHuC4ehSRtoje5uosSyo9 +www.tampermonkey.net FALSE / FALSE 1779467486514 geo LV_UNKNOWN +www.tampermonkey.net FALSE / FALSE 1779467487073 _dtm p_ +.youtube.com TRUE / FALSE 0 PREF hl=en&tz=UTC +.youtube.com TRUE / TRUE 0 SOCS CAI +.youtube.com TRUE / TRUE 1796431357 __Secure-YNID 19.YT=isEmmtHSC21zVpTt4IRSfL7unp3ffGoSLnfCSQuPaS1_zsJm2uaOpM6x6B07-GgRJWjcCobrfDTYfEaLtQtuhuC-AG_VbLpl5ufiehuf1jTLK_6e-QcxaEPq23s3xxyeZtchb1DACsSbJ8BL3y6EXkZ_xR3iojrLjbFuiKauOvsJKTeTMUTpnrD1WPnFlreSdYR2d9XapogEjF5wd1xGAuJDKPpCgRxcVLsE7ArfixoElOAdSpUYTLjLpUCCq3h7D-8TJlSbjhLHc3djv-8cyBUO0LZo7D-GhxBhPZHbVZ5x-nmDL-VdSa8DR45JYO3YK5gfwOJXhKDQNB4deg37oA +.youtube.com TRUE / TRUE 0 YSC o0TCOzuqu4M +.youtube.com TRUE / TRUE 1796487357 VISITOR_INFO1_LIVE yv5QtACFMuQ +.youtube.com TRUE / TRUE 1796487357 VISITOR_PRIVACY_METADATA CgJSVRIEGgAgaA%3D%3D +.youtube.com TRUE / TRUE 1796431357 __Secure-ROLLOUT_TOKEN CLTU25jzhYq4lwEQ7unAueDglAMYoY2LzLT2lAM%3D +.youtube.com TRUE / TRUE 1780935859 GPS 1