yt-dlp-gui/tests/test_formats.py
codex-pve addde2704a Время каждого скачивания, фавикон и разбор находок второго аудита
Времена скачиваний. Раньше хранился только агрегат, поэтому в ленте было
видно лишь «сколько раз» и «когда в последний». Добавлена таблица событий,
раскрытие по клику на счётчик и точное время рядом с относительным.

Метки округляются до минуты — и это не косметика. При нынешнем трафике
(десятки загрузок в сутки, единицы роликов) микросекундная метка
однозначно выделяет сеанс: тот, кто знает, когда человек заходил, узнал
бы, что он скачал. Уже накопленные метки огрубляются одноразовой
миграцией при старте, включая first_seen/last_seen ленты, где запись с
единственным скачиванием публиковала точную секунду. Эндпоинт событий
закрыт тем же лимитом частоты, что и остальные: он был единственным
открытым.

Просадка SQLite в 1700 раз. Прошлая починка утечки дескрипторов открывала
и закрывала соединение на каждый вызов, а в режиме WAL SQLite выполняет
контрольную точку при закрытии последнего соединения — то есть на каждой
операции. Замер: 12 операций в секунду. Доступ и так сериализован
глобальной блокировкой, поэтому теперь одно долгоживущее соединение:
20 203 операции в секунду, дескрипторов 3, прогон тестов ускорился с 90
секунд до двух.

Молчаливая подмена формата. В каскаде не хватало ступени «снять фильтр
кодека, но оставить склейку»: на сайтах с раздельными дорожками выбор
проваливался мимо склейки к муксованному 360p, и пользователь получал не
то, что заказывал, при верной подписи. Замыкающий вариант вдобавок
игнорировал ограничение высоты и отдавал 1080p на запрос 360p.

Продвинутый режим не работал на DASH-сайтах: регулярка отвергала
настоящие идентификаторы формата, в которых остаются '=', ':', '@', '~'.

Мусор от неудачных загрузок. Уборка частичных файлов стояла только в
ветке отмены, а самый частый исход в проде — сетевой сбой, 403 или
падение ffmpeg — оставлял до двух потолков размера на два часа. Он
считается в квоте, и сервис начинал отвечать «нет места» здоровым
пользователям.

Сторож размера считал байты по одному файлу, поэтому связка video+audio
давала вдвое больше потолка, а всё, что скачивалось быстрее интервала
опроса, не проверялось вовсе.

Снятие зависшей задачи не останавливало воркер: он вечно занимал слот из
трёх, уборщик сносил файлы у него из-под рук, а по завершении ссылка всё
равно уходила в ленту.

Кнопка «Скачать» залипала навсегда. Ранний выход при «Выберите формат»
стоял до try/finally, поэтому признак занятости не снимался и кнопка
становилась некликабельной до перезагрузки страницы. Регрессия предыдущего
коммита.

Карточка замирала навсегда при 404 на поток прогресса: EventSource уходит
в CLOSED и больше не переподключается, а порог попыток не достигался
никогда, поэтому сообщение о потере связи не показывалось.

Смена сортировки ленты молча терялась во время подгрузки — блокировка
«уже гружу» отбрасывала запрос, кнопка выглядела нажатой, а данные ей не
соответствовали. Заменено счётчиком поколений.

Ошибки nginx приходят HTML-страницей, и разбор JSON падал сырым
английским исключением прямо в интерфейс.

Также: init и миграция больше не роняют сервис при недоступном хранилище
(они вызываются на импорте, и исключение не давало воркеру подняться);
миграция переносит и события, иначе история оставалась осиротевшей;
чистка событий выполняется сразу при старте, а не через сутки сна, иначе
обещанные 90 дней были неправдой при частых перезапусках; UnavailableVideoError
больше не выдаётся за внутреннюю ошибку; размер страницы ленты берётся с
сервера; добавлен фавикон. Тестов 199.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwY2Cg54RK7WZdvMaLV5Di
2026-09-21 18:47:10 +03:00

151 lines
6.5 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.

