fix: correct quality selection -- specific format_id first, exclude av01, validate video stream

This commit is contained in:
vrubelroman 2026-05-03 01:36:04 +03:00
parent 326eabaa99
commit 053f6c8afc
3 changed files with 139 additions and 33 deletions

View file

@ -86,7 +86,7 @@ def _is_valid_cookies_file(cookies_path: Path) -> bool:
def _parse_height(format_dict: dict) -> int:
"""Извлекает реальную высоту из формата: height/width/format_note"""
"""Извлекает реальную высоту из формата: height/width/format_note/resolution"""
h = format_dict.get('height')
w = format_dict.get('width')
# Для вертикальных видео (Shorts) height и width могут быть перепутаны —
@ -97,11 +97,21 @@ def _parse_height(format_dict: dict) -> int:
return real_h
if h and isinstance(h, (int, float)) and h > 0:
return int(h)
# Если вообще нет размеров — парсим format_note (например "360p")
note = format_dict.get('format_note', '') or ''
match = re.search(r'(\d+)p', str(note))
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
@ -163,6 +173,31 @@ def _make_base_ydl_opts(user_agent: str, cookies_file_path: Path | None = None)
return opts
def _find_latest_downloaded() -> Path | None:
"""Возвращает самый свежий файл в папке загрузок."""
files = list(DOWNLOADS_DIR.glob('*'))
if not files:
return None
files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
return files[0]
def _file_has_video_stream(filepath: Path) -> bool:
"""Проверяет через ffprobe, содержит ли файл видео-поток."""
import subprocess
try:
result = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=codec_type', '-of', 'csv=p=0',
str(filepath)],
capture_output=True, text=True, timeout=15
)
return result.stdout.strip() == 'video'
except Exception as e:
logger.warning(f"[VALIDATE] Не удалось проверить видео-поток в {filepath.name}: {e}")
return True # в случае ошибки считаем, что видео есть
def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None = None) -> Path:
"""Скачивает видео с YouTube - используем cookies для обхода блокировок"""
logger.info(f"[DOWNLOAD] Начало скачивания: {url}")
@ -265,18 +300,20 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
is_specific_code = not ('[' in format_id or ']' in format_id)
requested_height = _extract_height_from_format_id(format_id)
format_options = [format_id]
if requested_height is not None:
if is_specific_code:
logger.info(f"[DOWNLOAD] Конкретный format code: {format_id}")
else:
logger.info(f"[DOWNLOAD] Format selector: {format_id}")
logger.info(f"[DOWNLOAD] Добавляем качество-сохраняющий fallback для height<={requested_height}")
format_options.append(f"bestvideo[height<={requested_height}]+bestaudio/best[height<={requested_height}]")
# Конкретный 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}")
@ -316,6 +353,19 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
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:
@ -341,6 +391,16 @@ def download_youtube_video(url: str, max_retries: int = 3, format_id: str | None
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
@ -624,9 +684,26 @@ def get_youtube_formats(url: str) -> list[dict]:
# ---------------------------------------------------------------
if len(result) == 0:
logger.info(f"[FORMATS] Реальных форматов не найдено, генерируем оценочные")
# Пытаемся определить реальную максимальную высоту из всех полей
max_possible_height = 0
for f in formats:
height = _parse_height(f)
if height > max_possible_height:
max_possible_height = height
if max_possible_height == 0:
# Если ничего не смогли определить — используем format_note напрямую
for f in formats:
note = str(f.get('format_note', '') or '')
numbers = re.findall(r'(\d+)', note)
for num in numbers:
n = int(num)
if 100 < n < 10000 and n > max_possible_height:
max_possible_height = n
if max_possible_height == 0:
max_possible_height = 2160
max_available_height = max(available_heights) if available_heights else 2160
available_tiers = [(h, l) for h, l in quality_tiers if h <= max_available_height]
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,