diff --git a/bot.py b/bot.py index 1a59590..855f530 100644 --- a/bot.py +++ b/bot.py @@ -15,14 +15,17 @@ from telegram.request import HTTPXRequest from dataclasses import dataclass from typing import Optional -# Таймаут для HTTP запросов к downloader-сервисам. -# Без ограничений один зависший микросервис навсегда блокирует единственный -# queue_worker и вся очередь перестает двигаться. -HTTP_TIMEOUT = httpx.Timeout(connect=10, read=300, write=30, pool=30) +# Таймаут для HTTP запросов +# Все таймауты убраны - видео может качаться и отправляться очень долго +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', @@ -111,11 +114,10 @@ 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': "Выберите качество видео:\n(через 10 сек — автоскачивание)", + 'select_quality': "Выберите качество видео:", 'quality_cancelled': "❌ Выбор отменён", 'fetching_formats': "🔍 Получаю доступные форматы...", }, @@ -169,11 +171,10 @@ 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:\n(10 sec auto-download)", + 'select_quality': "Select video quality:", 'quality_cancelled': "❌ Cancelled", 'fetching_formats': "🔍 Fetching available formats...", } @@ -389,7 +390,7 @@ def detect_video_source(url: str) -> str: return 'youtube' elif 'instagram.com' in domain: return 'instagram' - elif 'vk.com' in domain or 'vk.ru' in domain or 'vkontakte.ru' in domain: + elif 'vk.com' in domain or 'vkontakte.ru' in domain: return 'vk' elif 'yapfiles.ru' in domain: return 'yapfiles' @@ -399,23 +400,6 @@ 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<>"{}|\\^`\[\]]+' @@ -578,19 +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 - # Отправляем как видео со streaming — встроенный плеер Telegram - await item.original_message.reply_video( - video=video_file, + # Отправляем как документ, чтобы Telegram НЕ сжимал видео + # (reply_video сжимает, что приводит к потере качества и одинаковому размеру) + await item.original_message.reply_document( + document=video_file, filename=video_filename, caption=caption, - supports_streaming=True, - read_timeout=600, - write_timeout=600, + read_timeout=600, # 10 минут на ответ от Telegram + write_timeout=600, # 10 минут на отправку файла connect_timeout=60, pool_timeout=60 ) @@ -987,10 +970,29 @@ 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( @@ -1000,7 +1002,9 @@ async def get_formats_from_service(url: str) -> list[dict] | None: ) if response.status_code == 200: data = response.json() - return data.get('formats', []) + formats = data.get('formats', []) + _formats_cache[cache_key] = (time.time(), formats) + return formats logger.warning(f"Не удалось получить форматы: {response.status_code}") return None except Exception as e: @@ -1017,7 +1021,12 @@ async def show_quality_selection(status_message: Message, formats: list[dict], l """ keyboard = [] for idx, fmt in enumerate(formats): - button_text = fmt.get('label', fmt.get('quality', 'Unknown')) + 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 keyboard.append([InlineKeyboardButton( text=button_text, callback_data=f"quality:{idx}" @@ -1046,10 +1055,6 @@ 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 @@ -1099,66 +1104,6 @@ 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) @@ -1221,16 +1166,29 @@ 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 — сразу добавляем в очередь (выбор качества убран, т.к. android+mweb даёт только 360p) + # Для 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')) # Создаём элемент очереди item = QueueItem( diff --git a/vk-downloader/Dockerfile b/vk-downloader/Dockerfile index 84931f8..d6693ec 100644 --- a/vk-downloader/Dockerfile +++ b/vk-downloader/Dockerfile @@ -20,8 +20,7 @@ RUN mkdir -p downloads ENV PYTHONUNBUFFERED=1 -# 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" +# Gunicorn: 1 worker (последовательная обработка), без таймаута +# Порт берется из переменной окружения PORT (по умолчанию 5000) +CMD sh -c "gunicorn --workers=1 --timeout=0 --bind=0.0.0.0:${PORT:-5000} app:app" diff --git a/vk-downloader/app.py b/vk-downloader/app.py index 1e47538..5e08033 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 'vk.ru' not in url and 'vkontakte.ru' not in url: + if 'vk.com' 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 'vk.ru' not in url and 'vkontakte.ru' not in url: + if 'vk.com' 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 08d368f..c32e6f2 100644 --- a/vk-downloader/docker-compose.yml +++ b/vk-downloader/docker-compose.yml @@ -3,13 +3,8 @@ services: build: . container_name: vk_downloader_service restart: unless-stopped - ports: - - "127.0.0.1:5555:5555" + network_mode: host 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 52a2a8b..e42cd23 100644 --- a/youtube-downloader/Dockerfile +++ b/youtube-downloader/Dockerfile @@ -1,9 +1,28 @@ FROM python:3.11-slim -RUN apt-get update && apt-get install -y ffmpeg wget curl aria2 nodejs npm && rm -rf /var/lib/apt/lists/* + +# Устанавливаем зависимости для yt-dlp (включая Node.js для JS runtime) +RUN apt-get update && apt-get install -y \ + ffmpeg \ + wget \ + 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 -CMD sh -c "gunicorn --workers=1 --timeout=600 --preload --max-requests=1 --bind=0.0.0.0:\${PORT:-5000} app:app" + +# Gunicorn: 1 worker (последовательная обработка), без таймаута +# Порт берется из переменной окружения PORT (по умолчанию 5000) +CMD sh -c "gunicorn --workers=1 --timeout=0 --bind=0.0.0.0:${PORT:-5000} app:app" + diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index 2754212..f7333ec 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -1,18 +1,15 @@ """ 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 @@ -24,7 +21,7 @@ logging.basicConfig( logger = logging.getLogger(__name__) app = Flask(__name__) -CORS(app) +CORS(app) # Разрешаем CORS для взаимодействия с основным ботом # Директория для временных файлов DOWNLOADS_DIR = Path('downloads') @@ -46,10 +43,139 @@ 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: - """Возвращает самый свежий файл в папке загрузок (не .part/.ytdl).""" - files = [f for f in DOWNLOADS_DIR.glob('*') - if f.suffix not in ('.part', '.ytdl')] + """Возвращает самый свежий файл в папке загрузок.""" + files = list(DOWNLOADS_DIR.glob('*')) if not files: return None files.sort(key=lambda x: x.stat().st_mtime, reverse=True) @@ -58,6 +184,7 @@ 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', @@ -68,232 +195,462 @@ 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 _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 +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', + ] + # Добавляем 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]: - """Получает список доступных форматов через subprocess yt-dlp --dump-json.""" + """Получает список доступных форматов видео с YouTube""" logger.info(f"[FORMATS] Получение списка форматов для: {url}") - - 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}") - + + 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("Не удалось получить информацию о видео") + formats = info.get('formats', []) - duration = info.get('duration') - logger.info(f"[FORMATS] Всего форматов: {len(formats)}, длительность: {duration}с") - + logger.info(f"[FORMATS] Всего форматов: {len(formats)}") + + duration = info.get('duration') # длительность видео в секундах + logger.info(f"[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: - 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 + + 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']}") + 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 - + continue # не показываем 4K для видео с макс высотой 1080p + 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: + + if vcodec == 'none' or height <= 0: 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: + if height <= max_height and height > best_video_height: best_video = f best_video_height = height - is_best_dash = is_dash - - if not best_video or best_video_height in used_heights: + + if not best_video: + continue + + if 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}/best[height<={best_video_height}]/best" + format_selector = f"{video_format_id}/bestvideo[height<={best_video_height}]+bestaudio/best[height<={best_video_height}]" elif best_audio_info['format_id']: format_selector = ( f"{video_format_id}+{best_audio_info['format_id']}/" @@ -301,8 +658,8 @@ def get_youtube_formats(url: str) -> list[dict]: f"best[height<={best_video_height}]" ) else: - format_selector = f"{video_format_id}+bestaudio/best[height<={best_video_height}]/best" - + format_selector = f"{video_format_id}+bestaudio/bestvideo[height<={best_video_height}]+bestaudio/best[height<={best_video_height}]" + result.append({ 'format_id': format_selector, 'label': f"{display_label} ({video_ext})", @@ -310,7 +667,8 @@ 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', @@ -319,166 +677,153 @@ 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, }) - - # Fallsback: если форматов нет — оценочные + + # --------------------------------------------------------------- + # Если реальных форматов совсем нет — генерируем оценочные + # (бывает при очень плохих cookies, когда даже format_note пустой) + # --------------------------------------------------------------- if len(result) == 0: logger.info(f"[FORMATS] Реальных форматов не найдено, генерируем оценочные") - max_possible_height = max_actual_height + + # Пытаемся определить реальную максимальную высоту из всех полей + 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 = [] + if duration: - 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 + 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}]" + result.append({ - 'format_id': f"bestvideo[height<={max_height}]+bestaudio/best[height<={max_height}]", + 'format_id': format_selector, 'label': f"{label} (mp4)", 'quality': label, 'ext': 'mp4', - 'filesize_mb': round(bytes_est / 1024 / 1024, 1), + '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, }) - audio_bytes = 128 * 1000 / 8 * duration result.append({ 'format_id': 'bestaudio/best', 'label': 'Audio only (m4a)', 'quality': 'audio', 'ext': 'm4a', - 'filesize_mb': round(audio_bytes / 1024 / 1024, 1), + 'filesize_mb': None, }) - + logger.info(f"[FORMATS] Возвращаем {len(result)} форматов") return result -# ═══════════════════════════════════════════════════════════════ -# Скачивание (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} попыток") - - -# ═══════════════════════════════════════════════════════════════ -# Кэш форматов -# ═══════════════════════════════════════════════════════════════ - +# Простой кэш форматов: {normalized_url: (timestamp, list_of_formats)} +# Форматы YouTube не меняются часто, кэшируем на 30 минут _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 - del _formats_cache[cache_key] - + else: + logger.info(f"[FORMATS {request_id}] Кэш устарел ({now - cached_time:.0f}с > {_FORMATS_CACHE_TTL}с), обновляем...") + 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()) @@ -487,69 +832,96 @@ 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}] Скачивание: {url}, format_id={format_id}") - + format_id = data.get('format_id') # Опциональный параметр + logger.info(f"[REQUEST {request_id}] Получен запрос на скачивание (stream): {url}, format_id: {format_id}") + + # Проверяем, что это YouTube URL 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 - - 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 = download_youtube_video(url, format_id=format_id) + logger.info(f"[REQUEST {request_id}] Видео успешно скачано: {video_path}") + + # Читаем файл и отправляем 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 any(safe_filename.endswith(ext) for ext in ('.mp4', '.webm', '.mkv', '.m4a', '.mp3')): + if not safe_filename.endswith(('.mp4', '.webm', '.mkv')): safe_filename = 'youtube_video.mp4' - - 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') - + + # Определяем 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)} байт") + + # Удаляем временный файл 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) - 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_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!") 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 a2b37ef..cb68911 100644 --- a/youtube-downloader/youtube_cookies.txt +++ b/youtube-downloader/youtube_cookies.txt @@ -1,6 +1,42 @@ # 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 @@ -13,77 +49,4 @@ .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 -.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 +.facebook.com TRUE / TRUE 1784064559497 fr 0AcR44m5KTXADbTc8.AWehfS67NxYY9SrAf1c-itas_z26hWpd9OyZ9HwNKd41qG_Mxag.Bp4AMv..AAA.0.0.Bp4AMv.AWdIcrGHWEZWg8TB5d4kBwxLyHA