From b71105d7bfe0100bc31f3e365aebec2464bca916 Mon Sep 17 00:00:00 2001 From: Kieran Eglin Date: Mon, 8 Apr 2024 11:14:44 -0700 Subject: [PATCH] Added apprise command runner --- config/test.exs | 1 + lib/pinchflat/boot/pre_job_startup_tasks.ex | 15 ++++- .../notifications/apprise_command_runner.ex | 12 ++++ lib/pinchflat/notifications/command_runner.ex | 31 ++++++++-- lib/pinchflat/settings/setting.ex | 2 + lib/pinchflat/utils/cli_utils.ex | 34 ++++++++--- lib/pinchflat/yt_dlp/command_runner.ex | 41 +++---------- ...181121_add_apprise_version_to_settings.exs | 9 +++ .../boot/pre_job_startup_tasks_test.exs | 26 ++++++-- .../notifications/command_runner_test.exs | 59 +++++++++++++++++++ test/pinchflat/utils/cli_utils_test.exs | 23 ++++++++ test/pinchflat/yt_dlp/command_runner_test.exs | 25 -------- test/test_helper.exs | 3 + 13 files changed, 203 insertions(+), 78 deletions(-) create mode 100644 lib/pinchflat/notifications/apprise_command_runner.ex create mode 100644 priv/repo/migrations/20240408181121_add_apprise_version_to_settings.exs create mode 100644 test/pinchflat/notifications/command_runner_test.exs create mode 100644 test/pinchflat/utils/cli_utils_test.exs diff --git a/config/test.exs b/config/test.exs index 6c7409e..ce61044 100644 --- a/config/test.exs +++ b/config/test.exs @@ -3,6 +3,7 @@ import Config config :pinchflat, # Specifying backend data here makes mocking and local testing SUPER easy yt_dlp_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), + apprise_executable: Path.join([File.cwd!(), "/test/support/scripts/yt-dlp-mocks/repeater.sh"]), media_directory: Path.join([System.tmp_dir!(), "test", "media"]), metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]), tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]), diff --git a/lib/pinchflat/boot/pre_job_startup_tasks.ex b/lib/pinchflat/boot/pre_job_startup_tasks.ex index d4f4168..74b96d3 100644 --- a/lib/pinchflat/boot/pre_job_startup_tasks.ex +++ b/lib/pinchflat/boot/pre_job_startup_tasks.ex @@ -14,7 +14,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do alias Pinchflat.Repo alias Pinchflat.Settings - alias Pinchflat.YtDlp.CommandRunner alias Pinchflat.Filesystem.FilesystemHelpers def start_link(opts \\ []) do @@ -56,15 +55,25 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do filepath = Path.join(base_dir, "cookies.txt") if !File.exists?(filepath) do - Logger.info("Cookies does not exist - creating it") + Logger.info("yt-dlp cookie file does not exist - creating it") FilesystemHelpers.write_p!(filepath, "") end end defp apply_default_settings do - {:ok, yt_dlp_version} = CommandRunner.version() + {:ok, yt_dlp_version} = yt_dlp_runner().version() + {:ok, apprise_version} = apprise_runner().version() Settings.set(yt_dlp_version: yt_dlp_version) + Settings.set(apprise_version: apprise_version) + end + + defp yt_dlp_runner do + Application.get_env(:pinchflat, :yt_dlp_runner) + end + + defp apprise_runner do + Application.get_env(:pinchflat, :apprise_runner) end end diff --git a/lib/pinchflat/notifications/apprise_command_runner.ex b/lib/pinchflat/notifications/apprise_command_runner.ex new file mode 100644 index 0000000..13ad327 --- /dev/null +++ b/lib/pinchflat/notifications/apprise_command_runner.ex @@ -0,0 +1,12 @@ +defmodule Pinchflat.Notifications.AppriseCommandRunner do + @moduledoc """ + A behaviour for running CLI commands against a notification backend (apprise). + + Used so we can implement Mox for testing without actually running the + apprise command. + """ + + @callback run(binary(), keyword()) :: :ok | {:error, binary()} + @callback run(List.t(), keyword()) :: :ok | {:error, binary()} + @callback version() :: {:ok, binary()} | {:error, binary()} +end diff --git a/lib/pinchflat/notifications/command_runner.ex b/lib/pinchflat/notifications/command_runner.ex index 9415d39..3a3005f 100644 --- a/lib/pinchflat/notifications/command_runner.ex +++ b/lib/pinchflat/notifications/command_runner.ex @@ -5,16 +5,39 @@ defmodule Pinchflat.Notifications.CommandRunner do require Logger + alias Pinchflat.Utils.CliUtils alias Pinchflat.Utils.FunctionUtils + alias Pinchflat.Notifications.AppriseCommandRunner + + @behaviour AppriseCommandRunner @doc """ - # TODO + Runs an apprise command and returns the string output (often just ""). + Can take a single server string or a list of servers as well as additional + arguments to pass to the command. + + Returns {:ok, binary()} | {:error, binary()}. """ - def run() do + @impl AppriseCommandRunner + def run(endpoints, args) do + endpoints = List.wrap(endpoints) + parsed_args = CliUtils.parse_options(args) + + case System.cmd(backend_executable(), parsed_args ++ endpoints) do + {output, 0} -> + {:ok, String.trim(output)} + + {output, _} -> + {:error, String.trim(output)} + end end - # TODO: test - # TODO: add behaviour + @doc """ + Returns the version of apprise as a string. + + Returns {:ok, binary()} | {:error, binary()} + """ + @impl AppriseCommandRunner def version do case System.cmd(backend_executable(), ["--version"]) do {output, 0} -> diff --git a/lib/pinchflat/settings/setting.ex b/lib/pinchflat/settings/setting.ex index e7a89bb..f9eb386 100644 --- a/lib/pinchflat/settings/setting.ex +++ b/lib/pinchflat/settings/setting.ex @@ -10,6 +10,7 @@ defmodule Pinchflat.Settings.Setting do :onboarding, :pro_enabled, :yt_dlp_version, + :apprise_version, :apprise_server ] @@ -22,6 +23,7 @@ defmodule Pinchflat.Settings.Setting do field :onboarding, :boolean, default: true field :pro_enabled, :boolean, default: false field :yt_dlp_version, :string + field :apprise_version, :string field :apprise_server, :string end diff --git a/lib/pinchflat/utils/cli_utils.ex b/lib/pinchflat/utils/cli_utils.ex index a0a567b..4e2a488 100644 --- a/lib/pinchflat/utils/cli_utils.ex +++ b/lib/pinchflat/utils/cli_utils.ex @@ -1,15 +1,28 @@ defmodule Pinchflat.Utils.CliUtils do - # TODO: test + @moduledoc """ + Utility methods for working with CLI executables + """ + + alias Pinchflat.Utils.StringUtils + + @doc """ + Parses a list of command options into a list of strings suitable for passing to + `System.cmd/3`. + + 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 + + Returns [binary()] + """ def parse_options(command_opts) do - Enum.reduce(command_opts, [], &parse_option/2) + command_opts + |> List.wrap() + |> Enum.reduce([], &parse_option/2) 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_option({k, v}, acc) when is_atom(k) do stringified_key = StringUtils.to_kebab_case(Atom.to_string(k)) @@ -21,7 +34,10 @@ defmodule Pinchflat.Utils.CliUtils do end defp parse_option(arg, acc) when is_atom(arg) do - stringified_arg = StringUtils.to_kebab_case(Atom.to_string(arg)) + stringified_arg = + arg + |> Atom.to_string() + |> StringUtils.to_kebab_case() parse_option("--#{stringified_arg}", acc) end diff --git a/lib/pinchflat/yt_dlp/command_runner.ex b/lib/pinchflat/yt_dlp/command_runner.ex index fa7c6a9..7fc19bf 100644 --- a/lib/pinchflat/yt_dlp/command_runner.ex +++ b/lib/pinchflat/yt_dlp/command_runner.ex @@ -5,9 +5,9 @@ defmodule Pinchflat.YtDlp.CommandRunner do require Logger - alias Pinchflat.Utils.StringUtils - alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils + alias Pinchflat.Utils.CliUtils alias Pinchflat.YtDlp.YtDlpCommandRunner + alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils @behaviour YtDlpCommandRunner @@ -32,7 +32,7 @@ defmodule Pinchflat.YtDlp.CommandRunner do output_filepath = generate_output_filepath(addl_opts) print_to_file_opts = [{:print_to_file, output_template}, output_filepath] cookie_opts = build_cookie_options() - formatted_command_opts = [url] ++ parse_options(command_opts ++ print_to_file_opts ++ cookie_opts) + formatted_command_opts = [url] ++ CliUtils.parse_options(command_opts ++ print_to_file_opts ++ cookie_opts) Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}") @@ -48,6 +48,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do end end + @doc """ + Returns the version of yt-dlp as a string + + Returns {:ok, binary()} | {:error, binary()} + """ @impl YtDlpCommandRunner def version do command = backend_executable() @@ -81,36 +86,6 @@ defmodule Pinchflat.YtDlp.CommandRunner do 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_opts) do - Enum.reduce(command_opts, [], &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 - acc ++ [arg] - end - defp backend_executable do Application.get_env(:pinchflat, :yt_dlp_executable) end diff --git a/priv/repo/migrations/20240408181121_add_apprise_version_to_settings.exs b/priv/repo/migrations/20240408181121_add_apprise_version_to_settings.exs new file mode 100644 index 0000000..2a624b4 --- /dev/null +++ b/priv/repo/migrations/20240408181121_add_apprise_version_to_settings.exs @@ -0,0 +1,9 @@ +defmodule Pinchflat.Repo.Migrations.AddAppriseVersionToSettings do + use Ecto.Migration + + def change do + alter table(:settings) do + add :apprise_version, :string + end + end +end diff --git a/test/pinchflat/boot/pre_job_startup_tasks_test.exs b/test/pinchflat/boot/pre_job_startup_tasks_test.exs index f2cdd66..8fd81bb 100644 --- a/test/pinchflat/boot/pre_job_startup_tasks_test.exs +++ b/test/pinchflat/boot/pre_job_startup_tasks_test.exs @@ -1,11 +1,19 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do use Pinchflat.DataCase + import Mox import Pinchflat.JobFixtures alias Pinchflat.Settings alias Pinchflat.Boot.PreJobStartupTasks + setup do + stub(YtDlpRunnerMock, :version, fn -> {:ok, "1"} end) + stub(AppriseRunnerMock, :version, fn -> {:ok, "2"} end) + + :ok + end + describe "reset_executing_jobs" do test "resets executing jobs" do job = job_fixture() @@ -13,7 +21,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do assert Repo.reload!(job).state == "executing" - PreJobStartupTasks.start_link() + PreJobStartupTasks.init(%{}) assert Repo.reload!(job).state == "retryable" end @@ -27,21 +35,31 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do refute File.exists?(filepath) - PreJobStartupTasks.start_link() + PreJobStartupTasks.init(%{}) assert File.exists?(filepath) end end describe "apply_default_settings" do - test "sets default settings" do + test "sets yt_dlp version" do Settings.set(yt_dlp_version: nil) refute Settings.get!(:yt_dlp_version) - PreJobStartupTasks.start_link() + PreJobStartupTasks.init(%{}) assert Settings.get!(:yt_dlp_version) end + + test "sets apprise version" do + Settings.set(apprise_version: nil) + + refute Settings.get!(:apprise_version) + + PreJobStartupTasks.init(%{}) + + assert Settings.get!(:apprise_version) + end end end diff --git a/test/pinchflat/notifications/command_runner_test.exs b/test/pinchflat/notifications/command_runner_test.exs new file mode 100644 index 0000000..658dfed --- /dev/null +++ b/test/pinchflat/notifications/command_runner_test.exs @@ -0,0 +1,59 @@ +defmodule Pinchflat.Notifications.CommandRunnerTest do + use ExUnit.Case, async: true + + alias Pinchflat.Notifications.CommandRunner, as: Runner + + @original_executable Application.compile_env(:pinchflat, :apprise_executable) + + setup do + on_exit(&reset_executable/0) + end + + describe "run/2" do + test "returns :ok when the command succeeds" do + assert {:ok, _} = Runner.run("", []) + end + + test "includes the servers as the first argument" do + assert {:ok, output} = Runner.run(["server_1", "server_2"], []) + + assert String.contains?(output, "server_1 server_2") + end + + test "lets you pass a single server as a string" do + assert {:ok, output} = Runner.run("server_1", []) + + assert String.contains?(output, "server_1") + end + + test "passes all arguments to the command" do + assert {:ok, output} = Runner.run("", ["--dry-run"]) + + assert String.contains?(output, "--dry-run") + end + + test "returns the output when the command fails" do + wrap_executable("/bin/false", fn -> + assert {:error, ""} = Runner.run("", []) + end) + end + end + + describe "version/0" do + test "adds the version arg" do + assert {:ok, output} = Runner.version() + + assert String.contains?(output, "--version") + end + end + + defp wrap_executable(new_executable, fun) do + Application.put_env(:pinchflat, :apprise_executable, new_executable) + fun.() + reset_executable() + end + + def reset_executable do + Application.put_env(:pinchflat, :apprise_executable, @original_executable) + end +end diff --git a/test/pinchflat/utils/cli_utils_test.exs b/test/pinchflat/utils/cli_utils_test.exs new file mode 100644 index 0000000..b053158 --- /dev/null +++ b/test/pinchflat/utils/cli_utils_test.exs @@ -0,0 +1,23 @@ +defmodule Pinchflat.Utils.CliUtilsTest do + use ExUnit.Case, async: true + + alias Pinchflat.Utils.CliUtils + + describe "parse_options/1" do + test "it converts symbol k-v arg keys to kebab case" do + assert ["--buffer-size", "1024"] = CliUtils.parse_options(buffer_size: 1024) + end + + test "it keeps string k-v arg keys untouched" do + assert ["--under_score", "1024"] = CliUtils.parse_options({"--under_score", 1024}) + end + + test "it converts symbol arg keys to kebab case" do + assert ["--ignore-errors"] = CliUtils.parse_options(:ignore_errors) + end + + test "it keeps string arg keys untouched" do + assert ["-v"] = CliUtils.parse_options("-v") + end + end +end diff --git a/test/pinchflat/yt_dlp/command_runner_test.exs b/test/pinchflat/yt_dlp/command_runner_test.exs index 889b030..ac06a48 100644 --- a/test/pinchflat/yt_dlp/command_runner_test.exs +++ b/test/pinchflat/yt_dlp/command_runner_test.exs @@ -17,31 +17,6 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do assert {:ok, _output} = Runner.run(@media_url, [], "") end - test "it converts symbol k-v arg keys to kebab case" do - assert {:ok, output} = Runner.run(@media_url, [buffer_size: 1024], "") - - assert String.contains?(output, "--buffer-size 1024") - end - - test "it keeps string k-v arg keys untouched" do - assert {:ok, output} = Runner.run(@media_url, [{"--under_score", 1024}], "") - - assert String.contains?(output, "--under_score 1024") - end - - test "it converts symbol arg keys to kebab case" do - assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "") - - assert String.contains?(output, "--ignore-errors") - end - - test "it keeps string arg keys untouched" do - assert {:ok, output} = Runner.run(@media_url, ["-v"], "") - - assert String.contains?(output, "-v") - refute String.contains?(output, "--v") - end - test "it includes the media url as the first argument" do assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "") diff --git a/test/test_helper.exs b/test/test_helper.exs index e5f74bb..e6cd91e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,6 +1,9 @@ Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner) Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock) +Mox.defmock(AppriseRunnerMock, for: Pinchflat.Notifications.AppriseCommandRunner) +Application.put_env(:pinchflat, :apprise_runner, AppriseRunnerMock) + Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour) Application.put_env(:pinchflat, :http_client, HTTPClientMock)