1061 lines
42 KiB
Python
1061 lines
42 KiB
Python
"""Tests for YTDLPOpts class."""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from app.features.presets.schemas import Preset
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
|
|
|
|
|
|
class TestYTDLPOpts:
|
|
"""Test the YTDLPOpts class."""
|
|
|
|
def test_constructor_initializes_correctly(self):
|
|
"""Test that YTDLPOpts constructor initializes all attributes."""
|
|
with patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config:
|
|
mock_config_instance = Mock()
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
opts = YTDLPOpts()
|
|
|
|
assert opts._config is mock_config_instance
|
|
assert opts._item_opts == {}
|
|
assert opts._preset_opts == {}
|
|
assert opts._item_cli == []
|
|
assert opts._preset_cli == ""
|
|
|
|
def test_get_instance(self):
|
|
"""Test that get_instance returns a reset YTDLPOpts instance."""
|
|
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
|
opts = YTDLPOpts.get_instance()
|
|
|
|
assert isinstance(opts, YTDLPOpts)
|
|
assert opts._item_opts == {}
|
|
assert opts._preset_opts == {}
|
|
assert opts._item_cli == []
|
|
assert opts._preset_cli == ""
|
|
|
|
def test_add_cli_with_valid_args(self):
|
|
"""Test adding valid CLI arguments."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
|
|
):
|
|
mock_converter.return_value = {"format": "best"}
|
|
opts = YTDLPOpts()
|
|
|
|
result = opts.add_cli("--format best", from_user=False)
|
|
|
|
assert result is opts # Returns self for chaining
|
|
assert "--format best" in opts._item_cli
|
|
mock_converter.assert_called_once_with(args="--format best", level=False)
|
|
|
|
def test_add_cli_invalid(self):
|
|
"""Test that invalid CLI arguments raise ValueError."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
|
|
):
|
|
mock_converter.side_effect = Exception("Invalid argument")
|
|
opts = YTDLPOpts()
|
|
|
|
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
|
|
opts.add_cli("--invalid-arg", from_user=True)
|
|
|
|
def test_add_cli_empty(self):
|
|
"""Test that empty or invalid args return self without processing."""
|
|
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
|
opts = YTDLPOpts()
|
|
|
|
# Test empty string
|
|
result1 = opts.add_cli("", from_user=False)
|
|
assert result1 is opts
|
|
assert len(opts._item_cli) == 0
|
|
|
|
# Test short string
|
|
result2 = opts.add_cli("a", from_user=False)
|
|
assert result2 is opts
|
|
assert len(opts._item_cli) == 0
|
|
|
|
# Test non-string (this should be handled gracefully)
|
|
result3 = opts.add_cli(123, from_user=False) # type: ignore
|
|
assert result3 is opts
|
|
assert len(opts._item_cli) == 0
|
|
|
|
def test_add_with_valid_config(self):
|
|
"""Test adding configuration options."""
|
|
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
|
opts = YTDLPOpts()
|
|
|
|
config = {"format": "best", "quality": "720p"}
|
|
result = opts.add(config, from_user=False)
|
|
|
|
assert result is opts
|
|
assert opts._item_opts["format"] == "best"
|
|
assert opts._item_opts["quality"] == "720p"
|
|
|
|
def test_add_filters_bad_options(self):
|
|
"""Test that user config filters out dangerous options."""
|
|
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
|
opts = YTDLPOpts()
|
|
|
|
config = {
|
|
"format": "best",
|
|
"paths": "/dangerous/path", # Should be filtered
|
|
"outtmpl": "dangerous_template", # Should be filtered
|
|
"quality": "720p",
|
|
}
|
|
|
|
result = opts.add(config, from_user=True)
|
|
|
|
assert result is opts
|
|
assert opts._item_opts["format"] == "best"
|
|
assert opts._item_opts["quality"] == "720p"
|
|
assert "paths" not in opts._item_opts
|
|
assert "outtmpl" not in opts._item_opts
|
|
|
|
def test_preset_with_valid_preset(self):
|
|
"""Test applying a valid preset."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.presets.service.Presets") as mock_presets,
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
|
|
):
|
|
# Mock config
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.config_path = "/test/config"
|
|
mock_config_instance.download_path = "/test/downloads"
|
|
mock_config_instance.temp_path = "/test/temp"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
# Mock preset
|
|
mock_preset = Mock(spec=Preset)
|
|
mock_preset.id = "test_preset"
|
|
mock_preset.name = "Test Preset"
|
|
mock_preset.cli = "--format best"
|
|
mock_preset.cookies = None
|
|
mock_preset.template = "custom_template"
|
|
mock_preset.folder = "custom_folder"
|
|
|
|
mock_presets_instance = Mock()
|
|
mock_presets_instance.get.return_value = mock_preset
|
|
mock_presets.get_instance.return_value = mock_presets_instance
|
|
|
|
mock_converter.return_value = {"format": "best"}
|
|
|
|
with patch("app.features.ytdlp.ytdlp_opts.calc_download_path") as mock_calc_path:
|
|
mock_calc_path.return_value = "/test/downloads/custom_folder"
|
|
|
|
opts = YTDLPOpts()
|
|
result = opts.preset("test_preset")
|
|
|
|
assert result is opts
|
|
assert opts._preset_cli == "--format best"
|
|
assert opts._preset_opts["outtmpl"]["default"] == "custom_template"
|
|
assert opts._preset_opts["paths"]["home"] == "/test/downloads/custom_folder"
|
|
|
|
def test_preset_with_nonexistent_preset(self):
|
|
"""Test applying a nonexistent preset returns self."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
|
patch("app.features.presets.service.Presets") as mock_presets,
|
|
):
|
|
mock_presets_instance = Mock()
|
|
mock_presets_instance.get.return_value = None
|
|
mock_presets.get_instance.return_value = mock_presets_instance
|
|
|
|
opts = YTDLPOpts()
|
|
result = opts.preset("nonexistent")
|
|
|
|
assert result is opts
|
|
assert opts._preset_cli == ""
|
|
assert opts._preset_opts == {}
|
|
|
|
def test_preset_with_cookies_creates_file(self):
|
|
"""Test that preset with cookies creates cookie file."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.presets.service.Presets") as mock_presets,
|
|
):
|
|
# Mock config
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.config_path = "/test/config"
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
# Mock preset with cookies
|
|
mock_preset = Mock(spec=Preset)
|
|
mock_preset.id = "cookie_preset"
|
|
mock_preset.name = "Cookie Preset"
|
|
mock_preset.cli = None
|
|
mock_preset.cookies = "cookie_data"
|
|
mock_preset.template = None
|
|
mock_preset.folder = None
|
|
|
|
# Mock cookie_file path
|
|
mock_cookie_path = Mock()
|
|
mock_cookie_path.exists.return_value = True
|
|
mock_cookie_path.__str__ = Mock(return_value="/test/config/cookies/cookie_preset.txt")
|
|
mock_preset.get_cookies_file.return_value = mock_cookie_path
|
|
|
|
mock_presets_instance = Mock()
|
|
mock_presets_instance.get.return_value = mock_preset
|
|
mock_presets.get_instance.return_value = mock_presets_instance
|
|
|
|
opts = YTDLPOpts()
|
|
opts.preset("cookie_preset")
|
|
|
|
# Check that the cookie file would be created at the right path
|
|
expected_path = "/test/config/cookies/cookie_preset.txt"
|
|
assert opts._preset_opts["cookiefile"] == expected_path
|
|
mock_preset.get_cookies_file.assert_called_once_with(config=mock_config_instance)
|
|
|
|
def test_preset_invalid_cli(self):
|
|
"""Test that preset with invalid CLI raises ValueError."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
|
patch("app.features.presets.service.Presets") as mock_presets,
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
|
|
):
|
|
mock_preset = Mock(spec=Preset)
|
|
mock_preset.id = "bad_preset"
|
|
mock_preset.name = "Bad Preset"
|
|
mock_preset.cli = "--invalid-option"
|
|
mock_preset.cookies = None
|
|
mock_preset.template = None
|
|
mock_preset.folder = None
|
|
|
|
mock_presets_instance = Mock()
|
|
mock_presets_instance.get.return_value = mock_preset
|
|
mock_presets.get_instance.return_value = mock_presets_instance
|
|
|
|
mock_converter.side_effect = Exception("Invalid CLI")
|
|
|
|
opts = YTDLPOpts()
|
|
|
|
with pytest.raises(ValueError, match="Invalid preset 'Bad Preset' command options for yt-dlp"):
|
|
opts.preset("bad_preset")
|
|
|
|
def test_get_all_with_default_options(self):
|
|
"""Test get_all returns correct default options."""
|
|
with patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config:
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.temp_path = "/temp"
|
|
mock_config_instance.output_template = "default_template"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config_instance.debug = False
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
with patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge:
|
|
mock_merge.return_value = {
|
|
"paths": {"home": "/downloads", "temp": "/temp"},
|
|
"outtmpl": {"default": "default_template", "chapter": "chapter_template"},
|
|
}
|
|
|
|
opts = YTDLPOpts()
|
|
result = opts.get_all(keep=True)
|
|
|
|
assert result["paths"]["home"] == "/downloads"
|
|
assert result["outtmpl"]["default"] == "default_template"
|
|
|
|
def test_get_all_processes_cli_arguments(self):
|
|
"""Test get_all processes CLI arguments correctly."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
|
|
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
|
|
):
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.temp_path = "/temp"
|
|
mock_config_instance.output_template = "default_template"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config_instance.debug = False
|
|
mock_config_instance.get_replacers.return_value = {"home": "/test"}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_converter.return_value = {"format": "best"}
|
|
mock_merge.return_value = {"format": "best"}
|
|
|
|
opts = YTDLPOpts()
|
|
opts._item_cli = ["--format best"]
|
|
opts._preset_cli = "--quality 720p"
|
|
|
|
opts.get_all(keep=True)
|
|
|
|
# Should join CLI args and process replacers
|
|
expected_cli = "--quality 720p\n--format best"
|
|
mock_converter.assert_called_once_with(args=expected_cli, level=True)
|
|
|
|
def test_get_all_format_cases(self):
|
|
"""Test get_all handles special format values correctly."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
|
|
):
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.temp_path = "/temp"
|
|
mock_config_instance.output_template = "default_template"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config_instance.debug = False
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
opts = YTDLPOpts()
|
|
|
|
# Test "not_set" format
|
|
mock_merge.return_value = {"format": "not_set"}
|
|
result = opts.get_all(keep=True)
|
|
assert result["format"] is None
|
|
|
|
# Test "default" format
|
|
mock_merge.return_value = {"format": "default"}
|
|
result = opts.get_all(keep=True)
|
|
assert result["format"] is None
|
|
|
|
# Test "best" format
|
|
mock_merge.return_value = {"format": "best"}
|
|
result = opts.get_all(keep=True)
|
|
assert result["format"] is None
|
|
|
|
# Test "-best" format (should remove leading dash)
|
|
mock_merge.return_value = {"format": "-best"}
|
|
result = opts.get_all(keep=True)
|
|
assert result["format"] == "best"
|
|
|
|
def test_get_all_invalid_cli(self):
|
|
"""Test get_all raises error for invalid CLI arguments."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
|
|
):
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.temp_path = "/temp"
|
|
mock_config_instance.output_template = "default_template"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config_instance.debug = False
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_converter.side_effect = Exception("Invalid CLI")
|
|
|
|
opts = YTDLPOpts()
|
|
opts._item_cli = ["--invalid-arg"]
|
|
|
|
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
|
|
opts.get_all()
|
|
|
|
def test_get_all_resets_unless_keep_true(self):
|
|
"""Test get_all resets instance unless keep=True."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
|
|
):
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.temp_path = "/temp"
|
|
mock_config_instance.output_template = "default_template"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config_instance.debug = False
|
|
mock_config_instance.get_replacers.return_value = {} # Return empty dict
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_merge.return_value = {}
|
|
|
|
opts = YTDLPOpts()
|
|
opts._item_opts = {"test": "value"}
|
|
opts._item_cli = ["--test"]
|
|
|
|
# Test with keep=False (default)
|
|
opts.get_all(keep=False)
|
|
assert opts._item_opts == {}
|
|
assert opts._item_cli == []
|
|
|
|
# Reset test data
|
|
opts._item_opts = {"test": "value"}
|
|
opts._item_cli = ["--test"]
|
|
|
|
# Test with keep=True
|
|
opts.get_all(keep=True)
|
|
assert opts._item_opts == {"test": "value"}
|
|
assert opts._item_cli == ["--test"]
|
|
|
|
def test_reset_clears_all_options(self):
|
|
"""Test reset clears all internal state."""
|
|
with patch("app.features.ytdlp.ytdlp_opts.Config"):
|
|
opts = YTDLPOpts()
|
|
|
|
# Set some state
|
|
opts._item_opts = {"format": "best"}
|
|
opts._preset_opts = {"quality": "720p"}
|
|
opts._item_cli = ["--format best"]
|
|
opts._preset_cli = "--quality 720p"
|
|
|
|
result = opts.reset()
|
|
|
|
assert result is opts
|
|
assert opts._item_opts == {}
|
|
assert opts._preset_opts == {}
|
|
assert opts._item_cli == []
|
|
assert opts._preset_cli == ""
|
|
|
|
def test_method_chaining(self):
|
|
"""Test that methods support chaining."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config"),
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter"),
|
|
patch("app.features.presets.service.Presets") as mock_presets,
|
|
):
|
|
mock_presets_instance = Mock()
|
|
mock_presets_instance.get.return_value = None
|
|
mock_presets.get_instance.return_value = mock_presets_instance
|
|
|
|
opts = YTDLPOpts()
|
|
|
|
# Test chaining
|
|
result = opts.add_cli("--format best").add({"quality": "720p"}).preset("nonexistent").reset()
|
|
|
|
assert result is opts
|
|
|
|
def test_debug_logging(self):
|
|
"""Test debug logging when enabled."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
|
|
patch("app.features.ytdlp.ytdlp_opts.LOG") as mock_log,
|
|
):
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.temp_path = "/temp"
|
|
mock_config_instance.output_template = "default_template"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config_instance.debug = True # Enable debug
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
test_data = {"format": "best"}
|
|
mock_merge.return_value = test_data
|
|
|
|
opts = YTDLPOpts()
|
|
opts.get_all(keep=True)
|
|
|
|
mock_log.debug.assert_called_once_with("Prepared final yt-dlp options.", extra={"ytdlp_options": test_data})
|
|
|
|
def test_cookie_loading_error_handling(self):
|
|
"""Test error handling when cookie loading fails."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.presets.service.Presets") as mock_presets,
|
|
patch("app.features.ytdlp.ytdlp_opts.LOG") as mock_log,
|
|
):
|
|
# Mock config
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.config_path = "/test/config"
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
# Mock preset with cookies
|
|
mock_preset = Mock(spec=Preset)
|
|
mock_preset.id = "cookie_preset"
|
|
mock_preset.name = "Cookie Preset"
|
|
mock_preset.cli = None
|
|
mock_preset.cookies = "invalid_cookie_data"
|
|
mock_preset.template = None
|
|
mock_preset.folder = None
|
|
|
|
# Mock cookie loading failure
|
|
mock_preset.get_cookies_file.side_effect = ValueError("Invalid cookies")
|
|
|
|
mock_presets_instance = Mock()
|
|
mock_presets_instance.get.return_value = mock_preset
|
|
mock_presets.get_instance.return_value = mock_presets_instance
|
|
|
|
opts = YTDLPOpts()
|
|
opts.preset("cookie_preset")
|
|
|
|
# Should log exception but not raise
|
|
mock_log.exception.assert_called_once()
|
|
error_fields = mock_log.exception.call_args.kwargs["extra"]
|
|
assert error_fields["preset"] == "Cookie Preset"
|
|
assert error_fields["has_cookies"] is True
|
|
assert error_fields["exception_type"] == "ValueError"
|
|
|
|
assert "cookiefile" not in opts._preset_opts, "cookiefile should not be set"
|
|
|
|
def test_replacer_substitution_in_cli(self):
|
|
"""Test that CLI arguments get replacer substitution."""
|
|
with (
|
|
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
|
|
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
|
|
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
|
|
):
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.temp_path = "/temp"
|
|
mock_config_instance.output_template = "default_template"
|
|
mock_config_instance.output_template_chapter = "chapter_template"
|
|
mock_config_instance.debug = False
|
|
mock_config_instance.get_replacers.return_value = {"home": "/actual/home", "user": "testuser"}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_converter.return_value = {"output": "/actual/home/testuser"}
|
|
mock_merge.return_value = {}
|
|
|
|
opts = YTDLPOpts()
|
|
opts._item_cli = ["--output %(home)s/%(user)s"]
|
|
|
|
opts.get_all(keep=True)
|
|
|
|
# Should replace %(home)s and %(user)s
|
|
expected_cli = "--output /actual/home/testuser"
|
|
mock_converter.assert_called_once_with(args=expected_cli, level=True)
|
|
|
|
|
|
class TestARGSMerger:
|
|
"""Test the ARGSMerger class."""
|
|
|
|
def test_constructor_initializes_empty_args(self):
|
|
"""Test that ARGSMerger constructor initializes with empty args list."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
|
|
assert merger.args == []
|
|
|
|
def test_add_valid_args(self):
|
|
"""Test adding valid command-line arguments."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
result = merger.add("--format best")
|
|
|
|
assert result is merger # Returns self for chaining
|
|
assert merger.args == ["--format", "best"]
|
|
|
|
def test_add_filters_comment_lines(self):
|
|
"""Test that comment lines (starting with #) are filtered out."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
cli_with_comments = """--format best
|
|
# This is a comment
|
|
--output test.mp4
|
|
#Another comment without space
|
|
--no-playlist"""
|
|
|
|
merger.add(cli_with_comments)
|
|
|
|
assert "#" not in merger.as_string(), "Comments should be filtered out"
|
|
assert "This is a comment" not in merger.as_string()
|
|
assert "Another comment" not in merger.as_string()
|
|
|
|
assert "--format" in merger.args, "Valid options should remain"
|
|
assert "best" in merger.args
|
|
assert "--output" in merger.args
|
|
assert "test.mp4" in merger.args
|
|
assert "--no-playlist" in merger.args
|
|
|
|
def test_add_filters_indented_comment_lines(self):
|
|
"""Test that indented comment lines are filtered out."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
cli_with_indented_comments = """--format best
|
|
# Indented comment with spaces
|
|
# Indented comment with tabs
|
|
--output test.mp4
|
|
# Another indented comment
|
|
--socket-timeout 30"""
|
|
|
|
merger.add(cli_with_indented_comments)
|
|
|
|
result = merger.as_string()
|
|
assert "# Indented comment with spaces" not in result, "Comments should be filtered out"
|
|
assert "# Indented comment with tabs" not in result
|
|
assert "# Another indented comment" not in result
|
|
|
|
assert "--format" in merger.args, "Valid options should remain"
|
|
assert "--output" in merger.args
|
|
assert "--socket-timeout" in merger.args
|
|
|
|
def test_add_complex_comments(self):
|
|
"""Test filtering of complex real-world commented extractor-args."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
cli_with_complex_comments = """--extractor-args "youtube:player-client=default,tv,mweb,-web_safari;formats=incomplete"
|
|
#--extractor-args "youtube:player-client=default,tv,mweb;-formats=incomplete;player_js_version=actual"
|
|
#--extractor-args "youtube:player-client=default,tv,mweb,web_safari;formats=incomplete"
|
|
--socket-timeout 60"""
|
|
|
|
merger.add(cli_with_complex_comments)
|
|
|
|
result = merger.as_string()
|
|
|
|
assert "player_js_version=actual" not in result, "Commented lines should be filtered out completely"
|
|
assert "mweb,web_safari;formats=incomplete" not in result, (
|
|
"Check the specific commented variant (with comma before web_safari, not dash)"
|
|
)
|
|
|
|
assert "youtube:player-client=default,tv,mweb,-web_safari;formats=incomplete" in result, (
|
|
"Valid extractor-args should remain (with -web_safari, note the dash)"
|
|
)
|
|
assert "--socket-timeout" in merger.args
|
|
assert "60" in merger.args
|
|
|
|
def test_add_multiple_args_with_chaining(self):
|
|
"""Test adding multiple arguments using method chaining."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add("--format best").add("--output test.mp4").add("--no-playlist")
|
|
|
|
assert merger.args == ["--format", "best", "--output", "test.mp4", "--no-playlist"]
|
|
|
|
def test_add_args_with_quotes(self):
|
|
"""Test adding arguments containing quoted strings."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add('--output "%(title)s.%(ext)s"')
|
|
|
|
assert merger.args == ["--output", "%(title)s.%(ext)s"]
|
|
|
|
def test_add_complex_args(self):
|
|
"""Test adding complex arguments with multiple values."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add("--format 'bestvideo[height<=1080]+bestaudio/best'")
|
|
|
|
assert "--format" in merger.args
|
|
assert "bestvideo[height<=1080]+bestaudio/best" in merger.args
|
|
|
|
def test_add_empty(self):
|
|
"""Test that adding empty string returns self without modifying args."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
result = merger.add("")
|
|
|
|
assert result is merger
|
|
assert merger.args == []
|
|
|
|
def test_add_short(self):
|
|
"""Test that adding short string (len < 2) returns self without modifying args."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
result = merger.add("a")
|
|
|
|
assert result is merger
|
|
assert merger.args == []
|
|
|
|
def test_add_non_string(self):
|
|
"""Test that adding non-string returns self without modifying args."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
result = merger.add(123) # type: ignore
|
|
|
|
assert result is merger
|
|
assert merger.args == []
|
|
|
|
def test_as_string(self):
|
|
"""Test converting args to string."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add("--format best").add("--output test.mp4")
|
|
|
|
result = merger.as_string()
|
|
|
|
assert result == "--format best --output test.mp4"
|
|
|
|
def test_str_method(self):
|
|
"""Test __str__ method."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add("--format best").add("--output test.mp4")
|
|
|
|
result = str(merger)
|
|
|
|
assert result == "--format best --output test.mp4"
|
|
|
|
def test_as_dict(self):
|
|
"""Test converting args to dict."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add("--format best").add("--output test.mp4")
|
|
|
|
result = merger.as_dict()
|
|
|
|
assert isinstance(result, list)
|
|
assert result == ["--format", "best", "--output", "test.mp4"]
|
|
|
|
def test_as_ytdlp(self):
|
|
"""Test converting args to yt-dlp JSON options."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add("--format best")
|
|
|
|
with patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter:
|
|
mock_converter.return_value = {"format": "best"}
|
|
|
|
result = merger.as_ytdlp()
|
|
|
|
assert result == {"format": "best"}
|
|
mock_converter.assert_called_once_with(args="--format best", level=False)
|
|
|
|
def test_get_instance(self):
|
|
"""Test get_instance static method."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger.get_instance()
|
|
|
|
assert isinstance(merger, ARGSMerger)
|
|
assert merger.args == []
|
|
|
|
def test_reset(self):
|
|
"""Test reset method clears args."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add("--format best").add("--output test.mp4")
|
|
|
|
assert len(merger.args) > 0
|
|
|
|
result = merger.reset()
|
|
|
|
assert result is merger
|
|
assert merger.args == []
|
|
|
|
def test_args_with_special_characters(self):
|
|
"""Test handling arguments with special characters."""
|
|
from app.features.ytdlp.ytdlp_opts import ARGSMerger
|
|
|
|
merger = ARGSMerger()
|
|
merger.add('--postprocessor-args "-movflags +faststart"')
|
|
|
|
assert "--postprocessor-args" in merger.args
|
|
assert "-movflags +faststart" in merger.args
|
|
|
|
|
|
class TestYTDLPCli:
|
|
"""Test the YTDLPCli class."""
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_constructor_with_valid_item(self, mock_config, mock_presets):
|
|
"""Test YTDLPCli constructor with valid Item."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = None
|
|
|
|
item = Item(url="https://example.com/video")
|
|
cli = YTDLPCli(item=item)
|
|
|
|
assert cli.item is item
|
|
assert cli._config is mock_config_instance
|
|
|
|
def test_constructor_invalid_item(self):
|
|
"""Test YTDLPCli constructor raises error with non-Item type."""
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
with pytest.raises(ValueError, match="Expected Item instance"):
|
|
YTDLPCli(item="not an item") # type: ignore
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_with_user_fields_only(self, mock_config, mock_presets):
|
|
"""Test build with only user-provided fields."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = None
|
|
|
|
item = Item(
|
|
url="https://example.com/video",
|
|
folder="myfolder",
|
|
template="%(id)s.%(ext)s",
|
|
cli="--format best",
|
|
)
|
|
|
|
cli = YTDLPCli(item=item)
|
|
command, info = cli.build()
|
|
|
|
assert isinstance(command, str)
|
|
assert isinstance(info, dict)
|
|
assert "command" in info
|
|
assert "dict" in info
|
|
assert "ytdlp" in info
|
|
assert "merged" in info
|
|
assert info["merged"]["template"] == "%(id)s.%(ext)s"
|
|
assert info["merged"]["save_path"] == "/downloads/myfolder"
|
|
assert "--format best" in command
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_with_preset_fields_fallback(self, mock_config, mock_presets):
|
|
"""Test build falls back to preset fields when user doesn't provide them."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.presets.schemas import Preset
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
# Create a preset with fields
|
|
preset = Preset(
|
|
name="test_preset",
|
|
folder="preset_folder",
|
|
template="%(channel)s/%(title)s.%(ext)s",
|
|
cli="--format 720p",
|
|
)
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = preset
|
|
|
|
# Item without folder/template - should use preset's
|
|
item = Item(url="https://example.com/video", preset="test_preset")
|
|
|
|
cli = YTDLPCli(item=item)
|
|
command, info = cli.build()
|
|
|
|
assert info["merged"]["template"] == "%(channel)s/%(title)s.%(ext)s", "Should use preset values"
|
|
assert info["merged"]["save_path"] == "/downloads/preset_folder"
|
|
assert "--format 720p" in command
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_user_fields_override_preset(self, mock_config, mock_presets):
|
|
"""Test that user fields override preset fields."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.presets.schemas import Preset
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
preset = Preset(
|
|
name="test_preset",
|
|
folder="preset_folder",
|
|
template="%(channel)s/%(title)s.%(ext)s",
|
|
cli="--format 720p",
|
|
)
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = preset
|
|
|
|
# Item with user fields - should override preset
|
|
item = Item(
|
|
url="https://example.com/video",
|
|
preset="test_preset",
|
|
folder="user_folder",
|
|
template="%(id)s.%(ext)s",
|
|
cli="--format best",
|
|
)
|
|
|
|
cli = YTDLPCli(item=item)
|
|
command, info = cli.build()
|
|
|
|
assert info["merged"]["template"] == "%(id)s.%(ext)s", "Should use user values, not preset"
|
|
assert info["merged"]["save_path"] == "/downloads/user_folder"
|
|
assert "--format best" in command, "User CLI should appear after preset CLI in command"
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_with_default_fallback(self, mock_config, mock_presets):
|
|
"""Test build falls back to defaults when neither user nor preset provide fields."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/default/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = None
|
|
|
|
# Item with minimal fields
|
|
item = Item(url="https://example.com/video")
|
|
|
|
cli = YTDLPCli(item=item)
|
|
_, info = cli.build()
|
|
|
|
# Should use default values
|
|
assert info["merged"]["template"] == "%(title)s.%(ext)s"
|
|
assert info["merged"]["save_path"] == "/default/downloads"
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.create_cookies_file")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_with_cookies_from_user(self, mock_config, mock_create_cookies, mock_presets, tmp_path: Path):
|
|
"""Test build with cookies from user."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.temp_path = str(tmp_path)
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_cookie_path = Mock()
|
|
cookie_path = tmp_path / "cookies.txt"
|
|
mock_cookie_path.__str__ = Mock(return_value=str(cookie_path))
|
|
mock_create_cookies.return_value = mock_cookie_path
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = None
|
|
|
|
item = Item(url="https://example.com/video", cookies="session_id=abc123")
|
|
|
|
cli = YTDLPCli(item=item)
|
|
command, info = cli.build()
|
|
|
|
mock_create_cookies.assert_called_once_with("session_id=abc123")
|
|
assert "--cookies" in command
|
|
assert str(cookie_path) in command
|
|
assert info["merged"]["cookie_file"] == str(cookie_path)
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_with_cookies_from_preset(self, mock_config, mock_presets):
|
|
"""Test build with cookies from preset when user doesn't provide."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.presets.schemas import Preset
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.temp_path = "/tmp"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_cookie_path = Mock()
|
|
mock_cookie_path.__str__ = Mock(return_value="/preset/cookies.txt")
|
|
|
|
preset = Mock(spec=Preset)
|
|
preset.name = "test_preset"
|
|
preset.cookies = "preset_cookies"
|
|
preset.get_cookies_file = Mock(return_value=mock_cookie_path)
|
|
preset.folder = None
|
|
preset.template = None
|
|
preset.cli = None
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = preset
|
|
|
|
item = Item(url="https://example.com/video", preset="test_preset")
|
|
|
|
cli = YTDLPCli(item=item)
|
|
command, _ = cli.build()
|
|
|
|
preset.get_cookies_file.assert_called_once_with(config=mock_config_instance)
|
|
assert "--cookies" in command
|
|
assert "/preset/cookies.txt" in command
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_with_absolute_folder_path(self, mock_config, mock_presets):
|
|
"""Test build with absolute folder path from user."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = None
|
|
|
|
# Absolute path gets leading slash stripped and joined with download_path
|
|
# This is the actual behavior: /absolute/path -> absolute/path -> /downloads/absolute/path
|
|
item = Item(url="https://example.com/video", folder="/absolute/path")
|
|
|
|
cli = YTDLPCli(item=item)
|
|
command, info = cli.build()
|
|
|
|
assert info["merged"]["save_path"] == "/downloads/absolute/path", (
|
|
"The implementation strips leading slash and joins with download_path"
|
|
)
|
|
assert "--paths" in command
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_includes_url_in_command(self, mock_config, mock_presets):
|
|
"""Test that build includes the URL in the final command."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = None
|
|
|
|
item = Item(url="https://youtube.com/watch?v=test123")
|
|
|
|
cli = YTDLPCli(item=item)
|
|
command, _ = cli.build()
|
|
|
|
assert "https://youtube.com/watch?v=test123" in command
|
|
|
|
@patch("app.features.presets.service.Presets")
|
|
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
|
def test_build_cli_args_priority_order(self, mock_config, mock_presets):
|
|
"""Test that CLI args are added in correct priority order (preset first, user last)."""
|
|
from app.library.ItemDTO import Item
|
|
from app.features.presets.schemas import Preset
|
|
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
|
|
|
mock_config_instance = Mock()
|
|
mock_config_instance.download_path = "/downloads"
|
|
mock_config_instance.output_template = "%(title)s.%(ext)s"
|
|
mock_config_instance.get_replacers.return_value = {}
|
|
mock_config.get_instance.return_value = mock_config_instance
|
|
|
|
preset = Preset(name="test_preset", cli="--format 720p --extract-audio")
|
|
|
|
mock_presets.get_instance.return_value.get.return_value = preset
|
|
|
|
item = Item(url="https://example.com/video", preset="test_preset", cli="--format best --no-playlist")
|
|
|
|
cli = YTDLPCli(item=item)
|
|
_, info = cli.build()
|
|
|
|
# User args should come after preset args in the command
|
|
# This ensures user args can override preset args
|
|
args_list = info["dict"]
|
|
preset_format_idx = args_list.index("720p") if "720p" in args_list else -1
|
|
user_format_idx = args_list.index("best") if "best" in args_list else -1
|
|
|
|
assert user_format_idx > preset_format_idx, "User's 'best' should appear after preset's '720p'"
|