From c4d4a77229ff9066bfce0afcf51f5bbb233c1dae Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 4 Jun 2026 21:30:37 +0000 Subject: [PATCH] fix(downloader): chain fallback aria2c-curl-native + timeouts --- youtube-downloader/Dockerfile | 23 +--- youtube-downloader/app.py | 226 ++++++++++++++++++++-------------- 2 files changed, 135 insertions(+), 114 deletions(-) diff --git a/youtube-downloader/Dockerfile b/youtube-downloader/Dockerfile index e42cd23..1c5e61a 100644 --- a/youtube-downloader/Dockerfile +++ b/youtube-downloader/Dockerfile @@ -1,28 +1,9 @@ FROM python:3.11-slim - -# Устанавливаем зависимости для yt-dlp (включая Node.js для JS runtime) -RUN apt-get update && apt-get install -y \ - ffmpeg \ - wget \ - nodejs \ - npm \ - && rm -rf /var/lib/apt/lists/* - +RUN apt-get update && apt-get install -y ffmpeg wget curl aria2 nodejs npm && rm -rf /var/lib/apt/lists/* WORKDIR /app - -# Копируем requirements и устанавливаем зависимости COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt - -# Копируем код приложения COPY . . - -# Создаем директорию для загрузок RUN mkdir -p downloads - ENV PYTHONUNBUFFERED=1 - -# Gunicorn: 1 worker (последовательная обработка), без таймаута -# Порт берется из переменной окружения PORT (по умолчанию 5000) -CMD sh -c "gunicorn --workers=1 --timeout=0 --bind=0.0.0.0:${PORT:-5000} app:app" - +CMD sh -c "gunicorn --workers=1 --timeout=600 --preload --bind=0.0.0.0:\${PORT:-5000} app:app" diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index 82daab3..821d170 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -12,6 +12,12 @@ from flask_cors import CORS import yt_dlp import uuid import re +import copy + +# Цепочка загрузчиков: пробуем по очереди. +# aria2c/curl используют системный OpenSSL (обходит Python SSL handshake timeout), +# native — последний рубеж, работает без внешних зависимостей. +DOWNLOADER_CHAIN = ['aria2c', 'curl', 'native'] # Настройка логирования logging.basicConfig( @@ -123,32 +129,39 @@ def _extract_height_from_format_id(format_id: str) -> int | None: return None -def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) -> dict: +def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None, force_android: bool = False) -> dict: """Формирует базовые опции yt-dlp, общие для info и download Стратегия выбора player_client: - - Cookies есть → используем web клиенты (требуют n-challenge решения), - включаем js_runtimes + remote_components для решения n-challenge через Node.js. - - Cookies нет → используем android клиент (не поддерживает cookies, - но не требует n-challenge, хотя даёт меньше форматов). + - Всегда используем android + mweb клиенты — они работают без n-challenge + и не требуют валидных кук (YouTube всё чаще блокирует web клиенты). + - Если есть валидные куки — подключаем их как дополнение для расширенного + набора форматов, но основным остаётся android/mweb. + - force_android=True — только android (fallback при проблемах с mweb). """ - extractor_args = {} cookies_available = cookies_file_path is not None and cookies_file_path.exists() - if not cookies_available: - # Без cookies используем android — он не требует n-challenge - extractor_args = { - 'youtube': { - 'player_client': ['android'], - 'skip': ['translated_subs', 'hls'], - }, - } + # Стратегия player_client: + # - Если есть cookies + Node.js — используем web (основной) + android (fallback). + # Web клиент решает n-challenge через Node.js и не подвержен SABR-эксперименту. + # - Если нет cookies — android + mweb без зависимостей от JS runtime. + if cookies_available: + player_clients = ['web', 'android'] + else: + player_clients = ['android'] if force_android else ['android', 'mweb'] + + extractor_args = { + 'youtube': { + 'player_client': player_clients, + 'skip': ['translated_subs', 'hls'], + }, + } opts = { 'quiet': False, 'no_warnings': False, 'user_agent': user_agent, - 'socket_timeout': 60, + 'socket_timeout': 30, # уменьшен с 60 для раннего выявления зависаний 'extractor_retries': 3, 'http_headers': { 'User-Agent': user_agent, @@ -158,11 +171,9 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None) 'Connection': 'keep-alive', 'Referer': 'https://www.youtube.com/', }, + 'extractor_args': extractor_args, } - if extractor_args: - opts['extractor_args'] = extractor_args - if cookies_available: opts['cookiefile'] = str(cookies_file_path.absolute()) # Включаем n-challenge решение через Node.js + EJS скрипт с GitHub @@ -260,8 +271,9 @@ def _file_has_video_stream(filepath: Path) -> bool: return True # в случае ошибки считаем, что видео есть -def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> Path: - """Скачивает видео с YouTube - используем cookies для обхода блокировок""" +def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> tuple[Path, str]: + """Скачивает видео с YouTube - используем cookies для обхода блокировок. + Возвращает (путь_к_файлу, использованный_загрузчик).""" logger.info(f"[DOWNLOAD] Начало скачивания: {url}") cookies_file = os.getenv('YOUTUBE_COOKIES_FILE', 'youtube_cookies.txt') @@ -391,77 +403,105 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None format_options = default_format_options download_success = False + used_downloader = None # для трекинга, какой загрузчик сработал for format_option in format_options: - ydl_opts_download = _make_base_ydl_opts(user_agent, cookies_file_path if cookies_valid else None) - ydl_opts_download.update({ - 'format': format_option, - 'outtmpl': _safe_filename(video_title), - 'fragment_retries': 3, - 'postprocessors': [{'key': 'FFmpegFixupStretched'}], - }) - - use_cookies_this_attempt = cookies_valid - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries}: начинаем скачивание (Shorts: {is_shorts}, формат: {format_option}, cookies: {use_cookies_this_attempt})") - - try: - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: запуск yt-dlp для скачивания с форматом {format_option}") - with yt_dlp.YoutubeDL(ydl_opts_download) as ydl: - result_info = ydl.download([url]) - - # Логируем информацию о том, что реально скачалось - if result_info: - for entry in result_info: - if entry: - actual_format_id = entry.get('format_id', 'unknown') - actual_height = entry.get('height', 'unknown') - actual_ext = entry.get('ext', 'unknown') - actual_filesize = entry.get('filesize') or entry.get('filesize_approx') or 'unknown' - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}") - - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано с форматом {format_option}") - download_success = True - break - except Exception as download_error: - error_str = str(download_error) - error_lower = error_str.lower() - last_download_errors.append(f"[{format_option}] {error_str[:300]}") - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при скачивании формата {format_option}: {error_str}") - logger.error(f"[DOWNLOAD] Полный traceback:\n{traceback.format_exc()}") - - # Если ошибка с cookies, пробуем без них - if use_cookies_this_attempt and ('cookies' in error_lower or 'bot' in error_lower or 'sign in' in error_lower or 'authentication' in error_lower): - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: обнаружена ошибка связанная с cookies для формата {format_option}: {error_str[:200]}") - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: пробуем без cookies...") - # Пересоздаём opts без cookies - ydl_opts_download_no_cookies = _make_base_ydl_opts(user_agent, None) - ydl_opts_download_no_cookies.update({ - 'format': format_option, - 'outtmpl': _safe_filename(video_title), - 'fragment_retries': 3, - }) - try: - with yt_dlp.YoutubeDL(ydl_opts_download_no_cookies) as ydl: - ydl.download([url]) - logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies") - download_success = True - cookies_valid = False # Отключаем cookies для следующих попыток - break - except Exception as retry_error: - retry_str = str(retry_error) - last_download_errors.append(f"[{format_option} без cookies] {retry_str[:300]}") - logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка даже без cookies: {retry_error}") - logger.error(f"[DOWNLOAD] Полный traceback без cookies:\n{traceback.format_exc()}") - # Если и без cookies не получилось, пробуем следующий формат - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: не удалось скачать без cookies, пробуем следующий формат...") - continue - # Если ошибка формата, пробуем следующий формат - elif 'format is not available' in error_lower or 'requested format' in error_lower: - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: формат {format_option} недоступен, пробуем следующий...") - continue - else: - # Другая ошибка - пробуем следующий формат - logger.warning(f"[DOWNLOAD] Попытка {attempt + 1}: ошибка при скачивании формата {format_option}: {error_str[:200]}, пробуем следующий...") - continue + for downloader in DOWNLOADER_CHAIN: + ydl_opts_download = _make_base_ydl_opts(user_agent, cookies_file_path if cookies_valid else None) + ydl_opts_download.update({ + 'format': format_option, + 'outtmpl': _safe_filename(video_title), + 'fragment_retries': 3, + 'postprocessors': [{'key': 'FFmpegFixupStretched'}], + 'downloader': downloader, + # Таймауты для внешних загрузчиков (socket_timeout на них не действует) + 'downloader_args': { + 'curl': ['--connect-timeout', '15', '--max-time', '120'], + 'aria2c': ['--connect-timeout=15', '--timeout=120', '--max-tries=1'], + }, + }) + + use_cookies_this_attempt = cookies_valid + logger.info(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries}: формат={format_option}, загрузчик={downloader}, cookies={use_cookies_this_attempt}") + + try: + with yt_dlp.YoutubeDL(ydl_opts_download) as ydl: + result_info = ydl.download([url]) + + # Логируем, что реально скачалось + if result_info: + for entry in result_info: + if entry: + actual_format_id = entry.get('format_id', 'unknown') + actual_height = entry.get('height', 'unknown') + actual_ext = entry.get('ext', 'unknown') + actual_filesize = entry.get('filesize') or entry.get('filesize_approx') or 'unknown' + logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: реально скачан формат: id={actual_format_id}, height={actual_height}, ext={actual_ext}, size={actual_filesize}") + + logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: УСПЕХ — загрузчик={downloader}, формат={format_option}") + download_success = True + used_downloader = downloader + break # выходим из цикла загрузчиков + except Exception as download_error: + error_str = str(download_error) + error_lower = error_str.lower() + last_download_errors.append(f"[{downloader}|{format_option}] {error_str[:250]}") + logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: загрузчик={downloader} ошибка: {error_str[:200]}") + + # Классификация ошибки: сеть/SSL → пробуем следующий загрузчик + is_network_error = any(kw in error_lower for kw in [ + 'ssl', 'handshake', 'timeout', 'connection', + 'eof', 'reset', 'broken pipe', 'no route', + ]) + + # Ошибка формата → следующий формат (не загрузчик) + is_format_error = 'format is not available' in error_lower or 'requested format' in error_lower + + # Ошибка cookies → пробуем без cookies + is_cookies_error = use_cookies_this_attempt and any(kw in error_lower for kw in [ + 'cookies', 'bot', 'sign in', 'authentication', + ]) + + if is_format_error: + logger.warning(f"[DOWNLOAD] Формат {format_option} недоступен → следующий формат") + break # выходим из цикла загрузчиков, идём к следующему формату + + if is_cookies_error: + logger.warning(f"[DOWNLOAD] Ошибка cookies с загрузчиком {downloader} → пробуем без cookies") + ydl_opts_no_cookies = _make_base_ydl_opts(user_agent, None) + ydl_opts_no_cookies.update({ + 'format': format_option, + 'outtmpl': _safe_filename(video_title), + 'fragment_retries': 3, + 'downloader': downloader, + 'downloader_args': { + 'curl': ['--connect-timeout', '15', '--max-time', '120'], + 'aria2c': ['--connect-timeout=15', '--timeout=120', '--max-tries=1'], + }, + }) + try: + with yt_dlp.YoutubeDL(ydl_opts_no_cookies) as ydl: + ydl.download([url]) + logger.info(f"[DOWNLOAD] УСПЕХ без cookies — загрузчик={downloader}, формат={format_option}") + download_success = True + used_downloader = downloader + cookies_valid = False + break # выходим из цикла загрузчиков + except Exception as retry_error: + retry_str = str(retry_error) + last_download_errors.append(f"[{downloader}|без cookies] {retry_str[:250]}") + logger.error(f"[DOWNLOAD] Ошибка без cookies: {retry_str[:200]}") + continue # пробуем следующий загрузчик + + if is_network_error and downloader != DOWNLOADER_CHAIN[-1]: + logger.warning(f"[DOWNLOAD] Сеть/SSL ошибка с {downloader} → следующий загрузчик") + continue # следующий загрузчик в цепочке + + # Последний загрузчик или неизвестная ошибка → следующий формат + logger.warning(f"[DOWNLOAD] Загрузчик {downloader} не сработал, формат {format_option} → следующий формат") + break # выходим из цикла загрузчиков + + if download_success: + break # выходим из цикла форматов if not download_success: # Собираем детальный отчёт об ошибках @@ -472,7 +512,7 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None downloaded_files = list(DOWNLOADS_DIR.glob('*')) if downloaded_files: downloaded_files.sort(key=lambda x: x.stat().st_mtime, reverse=True) - return downloaded_files[0] + return downloaded_files[0], used_downloader or 'unknown' else: raise Exception("Файл не был найден после скачивания") @@ -910,8 +950,8 @@ def download_stream(): # Скачиваем видео logger.info(f"[REQUEST {request_id}] Начинаем скачивание видео...") - video_path = download_youtube_video(url, format_id=format_id) - logger.info(f"[REQUEST {request_id}] Видео успешно скачано: {video_path}") + video_path, used_downloader = download_youtube_video(url, format_id=format_id) + logger.info(f"[REQUEST {request_id}] Видео успешно скачано: {video_path} (загрузчик: {used_downloader})") # Читаем файл и отправляем file_size = video_path.stat().st_size