diff --git a/bot.py b/bot.py index 855f530..1a59590 100644 --- a/bot.py +++ b/bot.py @@ -15,17 +15,14 @@ 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) -# Клиентский кэш форматов: {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', @@ -114,10 +111,11 @@ 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': "⬇️ Скачиваю видео...", - 'select_quality': "Выберите качество видео:", + 'select_quality': "Выберите качество видео:\n(через 10 сек — автоскачивание)", 'quality_cancelled': "❌ Выбор отменён", 'fetching_formats': "🔍 Получаю доступные форматы...", }, @@ -171,10 +169,11 @@ 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...", - 'select_quality': "Select video quality:", + 'select_quality': "Select video quality:\n(10 sec auto-download)", 'quality_cancelled': "❌ Cancelled", 'fetching_formats': "🔍 Fetching available formats...", } @@ -390,7 +389,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' @@ -400,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<>"{}|\\^`\[\]]+' @@ -562,18 +578,19 @@ 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 сжимает, что приводит к потере качества и одинаковому размеру) - 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, - read_timeout=600, # 10 минут на ответ от Telegram - write_timeout=600, # 10 минут на отправку файла + supports_streaming=True, + read_timeout=600, + write_timeout=600, connect_timeout=60, pool_timeout=60 ) @@ -970,29 +987,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( @@ -1002,9 +1000,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: @@ -1021,12 +1017,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 +1046,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 +1099,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) @@ -1166,29 +1221,16 @@ 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')) - # Для 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) - return - # Если не удалось получить форматы, скачиваем как обычно (без выбора качества) - await status_message.edit_text(get_text(locale, 'processing')) + # Для YouTube — сразу добавляем в очередь (выбор качества убран, т.к. android+mweb даёт только 360p) # Создаём элемент очереди item = QueueItem( 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/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/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 diff --git a/youtube-downloader/Dockerfile b/youtube-downloader/Dockerfile index e42cd23..52a2a8b 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 --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 f7333ec..2754212 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -1,15 +1,18 @@ """ 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 @@ -21,7 +24,7 @@ logging.basicConfig( logger = logging.getLogger(__name__) app = Flask(__name__) -CORS(app) # Разрешаем CORS для взаимодействия с основным ботом +CORS(app) # Директория для временных файлов DOWNLOADS_DIR = Path('downloads') @@ -43,139 +46,10 @@ 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) -> dict: - """Формирует базовые опции yt-dlp, общие для info и download - - Стратегия выбора player_client: - - Cookies есть → используем web клиенты (требуют n-challenge решения), - включаем js_runtimes + remote_components для решения n-challenge через Node.js. - - Cookies нет → используем android клиент (не поддерживает cookies, - но не требует n-challenge, хотя даёт меньше форматов). - """ - 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'], - }, - } - - opts = { - 'quiet': False, - 'no_warnings': False, - 'user_agent': user_agent, - 'socket_timeout': 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/', - }, - } - - if extractor_args: - opts['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_latest_downloaded() -> Path | None: - """Возвращает самый свежий файл в папке загрузок.""" - files = list(DOWNLOADS_DIR.glob('*')) + """Возвращает самый свежий файл в папке загрузок (не .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) @@ -184,7 +58,6 @@ def _find_latest_downloaded() -> Path | None: def _file_has_video_stream(filepath: Path) -> bool: """Проверяет через ffprobe, содержит ли файл видео-поток.""" - import subprocess try: result = subprocess.run( ['ffprobe', '-v', 'error', '-select_streams', 'v:0', @@ -195,462 +68,232 @@ def _file_has_video_stream(filepath: Path) -> bool: return result.stdout.strip() == 'video' except Exception as e: logger.warning(f"[VALIDATE] Не удалось проверить видео-поток в {filepath.name}: {e}") - return True # в случае ошибки считаем, что видео есть + return True -def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> Path: - """Скачивает видео с 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', - ] +def _file_has_audio_stream(filepath: Path) -> bool: + """Проверяет через ffprobe, содержит ли файл аудио-поток.""" + 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 - # Добавляем fallback на combined форматы (например 18), которые всегда доступны - combined_fallback = ['best[ext=mp4]/best', 'best'] - - requested_height = None # высота, запрошенная пользователем - - if format_id: - is_specific_code = not ('[' in format_id or ']' in format_id) - requested_height = _extract_height_from_format_id(format_id) - - if 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 - 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 — для DASH форматов (видео без аудио), - # YouTube может разрывать фрагменты; увеличиваем retries - 'fragment_retries': 3, - # allow_unplayable_formats — позволяет скачивать форматы, - # которые YouTube помечает как "недоступные" для сторонних клиентов - 'allow_unplayable_formats': True, - }) - - 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}") - - # Проверяем, что файл содержит видео-поток, а не только аудио - # (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 - - 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, - 'allow_unplayable_formats': True, - }) - try: - with yt_dlp.YoutubeDL(ydl_opts_download_no_cookies) as ydl: - 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 - - 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 - - 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] - 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) +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) + + video_file = None + audio_file = None + + 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( + ['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: + 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 + + +# ═══════════════════════════════════════════════════════════════ +# CORE: subprocess-based yt-dlp +# ═══════════════════════════════════════════════════════════════ + +YTDLP_CMD = 'yt-dlp' +DOWNLOAD_TIMEOUT = 300 +INFO_TIMEOUT = 60 + +PLAYER_CLIENTS = 'web,android' +EXTRACTOR_ARGS = 'youtube:player_client=web,android:skip=translated_subs,hls' + + +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 + + +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) + + +# ═══════════════════════════════════════════════════════════════ +# YouTube formatter parser (shared with old codebase) +# ═══════════════════════════════════════════════════════════════ + +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 + + +# ═══════════════════════════════════════════════════════════════ +# Форматы (--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': - fs = _get_filesize(f) - if fs > best_audio_info['size']: - best_audio_info = {'size': fs, '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']}") - + if vcodec == 'none' and acodec != 'none' and best_audio_info['format_id'] is None: + 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: + 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 - - if not best_video: - continue - - if best_video_height in used_heights: + is_best_dash = is_dash + + 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}") - - # format_selector без /best в конце — чтобы yt-dlp не молча скатывался на другой размер + 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}/best[height<={best_video_height}]/best" elif best_audio_info['format_id']: format_selector = ( f"{video_format_id}+{best_audio_info['format_id']}/" @@ -658,8 +301,8 @@ def get_youtube_formats(url: str) -> list[dict]: 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/best[height<={best_video_height}]/best" + result.append({ 'format_id': format_selector, 'label': f"{display_label} ({video_ext})", @@ -667,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', @@ -677,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()) @@ -832,96 +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 = 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 - 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')): + if not any(safe_filename.endswith(ext) for ext in ('.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' - - logger.info(f"[REQUEST {request_id}] Отправляем файл: {safe_filename}, Content-Type: {content_type}, размер: {len(video_data)} байт") - - # Удаляем временный файл + + 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') + 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 cb68911..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 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 / 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 / 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 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