fix(downloader): chain fallback aria2c-curl-native + timeouts

This commit is contained in:
vrubelroman 2026-06-04 21:30:37 +00:00
parent a9d1ffc864
commit c4d4a77229
2 changed files with 135 additions and 114 deletions

View file

@ -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"

View file

@ -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,23 +129,30 @@ 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
# Стратегия 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': ['android'],
'player_client': player_clients,
'skip': ['translated_subs', 'hls'],
},
}
@ -148,7 +161,7 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None)
'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,24 +403,31 @@ 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:
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}: начинаем скачивание (Shorts: {is_shorts}, формат: {format_option}, cookies: {use_cookies_this_attempt})")
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}/{max_retries}: формат={format_option}, загрузчик={downloader}, 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:
@ -418,50 +437,71 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
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}")
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: УСПЕХ — загрузчик={downloader}, формат={format_option}")
download_success = True
break
used_downloader = downloader
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()}")
last_download_errors.append(f"[{downloader}|{format_option}] {error_str[:250]}")
logger.error(f"[DOWNLOAD] Попытка {attempt + 1}: загрузчик={downloader} ошибка: {error_str[:200]}")
# Если ошибка с 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({
# Классификация ошибки: сеть/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_download_no_cookies) as ydl:
with yt_dlp.YoutubeDL(ydl_opts_no_cookies) as ydl:
ydl.download([url])
logger.info(f"[DOWNLOAD] Попытка {attempt + 1}: успешно скачано без cookies")
logger.info(f"[DOWNLOAD] УСПЕХ без cookies — загрузчик={downloader}, формат={format_option}")
download_success = True
cookies_valid = False # Отключаем cookies для следующих попыток
break
used_downloader = downloader
cookies_valid = False
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
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