From ed55f3a3f73520a9232f70cdd21ec6461bbfc24d Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Thu, 6 Nov 2025 21:05:18 -0800 Subject: [PATCH] [FEATURE] `cli-to-sub` argument to convert yt-dlp args to ytdl-sub (#1376) Example usage: ``` $ ytdl-sub cli-to-sub -S vcodec:h264,res:480,acodec:m4a ytdl_options: format_sort: - vcodec:h264 - res:480 - acodec:m4a ``` --- docs/source/usage.rst | 8 ++++ src/ytdl_sub/cli/entrypoint.py | 7 ++- src/ytdl_sub/cli/parsers/cli_to_sub.py | 64 ++++++++++++++++++++++++++ src/ytdl_sub/cli/parsers/main.py | 4 ++ tests/unit/main/test_main.py | 19 +++++++- 5 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/ytdl_sub/cli/parsers/cli_to_sub.py diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 113e14f8..0db65064 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -105,3 +105,11 @@ Preview the source variables for a given URL. Helpful to create new subscription -sc, --split-chapters View source variables after splitting by chapters + +CLI to SUB Options +------------------ +Convert yt-dlp cli arguments to ytdl-sub `ytdl_options` arguments. + +.. code-block:: + + ytdl-sub cli-to-sub [YT-DLP ARGS] diff --git a/src/ytdl_sub/cli/entrypoint.py b/src/ytdl_sub/cli/entrypoint.py index eace6173..b825d45e 100644 --- a/src/ytdl_sub/cli/entrypoint.py +++ b/src/ytdl_sub/cli/entrypoint.py @@ -12,6 +12,7 @@ from yt_dlp.utils import sanitize_filename from ytdl_sub.cli.output_summary import output_summary from ytdl_sub.cli.output_transaction_log import _maybe_validate_transaction_log_file from ytdl_sub.cli.output_transaction_log import output_transaction_log +from ytdl_sub.cli.parsers.cli_to_sub import print_cli_to_sub from ytdl_sub.cli.parsers.dl import DownloadArgsParser from ytdl_sub.cli.parsers.main import DEFAULT_CONFIG_FILE_NAME from ytdl_sub.cli.parsers.main import parser @@ -206,6 +207,10 @@ def main() -> List[Subscription]: args, extra_args = parser.parse_known_args() + if args.subparser == "cli-to-sub": + print_cli_to_sub(args=extra_args) + return [] + # Load the config if args.config: config = ConfigFile.from_file_path(args.config) @@ -263,7 +268,7 @@ def main() -> List[Subscription]: _view_url_from_cli(config=config, url=args.url, split_chapters=args.split_chapters) ) else: - raise ValidationException("Must provide one of the commands: sub, dl, view") + raise ValidationException("Must provide one of the commands: sub, dl, view, cli-to-sub") if not args.suppress_transaction_log: output_transaction_log( diff --git a/src/ytdl_sub/cli/parsers/cli_to_sub.py b/src/ytdl_sub/cli/parsers/cli_to_sub.py new file mode 100644 index 00000000..d4190042 --- /dev/null +++ b/src/ytdl_sub/cli/parsers/cli_to_sub.py @@ -0,0 +1,64 @@ +from typing import List + +import yt_dlp +import yt_dlp.options + +from ytdl_sub.utils.logger import Logger +from ytdl_sub.utils.yaml import dump_yaml + +logger = Logger.get() + +# pylint: disable=missing-function-docstring + +############################################################## +# --- BEGIN ---- +# Copy of https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py + +create_parser = yt_dlp.options.create_parser + + +def parse_patched_options(opts): + + patched_parser = create_parser() + patched_parser.defaults.update( + { + "ignoreerrors": False, + "retries": 0, + "fragment_retries": 0, + "extract_flat": False, + "concat_playlist": "never", + "update_self": False, + } + ) + yt_dlp.options.create_parser = lambda: patched_parser + try: + return yt_dlp.parse_options(opts) + finally: + yt_dlp.options.create_parser = create_parser + + +default_opts = parse_patched_options([]).ydl_opts + + +def cli_to_api(opts, cli_defaults=False): + opts = (yt_dlp.parse_options if cli_defaults else parse_patched_options)(opts).ydl_opts + + diff = {k: v for k, v in opts.items() if default_opts[k] != v} + if "postprocessors" in diff: + diff["postprocessors"] = [ + pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"] + ] + return diff + + +# --- END ---- +############################################################## + + +def print_cli_to_sub(args: List[str]) -> None: + api_args = cli_to_api(args) + if not api_args: + logger.info("Does not resolve to any yt-dlp args") + return + + print(dump_yaml({"ytdl_options": api_args})) diff --git a/src/ytdl_sub/cli/parsers/main.py b/src/ytdl_sub/cli/parsers/main.py index daa4d5ea..aa26b591 100644 --- a/src/ytdl_sub/cli/parsers/main.py +++ b/src/ytdl_sub/cli/parsers/main.py @@ -221,3 +221,7 @@ view_parser.add_argument( help="View source variables after splitting by chapters", ) view_parser.add_argument("url", help="URL to view source variables for") + +################################################################################################### +# CLI-TO-SUB PARSER +cli_to_sub_parser = subparsers.add_parser("cli-to-sub") diff --git a/tests/unit/main/test_main.py b/tests/unit/main/test_main.py index b9a6b07d..32ff6d57 100644 --- a/tests/unit/main/test_main.py +++ b/tests/unit/main/test_main.py @@ -244,7 +244,9 @@ def test_no_positional_arg_command(mock_sys_exit, tv_show_config_path): main() assert mock_error.call_count == 1 - assert mock_error.call_args.args[0] == "Must provide one of the commands: sub, dl, view" + assert mock_error.call_args.args[0] == ( + "Must provide one of the commands: sub, dl, view, cli-to-sub" + ) def test_bad_config_path(mock_sys_exit): @@ -264,3 +266,18 @@ def test_bad_config_path(mock_sys_exit): "The config file 'does_not_exist.yaml' could not be found. " "Did you set --config correctly?" ) + + +def test_cli_to_sub(mock_sys_exit, capsys): + with ( + mock_sys_exit(expected_exit_code=0), + patch.object( + sys, + "argv", + ["ytdl-sub", "cli-to-sub", "--mark-watched", "--mtime"], + ), + ): + main() + + captured = capsys.readouterr() + assert captured.out == ("ytdl_options:\n mark_watched: true\n updatetime: true\n\n")