yt-dlp-gui/tests/test_probe.py
loop-uh 7564df4f95 YouTube: сторож прямого пути — при блокировке по SNI сразу через egress, а не «Проверяем…» навсегда
13.10.2026 провайдер начал молча глотать TLS с именем youtube.com. Ошибки
при этом нет: yt-dlp ждёт по 30 с на запрос и повторяет, /api/info не
отвечал дольше 90 с, запасной путь через egress включался только по тексту
бана. YouTube лежал около 16 часов, /readyz всё это время был «ок».

- downloader.yt_direct_ok: короткий запрос HEAD /generate_204 к
  www.youtube.com с YTG_YT_SOURCE_IP (таймаут 4 с, отказ — две неудачи
  подряд), результат кэшируется, обновляет его уборщик. Пока путь молчит,
  разбор ролика и списка идут через egress сразу; путь загрузки выбирает
  сервер (yt_egress_fallback), а не признак от клиента.
- В журнале строка на смену состояния ([youtube] напрямую не отвечает /
  снова отвечает); /readyz остаётся «ок», но несёт warnings и состояние в
  метриках. Выключатель: YTG_YT_REACH_CHECK=0.
- Пустой ответ через egress в режиме обхода повторяется один раз: туннель
  бывает медленным.
- Туннель пула для разбора через egress выбирается живой и случайный
  (egress_pool_proxy), а не первый: первый оказался «живым трупом» (ssh
  держит связь, данные не идут), и загрузка висела на подготовке при 31
  рабочем туннеле.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:11:36 +03:00