"""Селекторы форматов.
Это место ломалось чаще всего и самым незаметным образом: регистр кодека,
потерянный '?' у фильтра высоты, пропавшие кавычки вокруг regex. Поэтому
проверяем двумя способами — дословным сравнением строки и компиляцией
селектора самим yt-dlp (сеть при этом не нужна).
"""
import itertools
import pytest
import yt_dlp
import downloader as dl
def compiles(selector: str) -> bool:
"""Селектор принимается самим yt-dlp?"""
yt_dlp.YoutubeDL({"quiet": True}).build_format_selector(selector)
return True
# --- дословные эталоны ------------------------------------------------------
def test_video_selector_exact():
sel, extra, label = dl.build_format("video", height="1080",
vcodec="h264", vaudio="aac")
assert sel == (
"bestvideo[height<=?1080][vcodec~='(?i:^(avc1|h264))']"
"+bestaudio[acodec~='(?i:^mp4a)']"
"/bestvideo[height<=?1080][vcodec~='(?i:^(avc1|h264))']+bestaudio"
# ступень без фильтра кодека: без неё на сайтах с раздельными
# дорожками выбор проваливался мимо склейки к муксованному 360p
"/bestvideo[height<=?1080]+bestaudio"
"/best[height<=?1080][vcodec~='(?i:^(avc1|h264))']"
"/best[height<=?1080]"
# замыкающий вариант обязан уважать ограничение высоты
"/worst"
)
assert extra["merge_output_format"] == "mp4"
assert label == "1080p · H.264 · AAC"
def test_height_filter_tolerates_unknown_height():
"""Без '?' форматы с height=None молча выбрасываются."""
sel, _, _ = dl.build_format("video", height="720")
assert "[height<=?720]" in sel
assert "[height<=720]" not in sel
def test_vp9_matches_both_spellings():
"""VP9 приходит и как 'vp9', и как 'vp09.xx', но не должен цеплять vp8."""
sel, extra, _ = dl.build_format("video", vcodec="vp9")
assert "^vp0?9" in sel
assert extra["merge_output_format"] == "webm"
def test_codec_filters_are_case_insensitive():
"""Сайты отдают и 'avc1', и 'AVC1'; фильтры yt-dlp регистрозависимы."""
for vcodec in ("h264", "av1", "vp9"):
sel, _, _ = dl.build_format("video", vcodec=vcodec)
assert "(?i:" in sel, vcodec
def test_audio_prefers_matching_source_to_avoid_reencode():
"""Если цель — AAC, источник тоже должен быть AAC: тогда поток копируется."""
sel, extra, _ = dl.build_format("audio", acodec="aac")
assert sel.startswith("bestaudio[acodec^=mp4a]")
assert extra["postprocessors"][0]["preferredcodec"] == "aac"
sel, _, _ = dl.build_format("audio", acodec="opus")
assert sel.startswith("bestaudio[acodec^=opus]")
def test_audio_original_does_not_set_quality():
"""«Оригинал» копирует дорожку, перекодирования быть не должно."""
_, extra, label = dl.build_format("audio", acodec="best")
pp = extra["postprocessors"][0]
assert pp["preferredcodec"] == "best"
assert "preferredquality" not in pp
assert label == "Оригинал"
# --- компиляция всех сочетаний ---------------------------------------------
@pytest.mark.parametrize("height,vcodec,vaudio", list(itertools.product(
["auto", "2160", "1080", "480", "360"],
["auto", "h264", "av1", "vp9"],
["auto", "aac", "opus"],
)))
def test_every_video_combination_compiles(height, vcodec, vaudio):
sel, _, _ = dl.build_format("video", height=height,
vcodec=vcodec, vaudio=vaudio)
assert compiles(sel)
@pytest.mark.parametrize("acodec", ["best", "mp3", "aac", "opus"])
def test_every_audio_combination_compiles(acodec):
sel, _, _ = dl.build_format("audio", acodec=acodec)
assert compiles(sel)
# --- отказы -----------------------------------------------------------------
@pytest.mark.parametrize("kwargs,code", [
({"kind": "video", "height": "999"}, "unknown_height"),
({"kind": "video", "vcodec": "theora"}, "unknown_vcodec"),
({"kind": "video", "vaudio": "flac"}, "unknown_vaudio"),
({"kind": "audio", "acodec": "wav"}, "unknown_acodec"),
({"kind": "картинка"}, "unknown_kind"),
])
def test_build_format_rejects_unknown(kwargs, code):
kind = kwargs.pop("kind")
with pytest.raises(ValueError) as e:
dl.build_format(kind, **kwargs)
assert str(e.value) == code
# --- выбор формата по идентификатору ---------------------------------------
@pytest.mark.parametrize("fid", [
"137", "137+140", "hls-480", "616", "mp4-low",
# DASH отдаёт Representation@id как есть: '=', ':', '@', '~' в нём
# встречаются, и прежняя регулярка ломала продвинутый режим целиком
"video=1500000", "audio=128000", "video=1500000+audio=128000",
])
def test_format_id_accepts_real_ids(fid):
sel, _, _ = dl.build_from_format_id(fid)
assert sel == fid
@pytest.mark.parametrize("fid", [
"bestvideo[height<=1080]", # синтаксис селектора
"a/b", "137 140", "$(id)", "../x", "137+140+141", "",
"a" * 70, # длиннее предела
"all", "mergeall", "best", "worst", "bv", "ba", "BEST", # ключевые слова
"all+140", "137+all",
])
def test_format_id_rejects_selectors_and_keywords(fid):
"""'all' и 'mergeall' заставляли скачивать все дорожки сразу — это и
обход выбора пользователя, и усиление нагрузки."""
with pytest.raises(ValueError):
dl.build_from_format_id(fid)
def test_format_id_pair_merges_into_safe_container():
_, extra, _ = dl.build_from_format_id("137+140")
assert extra["merge_output_format"] == "mkv"
_, extra, _ = dl.build_from_format_id("137+140", container="mp4")
assert extra["merge_output_format"] == "mp4"
_, extra, _ = dl.build_from_format_id("137+140", container="; rm -rf /")
assert extra["merge_output_format"] == "mkv"