Merge pull request #428 from pumpkinperson996/fix/ytdlp-zero-retries

fix(downloader): yt-dlp 未设 retries 时零重试,一次网络抖动就让任务失败
This commit is contained in:
Jianwu Huang
2026-08-25 13:57:33 +08:00
committed by GitHub
4 changed files with 145 additions and 2 deletions
+125
View File
@@ -0,0 +1,125 @@
"""
Coverage for the yt-dlp retry settings shared by the downloaders.
Background: yt-dlp's documented `retries` default of 10 comes from its *command
line* option parser. Nothing applies that default to the Python API, so a
`YoutubeDL({...})` built without `retries` ends up in:
# yt_dlp/downloader/http.py
for retry in RetryManager(self.params.get('retries'), ...) # -> None
# yt_dlp/utils/_utils.py
self.retries = _retries or 0 # -> 0
i.e. exactly one attempt and no retries. A single transient network hiccup
(observed: read timeout from upos-sz-mirrorcosov.bilivideo.com) then fails the
whole note task, even though an immediate re-run succeeds.
These tests pin both halves of the fix: the constant produces a real retry
budget, and every yt-dlp options dict in the downloaders actually carries it.
"""
import ast
import importlib.util
import pathlib
import sys
import types
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
DOWNLOADERS = ROOT / "app" / "downloaders"
DOWNLOADER_SOURCES = ["youtube_downloader.py", "bilibili_downloader.py"]
def _load_base():
"""Load app/downloaders/base.py with its app-level imports stubbed out."""
for name, attrs in {
"app": {},
"app.enmus": {},
"app.models": {},
"app.enmus.note_enums": {"DownloadQuality": str},
"app.models.notes_model": {"AudioDownloadResult": object},
"app.models.transcriber_model": {"TranscriptResult": object},
}.items():
module = types.ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules.setdefault(name, module)
spec = importlib.util.spec_from_file_location("dl_base", DOWNLOADERS / "base.py")
if spec is None or spec.loader is None:
raise ImportError("base module spec not found")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class RetryOptsValueTest(unittest.TestCase):
def setUp(self):
self.base = _load_base()
def test_retry_budget_is_not_zero(self):
try:
from yt_dlp.utils import RetryManager
except Exception as exc: # pragma: no cover - env without yt-dlp
self.skipTest(f"yt-dlp not importable: {exc}")
retries = self.base.YDL_RETRY_OPTS["retries"]
budget = RetryManager(retries, lambda *a, **k: None).retries
self.assertGreater(budget, 0)
def test_documents_the_zero_default_being_guarded_against(self):
"""The bug this guards: an unset `retries` collapses to a 0 budget."""
try:
from yt_dlp.utils import RetryManager
except Exception as exc: # pragma: no cover - env without yt-dlp
self.skipTest(f"yt-dlp not importable: {exc}")
self.assertEqual(RetryManager(None, lambda *a, **k: None).retries, 0)
def test_socket_timeout_is_bounded(self):
# Without a bound, a stalled read can hang a task instead of failing
# fast enough for the retries above to be useful.
timeout = self.base.YDL_RETRY_OPTS["socket_timeout"]
self.assertGreater(timeout, 0)
class RetryOptsAreAppliedTest(unittest.TestCase):
"""
Structural check: every `ydl_opts = {...}` literal in the downloaders must
unpack YDL_RETRY_OPTS. Catches a newly added download path that silently
goes back to the zero-retry default.
"""
def _ydl_opts_dicts(self, path):
tree = ast.parse(path.read_text(encoding="utf-8"))
found = []
for node in ast.walk(tree):
if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Dict):
continue
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
if "ydl_opts" in names:
found.append(node.value)
return found
def test_every_ydl_opts_dict_unpacks_retry_opts(self):
for filename in DOWNLOADER_SOURCES:
path = DOWNLOADERS / filename
dicts = self._ydl_opts_dicts(path)
self.assertTrue(dicts, f"no ydl_opts dict found in {filename}")
for index, node in enumerate(dicts):
with self.subTest(file=filename, dict_index=index, line=node.lineno):
unpacked = {
value.id
for key, value in zip(node.keys, node.values)
if key is None and isinstance(value, ast.Name)
}
self.assertIn(
"YDL_RETRY_OPTS",
unpacked,
f"{filename}:{node.lineno} builds yt-dlp options without "
f"YDL_RETRY_OPTS, so it gets zero retries",
)
if __name__ == "__main__":
unittest.main()