ytptube/app/features/ytdlp/tests/test_ytdlp_module.py
2026-06-14 18:35:03 +03:00

465 lines
18 KiB
Python

import importlib
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
from yt_dlp.globals import extractors as ytdlp_extractors
from app.features.ytdlp.outtmpl import rewrite_outtmpl
from app.features.ytdlp.patches import patch_windows_popen_wait
from app.features.ytdlp.utils import _DATA
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
def _archive_path(tmp_path: Path) -> str:
return str(tmp_path / "archive.txt")
class TestArchiveProxy:
def test_bool_and_falsey_cases(self, tmp_path: Path) -> None:
# No file path means proxy is falsey and operations return False
p = _ArchiveProxy(file=None)
assert bool(p) is False
assert ("id" in p) is False
assert p.add("id") is False
# Empty item also returns False
p2 = _ArchiveProxy(file=_archive_path(tmp_path))
assert bool(p2) is True
assert ("" in p2) is False
assert p2.add("") is False
@patch("app.features.ytdlp.archiver.Archiver.get_instance")
def test_delegates_to_archiver(self, mock_get_instance, tmp_path: Path) -> None:
arch = MagicMock()
mock_get_instance.return_value = arch
file = _archive_path(tmp_path)
p = _ArchiveProxy(file=file)
# contains -> read(file, [item]) and check membership
arch.read.return_value = ["abc"]
assert ("abc" in p) is True
arch.read.assert_called_with(file, ["abc"])
arch.read.return_value = []
assert ("xyz" in p) is False
# add -> add(file, [item]) returns boolean
arch.add.return_value = True
assert p.add("abc") is True
arch.add.assert_called_with(file, ["abc"])
arch.add.return_value = False
assert p.add("xyz") is False
class TestYtDlpOptions:
def test_options_shape(self) -> None:
opts = ytdlp_options()
assert isinstance(opts, list)
assert len(opts) > 0
# Every entry should have required keys
for o in opts:
assert {"flags", "description", "group", "ignored"} <= set(o.keys())
assert isinstance(o["flags"], list)
assert len(o["flags"]) > 0
# Ensure SUPPRESSHELP has been normalized away
if isinstance(o.get("description"), str):
assert "SUPPRESSHELP" not in o["description"]
def test_ignored_flags_match_remove_keys(self) -> None:
# Collect the flags that should be ignored from REMOVE_KEYS
ignored_flags: set[str] = {
f.strip() for group in _DATA.REMOVE_KEYS for v in group.values() for f in v.split(",") if f.strip()
}
opts = ytdlp_options()
# Map flag -> ignored value as reported by our function (first match wins)
flag_to_ignored: dict[str, bool] = {}
for o in opts:
for f in o["flags"]:
if f not in flag_to_ignored:
flag_to_ignored[f] = bool(o["ignored"]) # normalize to bool
# For any ignored flag that actually exists in yt-dlp parser, ensure it is marked ignored
present_ignored_flags = [f for f in ignored_flags if f in flag_to_ignored]
assert len(present_ignored_flags) > 0, "We expect at least one to be present (e.g., -P / --paths, etc.)"
assert all(flag_to_ignored[f] is True for f in present_ignored_flags)
class TestYTDLP:
"""Test the YTDLP class overridden methods."""
def _create_ytdlp(self, params=None):
"""Helper to create a YTDLP instance with mocked parent __init__."""
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params=params)
ytdlp.params = params or {}
return ytdlp
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_archive_param(self, mock_super_init, tmp_path: Path) -> None:
"""Test that __init__ removes download_archive before calling super, then restores it."""
mock_super_init.return_value = None
file = _archive_path(tmp_path)
params = {"download_archive": file, "quiet": True}
ytdlp = YTDLP(params=params, auto_init=True)
# Set params as it would be after super().__init__
ytdlp.params = {}
# Verify super().__init__ was called without download_archive
mock_super_init.assert_called_once()
call_kwargs = mock_super_init.call_args[1]
assert "download_archive" not in call_kwargs["params"]
assert call_kwargs["params"]["quiet"] is True
assert call_kwargs["auto_init"] is True
# Our __init__ code manually sets these after super()
ytdlp.params["download_archive"] = file
assert ytdlp.params["download_archive"] == file, "Verify download_archive was restored to params"
# Verify archive proxy was set up
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert ytdlp.archive._file == file
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_no_archive(self, mock_super_init) -> None:
"""Test __init__ works correctly when download_archive is not in params."""
mock_super_init.return_value = None
params = {"quiet": True}
ytdlp = YTDLP(params=params, auto_init=False)
# Verify super().__init__ was called with original params
mock_super_init.assert_called_once()
call_kwargs = mock_super_init.call_args[1]
assert call_kwargs["params"]["quiet"] is True
assert call_kwargs["auto_init"] is False
assert isinstance(ytdlp.archive, _ArchiveProxy), "Verify archive proxy is falsey"
assert not ytdlp.archive
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_none_params(self, mock_super_init) -> None:
"""Test __init__ handles None params gracefully."""
mock_super_init.return_value = None
ytdlp = YTDLP(params=None)
mock_super_init.assert_called_once_with(params=None, auto_init=True)
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert not ytdlp.archive
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_patches_wait(self, mock_super_init) -> None:
mock_super_init.return_value = None
class FakePopen:
def wait(self, timeout=None):
return timeout
with patch("app.features.ytdlp.patches.sys.platform", "win32"):
with patch("yt_dlp.utils.Popen", FakePopen):
YTDLP(params={})
assert getattr(FakePopen, "_ytptube_wait_patched", False) is True
def test_wait_patch_polls(self) -> None:
calls: list[float | None] = []
class FakePopen:
_ytptube_wait_patched = False
def wait(self, timeout=None):
calls.append(timeout)
if len(calls) < 3:
raise TimeoutError
return 0
with patch("app.features.ytdlp.patches.sys.platform", "win32"):
with (
patch("yt_dlp.utils.Popen", FakePopen),
patch("app.features.ytdlp.patches.subprocess.TimeoutExpired", TimeoutError),
):
patch_windows_popen_wait()
result = FakePopen().wait()
assert result == 0
assert calls == [0.1, 0.1, 0.1]
def test_wait_patch_timeout(self) -> None:
calls: list[float | None] = []
class FakePopen:
_ytptube_wait_patched = False
def wait(self, timeout=None):
calls.append(timeout)
return 0
with patch("app.features.ytdlp.patches.sys.platform", "win32"):
with patch("yt_dlp.utils.Popen", FakePopen):
patch_windows_popen_wait()
result = FakePopen().wait(timeout=5)
assert result == 0
assert calls == [5]
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_interrupted(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params={})
mock_obj: Any = ytdlp
mock_obj.to_screen = Mock()
# Set interrupted flag
ytdlp._interrupted = True
result = ytdlp._delete_downloaded_files("arg1", "arg2", kwarg1="value1")
# Should not call super method
mock_super_delete.assert_not_called()
# Should show message
mock_obj.to_screen.assert_called_once_with("[info] Cancelled — skipping temp cleanup.")
# Should return None
assert result is None
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_calls_super(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files calls super when not interrupted."""
mock_super_delete.return_value = "cleanup_result"
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params={})
ytdlp._interrupted = False
result = ytdlp._delete_downloaded_files("arg1", kwarg1="value1")
# Should call super method with same args
mock_super_delete.assert_called_once_with("arg1", kwarg1="value1")
# Should return super's result
assert result == "cleanup_result"
def test_record_archive_missing(self) -> None:
"""Test record_download_archive returns early when download_archive is not set."""
ytdlp = self._create_ytdlp(params={})
ytdlp.archive = Mock()
ytdlp.record_download_archive({"id": "test123"})
# Should not interact with archive
ytdlp.archive.add.assert_not_called()
def test_record_archive_adds_id(self, tmp_path: Path) -> None:
"""Test record_download_archive adds the archive ID."""
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
ytdlp.write_debug = Mock()
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value="youtube test123")
info_dict = {"id": "test123", "ie_key": "Youtube"}
ytdlp.record_download_archive(info_dict)
# Verify _make_archive_id was called
ytdlp._make_archive_id.assert_called_once_with(info_dict)
# Verify archive.add was called with the archive_id
ytdlp.archive.add.assert_called_once_with("youtube test123")
ytdlp.write_debug.assert_called_with("Adding to archive: youtube test123")
def test_record_archive_old_ids(self, tmp_path: Path) -> None:
"""Test record_download_archive adds _old_archive_ids when present."""
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
ytdlp.write_debug = Mock()
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value="youtube new123")
info_dict = {
"id": "new123",
"ie_key": "Youtube",
"_old_archive_ids": ["youtube old123", "youtube old456", "youtube new123"],
}
ytdlp.record_download_archive(info_dict)
# Should add main archive_id
assert ytdlp.archive.add.call_count == 3
calls = [call[0][0] for call in ytdlp.archive.add.call_args_list]
assert calls[0] == "youtube new123", "First call is main archive_id"
# Should add old IDs except the duplicate
assert "youtube old123" in calls
assert "youtube old456" in calls
# Should not add duplicate (youtube new123 appears only once)
assert calls.count("youtube new123") == 1
def test_record_archive_empty_old_ids(self, tmp_path: Path) -> None:
"""Test record_download_archive handles empty or invalid _old_archive_ids."""
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
ytdlp.write_debug = Mock()
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value="youtube test123")
# Test with empty list
info_dict = {"id": "test123", "ie_key": "Youtube", "_old_archive_ids": []}
ytdlp.record_download_archive(info_dict)
assert ytdlp.archive.add.call_count == 1 # Only main ID
ytdlp.archive.reset_mock()
# Test with None
info_dict = {"id": "test123", "ie_key": "Youtube", "_old_archive_ids": None}
ytdlp.record_download_archive(info_dict)
assert ytdlp.archive.add.call_count == 1 # Only main ID
ytdlp.archive.reset_mock()
# Test with non-list value
info_dict = {"id": "test123", "ie_key": "Youtube", "_old_archive_ids": "not a list"}
ytdlp.record_download_archive(info_dict)
assert ytdlp.archive.add.call_count == 1 # Only main ID
def test_record_archive_empty_id(self, tmp_path: Path) -> None:
"""Test record_download_archive returns early when _make_archive_id returns empty."""
ytdlp = self._create_ytdlp(params={"download_archive": _archive_path(tmp_path)})
ytdlp.archive = Mock()
ytdlp._make_archive_id = Mock(return_value=None)
ytdlp.record_download_archive({"id": "test123"})
# Should not add anything
ytdlp.archive.add.assert_not_called()
def test_outtmpl_callable(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
result = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "x"})
assert len(result) == 8
assert result.isalnum()
def test_exec_init(self) -> None:
YTDLP(
params={
"compat_opts": set(),
"postprocessors": [{"key": "Exec", "exec_cmd": "echo %(title)q"}],
}
)
def test_outtmpl_reuses_value(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
result = ytdlp.evaluate_outtmpl("%(ytp_random:8)s/%(ytp_random:8)s", {"title": "x"})
first, second = result.split("/")
assert first == second
def test_outtmpl_new_value(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
first = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "x"})
second = ytdlp.evaluate_outtmpl("%(ytp_random:8)s", {"title": "y"})
assert first != second
def test_prepare_filename_sidecars(self) -> None:
ytdlp = YTDLP(
params={
"outtmpl": {
"default": "%(ytp_random:6)s.%(ext)s",
"thumbnail": "%(ytp_random:6)s.%(ext)s",
"subtitle": "%(ytp_random:6)s.%(ext)s",
"infojson": "%(ytp_random:6)s.%(ext)s",
}
}
)
info = {"id": "abc123", "title": "Example", "ext": "mp4"}
default_name = ytdlp.prepare_filename(info)
thumbnail_name = ytdlp.prepare_filename(info, "thumbnail")
subtitle_name = ytdlp.prepare_filename(info, "subtitle")
infojson_name = ytdlp.prepare_filename(info, "infojson")
default_base = default_name.rsplit(".", 1)[0]
thumbnail_base = thumbnail_name.rsplit(".", 1)[0]
subtitle_base = subtitle_name.rsplit(".", 1)[0]
infojson_base = infojson_name.removesuffix(".info.json")
assert default_base == thumbnail_base
assert default_base == subtitle_base
assert default_base == infojson_base
def test_prepare_filename_resets(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(ytp_random:8)s.%(ext)s"}})
first = ytdlp.prepare_filename({"id": "one", "title": "One", "ext": "mp4"})
second = ytdlp.prepare_filename({"id": "two", "title": "Two", "ext": "mp4"})
assert first != second
@pytest.mark.parametrize(
("template", "expected"),
[
("%(ytp_random:8|fallback)s", 8),
("%(ytp_random:8&{} - |)s", 11),
("%(ytp_random:8)S", 8),
],
)
def test_outtmpl_suffix(self, template: str, expected: int) -> None:
ytdlp = YTDLP(
params={
"outtmpl": {"default": "%(title)s"},
"restrictfilenames": True,
},
)
result = ytdlp.evaluate_outtmpl(template, {"title": "x"})
assert len(result) == expected
def test_outtmpl_modes(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
digits = ytdlp.evaluate_outtmpl("%(ytp_random:6:d)s", {"title": "x"})
letters = ytdlp.evaluate_outtmpl("%(ytp_random:6:s)s", {"title": "x"})
assert digits.isdigit()
assert letters.isalpha()
def test_outtmpl_unknown_callable(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
with pytest.raises(ValueError, match="Unsupported YTPTube output template callable"):
ytdlp.prepare_outtmpl("%(ytp_unknown:8)s", {"title": "x"})
def test_outtmpl_invalid_length(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
with pytest.raises(ValueError, match="ytp_random length must be an integer"):
ytdlp.prepare_outtmpl("%(ytp_random:nope)s", {"title": "x"})
class TestOuttmpl:
def test_rewrite_outtmpl_cache(self) -> None:
cache: dict[str, object] = {}
template = "%(ytp_random:8)s/%(ytp_random:8)s.%(ext)s"
rewritten, info = rewrite_outtmpl(template, {"ext": "mp4"}, cache=cache)
assert rewritten == "%(__ytptube_outtmpl_0)s/%(__ytptube_outtmpl_0)s.%(ext)s"
assert len(cache) == 1
assert info["__ytptube_outtmpl_0"] == next(iter(cache.values()))