fix(downloader): chain fallback aria2c-curl-native + timeouts
This commit is contained in:
parent
a9d1ffc864
commit
c4d4a77229
2 changed files with 135 additions and 114 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue