import importlib from pathlib import Path 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={}) ytdlp.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 ytdlp.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_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()))