diff --git a/lib/pinchflat/downloader_backends/yt_dlp/command_runner.ex b/lib/pinchflat/downloader_backends/yt_dlp/command_runner.ex new file mode 100644 index 0000000..134256f --- /dev/null +++ b/lib/pinchflat/downloader_backends/yt_dlp/command_runner.ex @@ -0,0 +1,53 @@ +defmodule Pinchflat.DownloaderBackends.YtDlp.CommandRunner do + @moduledoc """ + Runs yt-dlp commands using the `System.cmd/3` function + """ + + alias Pinchflat.Utils.StringUtils + + @doc """ + Runs a yt-dlp command and returns the output and status + + TODO: look into using a behavior for this (if I ever add other backends) + """ + def run(url, command_options, config_options \\ []) do + default_command = System.find_executable("yt-dlp") + base_command = Keyword.get(config_options, :base_command, default_command) + formatted_command_options = parse_options(command_options) ++ [url] + + case System.cmd(base_command, formatted_command_options, stderr_to_stdout: true) do + {output, 0} -> {:ok, output} + {output, status} -> {:error, output, status} + end + end + + # We want to satisfy the following behaviours: + # + # 1. If the key is an atom, convert it to a string and convert it to kebab case (for convenience) + # 2. If the key is a string, assume we want it as-is and don't convert it + # 3. If the key is accompanied by a value, append the value to the list + # 4. If the key is not accompanied by a value, assume it's a flag and PREpend it to the list + defp parse_options(command_options) do + Enum.reduce(command_options, [], &parse_option/2) + end + + defp parse_option({k, v}, acc) when is_atom(k) do + stringified_key = StringUtils.to_kebab_case(Atom.to_string(k)) + + parse_option({"--#{stringified_key}", v}, acc) + end + + defp parse_option({k, v}, acc) when is_binary(k) do + acc ++ [k, to_string(v)] + end + + defp parse_option(arg, acc) when is_atom(arg) do + stringified_arg = StringUtils.to_kebab_case(Atom.to_string(arg)) + + parse_option("--#{stringified_arg}", acc) + end + + defp parse_option(arg, acc) when is_binary(arg) do + [arg | acc] + end +end diff --git a/lib/utils/string_utils.ex b/lib/utils/string_utils.ex new file mode 100644 index 0000000..f52bcfb --- /dev/null +++ b/lib/utils/string_utils.ex @@ -0,0 +1,14 @@ +defmodule Pinchflat.Utils.StringUtils do + @moduledoc """ + Utility functions for working with strings + """ + + @doc """ + Converts a string to kebab-case (ie: `hello world` -> `hello-world`) + """ + def to_kebab_case(string) do + string + |> String.replace(~r/[\s_]/, "-") + |> String.downcase() + end +end diff --git a/test/pinchflat/downloader_backends/yt_dlp/command_runner_test.exs b/test/pinchflat/downloader_backends/yt_dlp/command_runner_test.exs new file mode 100644 index 0000000..a4a2874 --- /dev/null +++ b/test/pinchflat/downloader_backends/yt_dlp/command_runner_test.exs @@ -0,0 +1,56 @@ +defmodule Pinchflat.DownloaderBackends.YtDlp.CommandRunnerTest do + use ExUnit.Case, async: true + + alias Pinchflat.DownloaderBackends.YtDlp.CommandRunner, as: Runner + + @cmd "echo" + @video_url "https://www.youtube.com/watch?v=9bZkp7q19f0" + + describe "run/3" do + test "it returns the output and status when the command succeeds" do + assert {:ok, _output} = Runner.run(@video_url, [], base_command: @cmd) + end + + test "it converts symbol k-v arg keys to kebab case" do + assert {:ok, output} = Runner.run(@video_url, [buffer_size: 1024], base_command: @cmd) + + assert String.contains?(output, "--buffer-size 1024") + end + + test "it keeps string k-v arg keys untouched" do + assert {:ok, output} = Runner.run(@video_url, [{"--under_score", 1024}], base_command: @cmd) + + assert String.contains?(output, "--under_score 1024") + end + + test "it converts symbol arg keys to kebab case" do + assert {:ok, output} = Runner.run(@video_url, [:ignore_errors], base_command: @cmd) + + assert String.contains?(output, "--ignore-errors") + end + + test "it keeps string arg keys untouched" do + assert {:ok, output} = Runner.run(@video_url, ["-v"], base_command: @cmd) + + assert String.contains?(output, "-v") + refute String.contains?(output, "--v") + end + + test "it places arg keys (flags) at the beginning of the command" do + assert {:ok, output} = + Runner.run(@video_url, [{"--under_score", 1024}, :ignore_errors], base_command: @cmd) + + assert String.contains?(output, "--ignore-errors --under_score 1024") + end + + test "it includes the video url as the last argument" do + assert {:ok, output} = Runner.run(@video_url, [:ignore_errors], base_command: @cmd) + + assert String.contains?(output, "--ignore-errors #{@video_url}\n") + end + + test "it returns the output and status when the command fails" do + assert {:error, "", 1} = Runner.run(@video_url, [], base_command: "/bin/false") + end + end +end diff --git a/test/pinchflat/utils/string_utils_test.exs b/test/pinchflat/utils/string_utils_test.exs new file mode 100644 index 0000000..59ebbfc --- /dev/null +++ b/test/pinchflat/utils/string_utils_test.exs @@ -0,0 +1,15 @@ +defmodule Pinchflat.Utils.StringUtilsTest do + use ExUnit.Case, async: true + + alias Pinchflat.Utils.StringUtils, as: StringUtils + + describe "to_kebab_case/1" do + test "converts a space-delimited string to kebab-case" do + assert StringUtils.to_kebab_case("hello world") == "hello-world" + end + + test "converts an underscore-delimited string to kebab-case" do + assert StringUtils.to_kebab_case("hello_world") == "hello-world" + end + end +end