438 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Разбор метаданных: какие форматы попадают в выдачу, а какие отсеиваются.
Здесь закреплена дорогая ошибка: у yt-dlp строка "none" означает, что
дорожки точно нет, а None — что кодек просто неизвестен. Код писал
`f.get("vcodec") or "none"` и превращал неизвестный в отсутствующий, из-за
чего у всех сайтов, не сообщающих кодеки (то есть почти у всех, кроме
YouTube), выбрасывались ВСЕ форматы.
"""
import pytest
import downloader as dl
class FakeYDL:
"""Подменяет YoutubeDL: отдаёт заранее заданные метаданные без сети."""
def __init__(self, info):
self._info = info
def __call__(self, opts):
return self
def __enter__(self):
return self
def __exit__(self, *a):
return False
def extract_info(self, url, download=False):
return self._info
def sanitize_info(self, info):
return info
@pytest.fixture
def probe_with(monkeypatch):
def run(info):
monkeypatch.setattr(dl.yt_dlp, "YoutubeDL", FakeYDL(info))
return dl.probe("https://example.com/v")
return run
def test_formats_with_unknown_codecs_are_kept(probe_with, fake_info):
out = probe_with(fake_info)
ids = [f["format_id"] for f in out["formats"]]
assert "hls-480" in ids, "формат с vcodec=None выброшен — это регрессия"
assert "mp4-low" in ids
def test_format_with_explicit_none_both_is_dropped(probe_with, fake_info):
"""Раскадровка не содержит ни видео, ни звука — её показывать незачем."""
out = probe_with(fake_info)
assert "sb0" not in [f["format_id"] for f in out["formats"]]
def test_kind_classification(probe_with, fake_info):
out = probe_with(fake_info)
kinds = {f["format_id"]: f["kind"] for f in out["formats"]}
assert kinds["137"] == "video" # есть видео, звука нет
assert kinds["140"] == "audio" # есть звук, видео нет
assert kinds["hls-480"] == "both" # неизвестно -> считаем полноценным
def test_unknown_codec_reported_as_none_not_crash(probe_with, fake_info):
"""Раньше здесь падал v.split('.') у None, и запрос отвечал общей
ошибкой вместо выдачи форматов."""
out = probe_with(fake_info)
f = next(f for f in out["formats"] if f["format_id"] == "hls-480")
assert f["vcodec"] is None and f["acodec"] is None
def test_codec_is_shortened(probe_with, fake_info):
out = probe_with(fake_info)
f = next(f for f in out["formats"] if f["format_id"] == "137")
assert f["vcodec"] == "avc1" # из 'avc1.640028'
def test_heights_include_unknown_codec_formats(probe_with, fake_info):
"""Список качеств строился по тому же принципу и терял те же форматы."""
out = probe_with(fake_info)
assert "480" in out["heights"]
assert "1080" in out["heights"]
def test_duration_limit(probe_with, fake_info, monkeypatch):
import config
monkeypatch.setattr(config, "MAX_DURATION_SEC", 60)
with pytest.raises(ValueError) as e:
probe_with(fake_info) # ролик на 120 секунд
assert str(e.value) == "too_long"
def test_empty_formats_does_not_crash(probe_with):
out = probe_with({"id": "x", "title": "Пусто", "formats": []})
assert out["formats"] == []
assert out["heights"] == []
def test_missing_title_gets_placeholder(probe_with):
out = probe_with({"id": "x", "formats": []})
assert out["title"] == "Без названия"
def test_video_without_formats_is_not_a_gallery(probe_with):
"""Ролик 18+ без форматов (YouTube требует вход) выдавался за «пост с
фото» — предлагалось скачать его обложку."""
info = {"id": "x", "title": "Ролик 18+", "duration": 518, "age_limit": 18,
"thumbnail": "https://i.ytimg.com/vi/x/hq.jpg",
"thumbnails": [{"url": "https://i.ytimg.com/vi/x/hq.jpg"}], "formats": []}
with pytest.raises(ValueError, match="age_restricted"):
probe_with(info)
with pytest.raises(ValueError, match="no_formats"):
probe_with({**info, "age_limit": 0})
def test_image_post_is_still_a_gallery(probe_with):
info = {"id": "p", "title": "Пост", "formats": [],
"thumbnails": [{"url": "https://pbs.twimg.com/media/a.jpg"}],
"thumbnail": "https://pbs.twimg.com/media/a.jpg"}
assert probe_with(info)["is_gallery"] is True
def test_silent_bot_check_retries_via_egress(monkeypatch):
"""Антибот YouTube при ignore_no_formats_error приходит «молча» — роликом
без форматов. Такой ролик повторяем через egress."""
import config
monkeypatch.setattr(config, "EGRESS_PROXY", "socks5h://127.0.0.1:1")
ok = {"id": "x", "title": "Ролик", "duration": 60,
"formats": [{"format_id": "18", "ext": "mp4", "vcodec": "avc1",
"acodec": "mp4a", "height": 360}]}
calls = []
def fake(url, proxy):
calls.append(proxy)
return ok if proxy else {**ok, "formats": []}
monkeypatch.setattr(dl, "_probe_extract", fake)
out = dl.probe("https://www.youtube.com/watch?v=x")
assert out["via_egress"] is True and out["formats"]
assert calls == [None, "socks5h://127.0.0.1:1"]
def test_info_cache_reuse_rules(monkeypatch):
"""Готовый разбор отдаётся загрузке только свежим, тем же путём и под любым
из адресов ролика; тяжёлые автосубтитры не хранятся."""
monkeypatch.setattr(dl, "_info_cache", dl.OrderedDict())
info = {"id": "v", "formats": [{"format_id": "18"}], "automatic_captions": {"en": ["x"] * 100}}
dl.cache_info(("https://youtu.be/v?si=abc", "https://www.youtube.com/watch?v=v"), True, info)
got = dl.cached_info("https://www.youtube.com/watch?v=v", True)
assert got and "automatic_captions" not in got
assert dl.cached_info("https://youtu.be/v?si=abc", True)
assert dl.cached_info("https://www.youtube.com/watch?v=v", False) is None, \
"ссылки из egress нельзя качать напрямую"
monkeypatch.setattr(dl, "INFO_CACHE_SEC", -1)
assert dl.cached_info("https://www.youtube.com/watch?v=v", True) is None
def test_info_cache_drops_format_selection(monkeypatch):
"""Разбор кэшируется без выбранного по умолчанию формата: иначе загрузка
аудио наследовала requested_formats=[видео, звук] и падала на склейке
с нескачанным видео (FileNotFoundError .f401.mkv)."""
import yt_dlp
monkeypatch.setattr(dl, "_info_cache", dl.OrderedDict())
video = {"format_id": "401", "url": "https://x/v", "ext": "mp4", "protocol": "https",
"vcodec": "av01", "acodec": "none", "height": 2160, "width": 3840}
audio = {"format_id": "251", "url": "https://x/a", "ext": "webm", "protocol": "https",
"vcodec": "none", "acodec": "opus", "abr": 130}
info = {"id": "v", "title": "T", "extractor": "youtube", "extractor_key": "Youtube",
"webpage_url": "https://www.youtube.com/watch?v=v",
"formats": [audio, video], "requested_formats": [video, audio],
"format_id": "401+251", "format": "401+251", "ext": "webm",
"height": 2160, "vcodec": "av01", "acodec": "opus"}
dl.cache_info(("https://www.youtube.com/watch?v=v",), False, info)
got = dl.cached_info("https://www.youtube.com/watch?v=v", False)
assert got["title"] == "T" and len(got["formats"]) == 2
for k in ("requested_formats", "format_id", "format", "height", "vcodec", "ext"):
assert k not in got, k
with yt_dlp.YoutubeDL({"format": "bestaudio", "simulate": True, "quiet": True}) as ydl:
res = ydl.process_ie_result(dict(got), download=True)
assert res["format_id"] == "251" and not res.get("requested_formats")
def test_info_cache_is_bounded(monkeypatch):
monkeypatch.setattr(dl, "_info_cache", dl.OrderedDict())
monkeypatch.setattr(dl, "INFO_CACHE_MAX", 3)
for i in range(5):
dl.cache_info((f"https://example.com/v{i}",), False, {"formats": [1]})
assert len(dl._info_cache) == 3
@pytest.mark.parametrize("url,expected", [
("https://www.youtube.com/playlist?list=PLabc", True),
("https://www.youtube.com/@channel/videos", True),
("https://www.youtube.com/watch?v=x&list=PLabc", False), # конкретный ролик
("https://youtu.be/x?list=PLabc", False),
("https://www.youtube.com/shorts/x", False),
("https://vimeo.com/showcase/1", False),
])
def test_is_youtube_list(url, expected):
assert dl.is_youtube_list(url) is expected
def test_playlist_probe_lists_entries_fast(monkeypatch):
"""Плейлист — только список роликов (без разбора каждого), со ссылками."""
flat = {"_type": "playlist", "title": "Курс", "uploader": "Автор", "playlist_count": 3,
"entries": [{"id": "a1", "url": "https://www.youtube.com/watch?v=a1",
"title": "Первый", "duration": 60},
{"id": "b2", "title": "Второй"}, # без url — соберём по id
{"id": None, "url": "javascript:alert(1)"}]} # мусор — отбросить
seen = {}
class Y(FakeYDL):
def __init__(self, opts):
seen.update(opts)
super().__init__(flat)
def __call__(self, opts):
return Y(opts)
monkeypatch.setattr(dl.yt_dlp, "YoutubeDL", lambda opts: Y(opts))
out = dl.probe("https://www.youtube.com/playlist?list=PLx")
assert out["is_playlist"] and out["count"] == 3
assert [e["url"] for e in out["entries"]] == ["https://www.youtube.com/watch?v=a1",
"https://www.youtube.com/watch?v=b2"]
assert seen.get("extract_flat") == "in_playlist"
@pytest.mark.parametrize("formats,expected", [
([{"format_id": "0", "vcodec": None, "acodec": "none", "ext": "mp4"},
{"format_id": "d", "vcodec": "avc1", "acodec": "none", "ext": "mp4"}], False), # немой рил
([{"format_id": "0", "vcodec": None, "acodec": None, "ext": "mp4"}], True), # кодек неизвестен
([{"format_id": "v", "vcodec": "avc1", "acodec": "none", "ext": "mp4"},
{"format_id": "a", "vcodec": "none", "acodec": "mp4a", "ext": "m4a"}], True),
])
def test_probe_reports_silent_video(monkeypatch, formats, expected):
"""Немой ролик (Instagram/Pinterest): вкладку «Аудио» не предлагаем —
извлечение звука падало на ffprobe с непонятной ошибкой."""
monkeypatch.setattr(dl, "_info_cache", dl.OrderedDict())
monkeypatch.setattr(dl, "_probe_extract", lambda url, proxy: {
"id": "r", "title": "t", "duration": 10, "formats": formats,
"webpage_url": "https://www.instagram.com/reel/r/"})
assert dl.probe("https://www.instagram.com/reel/r/")["has_audio"] is expected
def test_silent_audio_error_is_explained():
raw = ("ERROR: Postprocessing: WARNING: unable to obtain file audio codec with ffprobe")
assert "нет звука" in dl._clean_err(raw)
def test_probe_multi_video_post(monkeypatch):
"""Твит/карусель с несколькими видео: форматы у записей, а не у поста.
Высоты и звук берём из записей, число видео запоминаем для сервера."""
monkeypatch.setattr(dl, "_info_cache", dl.OrderedDict())
monkeypatch.setattr(dl, "_multi_posts", dl.OrderedDict())
entry = lambda i: {"id": str(i), "formats": [
{"format_id": "v", "vcodec": "avc1", "acodec": "mp4a", "height": 720, "ext": "mp4"}]}
monkeypatch.setattr(dl, "_probe_extract", lambda url, proxy: {
"_type": "playlist", "id": "p", "title": "t", "entries": [entry(1), entry(2)],
"webpage_url": "https://x.com/u/status/1"})
out = dl.probe("https://x.com/u/status/1")
assert out["multi_count"] == 2 and out["heights"] == ["720"] and out["has_audio"]
assert not out["is_gallery"]
assert dl.multi_post("https://x.com/u/status/1") == {"videos": 2, "images": 0, "entries": 2}
def test_probe_mixed_carousel_is_not_a_gallery(monkeypatch):
"""Карусель из фото и видео: раньше — галерея (видео терялись), теперь —
пост: видео и фото, номера фото — их места в карусели."""
monkeypatch.setattr(dl, "_info_cache", dl.OrderedDict())
monkeypatch.setattr(dl, "_multi_posts", dl.OrderedDict())
photo = lambda i: {"id": f"p{i}", "thumbnails": [{"url": f"https://cdn/p{i}.jpg"}]}
video = {"id": "v", "thumbnail": "https://cdn/v.jpg", "formats": [
{"format_id": "v", "vcodec": "avc1", "acodec": "mp4a", "height": 1080, "ext": "mp4"}]}
info = {"_type": "playlist", "id": "c", "title": "t",
"entries": [photo(1), video, photo(3)],
"webpage_url": "https://www.instagram.com/p/c/"}
monkeypatch.setattr(dl, "_probe_extract", lambda url, proxy: info)
out = dl.probe("https://www.instagram.com/p/c/")
assert not out["is_gallery"] and out["multi_count"] == 1 and out["multi_images"] == 2
assert dl.multi_post("https://www.instagram.com/p/c/") == {"videos": 1, "images": 2, "entries": 3}
assert dl.collect_images_indexed(info) == [(1, "https://cdn/p1.jpg"), (3, "https://cdn/p3.jpg")]
# --- YouTube напрямую молчит (блокировка по SNI) -> сразу через egress --------
# 13.10.2026 провайдер начал глотать TLS с именем youtube.com: ошибки нет,
# yt-dlp ждёт по 30 с на запрос, «Проверяем…» висело минутами 16 часов подряд.
_YT_OK = {"id": "x", "title": "Ролик", "duration": 60,
"formats": [{"format_id": "18", "ext": "mp4", "vcodec": "avc1",
"acodec": "mp4a", "height": 360}]}
@pytest.fixture
def egress(monkeypatch):
import config
monkeypatch.setattr(config, "EGRESS_PROXY", "socks5h://127.0.0.1:1")
monkeypatch.setattr(config, "PROXY", "")
monkeypatch.setattr(dl, "_info_cache", dl.OrderedDict())
calls = []
monkeypatch.setattr(dl, "_probe_extract",
lambda url, proxy: (calls.append(proxy), dict(_YT_OK))[1])
return calls
def test_youtube_silent_directly_goes_via_egress_at_once(monkeypatch, egress):
"""Прямой путь молчит — прямой разбор не запускается вовсе: он не упал
бы с ошибкой, а провисел бы минуты."""
monkeypatch.setattr(dl, "_yt_direct_check", lambda: False)
out = dl.probe("https://www.youtube.com/watch?v=x")
assert out["via_egress"] is True
assert egress == ["socks5h://127.0.0.1:1"]
assert dl.cached_info("https://www.youtube.com/watch?v=x", True), \
"разбор через egress — загрузке, тем же путём"
def test_empty_answer_via_egress_is_retried_once(monkeypatch, egress):
"""Туннель бывает медленным, и yt-dlp молча возвращает ролик без форматов.
Прямого пути нет — пробуем egress ещё раз, а не отдаём ошибку."""
monkeypatch.setattr(dl, "_yt_direct_check", lambda: False)
answers = iter([{**_YT_OK, "formats": []}, dict(_YT_OK)])
monkeypatch.setattr(dl, "_probe_extract",
lambda url, proxy: (egress.append(proxy), next(answers))[1])
out = dl.probe("https://www.youtube.com/watch?v=x")
assert out["via_egress"] is True and out["formats"]
assert egress == ["socks5h://127.0.0.1:1"] * 2
def test_reach_check_concerns_only_youtube(monkeypatch, egress):
asked = []
monkeypatch.setattr(dl, "_yt_direct_check", lambda: (asked.append(1), False)[1])
out = dl.probe("https://vimeo.com/1")
assert out["via_egress"] is False and egress == [None] and not asked
def test_reach_check_can_be_switched_off(monkeypatch, egress):
import config
monkeypatch.setattr(config, "YT_REACH_CHECK", False)
monkeypatch.setattr(dl, "_yt_direct_check", lambda: False)
assert dl.probe("https://www.youtube.com/watch?v=x")["via_egress"] is False
assert egress == [None]
def test_reach_check_is_cached_and_one_failure_is_not_an_outage(monkeypatch):
"""Проверка — раз в TTL, а не на каждый разбор; одна неудача из двух
попыток — случайность, а не блокировка."""
answers = iter([False, True])
asked = []
monkeypatch.setattr(dl, "_yt_direct_check", lambda: (asked.append(1), next(answers))[1])
assert dl.yt_direct_ok() is True
assert dl.yt_direct_ok() is True
assert len(asked) == 2, "вторая попытка — только после неудачной первой, дальше кэш"
def test_reach_outage_and_recovery_are_logged_once(monkeypatch, capsys):
"""Поломку должно быть видно в журнале: 13.10.2026 он был пуст. Строка —
на смену состояния, а не на каждую проверку."""
import config
monkeypatch.setattr(config, "EGRESS_PROXY", "socks5h://127.0.0.1:1")
state = {"ok": False}
monkeypatch.setattr(dl, "_yt_direct_check", lambda: state["ok"])
monkeypatch.setattr(dl, "YT_REACH_FAIL_TTL_SEC", -1) # без кэша: каждая проверка настоящая
assert dl.yt_direct_ok() is False
assert dl.yt_direct_ok() is False
assert dl.yt_direct_state()["ok"] is False
out = capsys.readouterr().out
assert out.count("[youtube] напрямую не отвечает") == 1 and "через egress" in out
state["ok"] = True
assert dl.yt_direct_ok() is True
assert "[youtube] напрямую снова отвечает" in capsys.readouterr().out
assert dl.yt_direct_state()["ok"] is True
def test_https_alive_gives_up_on_silent_server():
"""Так выглядит блокировка по SNI: TCP принят, в ответ — тишина. Проверка
обязана сдаться по таймауту, а не ждать вечно."""
import socket
import time
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
addr = srv.getsockname()
try:
t0 = time.time()
assert dl._https_alive("www.youtube.com", timeout=0.3, connect_to=addr) is False
assert time.time() - t0 < 2
finally:
srv.close()
assert dl._https_alive("www.youtube.com", timeout=0.3, connect_to=addr) is False, \
"порт закрыт — тоже «нет»"
def test_playlist_probe_via_egress_when_youtube_is_silent(monkeypatch):
import config
monkeypatch.setattr(config, "EGRESS_PROXY", "socks5h://127.0.0.1:1")
monkeypatch.setattr(config, "PROXY", "")
monkeypatch.setattr(config, "YT_SOURCE_IP", "192.0.2.1")
monkeypatch.setattr(dl, "_yt_direct_check", lambda: False)
seen = {}
class Y(FakeYDL):
def __init__(self, opts):
seen.update(opts)
super().__init__({"title": "Курс", "entries": []})
monkeypatch.setattr(dl.yt_dlp, "YoutubeDL", lambda opts: Y(opts))
out = dl.probe("https://www.youtube.com/playlist?list=PLx")
assert out["via_egress"] is True
assert seen.get("proxy") == "socks5h://127.0.0.1:1" and "source_address" not in seen, \
"через прокси свой адрес не привязываем"
# --- Туннель пула для разбора через egress ---------------------------------
def test_egress_pool_skips_dead_tunnel(monkeypatch):
"""Разбор через egress брал первый туннель пула; 13.10.2026 он был «живым
трупом» (ssh держит связь, данные не идут), и загрузка висела на подготовке
при 31 рабочем туннеле."""
import config
pool = ["socks5://127.0.0.1:1", "socks5://127.0.0.1:2", "socks5://127.0.0.1:3"]
monkeypatch.setattr(config, "EGRESS_POOL", pool)
monkeypatch.setattr(dl.random, "randrange", lambda n: 0)
monkeypatch.setattr(dl, "_tunnel_alive", lambda proxy: proxy != pool[0])
assert dl.egress_pool_proxy() == pool[1]
monkeypatch.setattr(dl, "_tunnel_alive", lambda proxy: False)
assert dl.egress_pool_proxy() == pool[0], "все молчат — отдаём хоть какой-то, а не падаем"
def test_tunnel_alive_checks_the_far_end():
"""Порт туннеля отвечает всегда; «жив» — когда соединение дошло дальше."""
import socket
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
port = srv.getsockname()[1]
try:
assert dl._tunnel_alive(f"socks5://127.0.0.1:{port}", timeout=0.3) is False
finally:
srv.close()
assert dl._tunnel_alive(f"socks5://127.0.0.1:{port}", timeout=0.3) is False