Added apprise command runner

This commit is contained in:
Kieran Eglin 2024-04-08 11:14:44 -07:00
parent aed36acfaa
commit b71105d7bf
No known key found for this signature in database
GPG key ID: 193984967FCF432D
13 changed files with 203 additions and 78 deletions

View file

@ -3,6 +3,7 @@ import Config
config :pinchflat, config :pinchflat,
# Specifying backend data here makes mocking and local testing SUPER easy # 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"]), 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"]), media_directory: Path.join([System.tmp_dir!(), "test", "media"]),
metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]), metadata_directory: Path.join([System.tmp_dir!(), "test", "metadata"]),
tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]), tmpfile_directory: Path.join([System.tmp_dir!(), "test", "tmpfiles"]),

View file

@ -14,7 +14,6 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.YtDlp.CommandRunner
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Filesystem.FilesystemHelpers
def start_link(opts \\ []) do def start_link(opts \\ []) do
@ -56,15 +55,25 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
filepath = Path.join(base_dir, "cookies.txt") filepath = Path.join(base_dir, "cookies.txt")
if !File.exists?(filepath) do 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, "") FilesystemHelpers.write_p!(filepath, "")
end end
end end
defp apply_default_settings do 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(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
end end

View file

@ -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

View file

@ -5,16 +5,39 @@ defmodule Pinchflat.Notifications.CommandRunner do
require Logger require Logger
alias Pinchflat.Utils.CliUtils
alias Pinchflat.Utils.FunctionUtils alias Pinchflat.Utils.FunctionUtils
alias Pinchflat.Notifications.AppriseCommandRunner
@behaviour AppriseCommandRunner
@doc """ @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 end
# TODO: test @doc """
# TODO: add behaviour Returns the version of apprise as a string.
Returns {:ok, binary()} | {:error, binary()}
"""
@impl AppriseCommandRunner
def version do def version do
case System.cmd(backend_executable(), ["--version"]) do case System.cmd(backend_executable(), ["--version"]) do
{output, 0} -> {output, 0} ->

View file

@ -10,6 +10,7 @@ defmodule Pinchflat.Settings.Setting do
:onboarding, :onboarding,
:pro_enabled, :pro_enabled,
:yt_dlp_version, :yt_dlp_version,
:apprise_version,
:apprise_server :apprise_server
] ]
@ -22,6 +23,7 @@ defmodule Pinchflat.Settings.Setting do
field :onboarding, :boolean, default: true field :onboarding, :boolean, default: true
field :pro_enabled, :boolean, default: false field :pro_enabled, :boolean, default: false
field :yt_dlp_version, :string field :yt_dlp_version, :string
field :apprise_version, :string
field :apprise_server, :string field :apprise_server, :string
end end

View file

@ -1,15 +1,28 @@
defmodule Pinchflat.Utils.CliUtils do 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 def parse_options(command_opts) do
Enum.reduce(command_opts, [], &parse_option/2) command_opts
|> List.wrap()
|> Enum.reduce([], &parse_option/2)
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_option({k, v}, acc) when is_atom(k) do defp parse_option({k, v}, acc) when is_atom(k) do
stringified_key = StringUtils.to_kebab_case(Atom.to_string(k)) stringified_key = StringUtils.to_kebab_case(Atom.to_string(k))
@ -21,7 +34,10 @@ defmodule Pinchflat.Utils.CliUtils do
end end
defp parse_option(arg, acc) when is_atom(arg) do 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) parse_option("--#{stringified_arg}", acc)
end end

View file

@ -5,9 +5,9 @@ defmodule Pinchflat.YtDlp.CommandRunner do
require Logger require Logger
alias Pinchflat.Utils.StringUtils alias Pinchflat.Utils.CliUtils
alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils
alias Pinchflat.YtDlp.YtDlpCommandRunner alias Pinchflat.YtDlp.YtDlpCommandRunner
alias Pinchflat.Filesystem.FilesystemHelpers, as: FSUtils
@behaviour YtDlpCommandRunner @behaviour YtDlpCommandRunner
@ -32,7 +32,7 @@ defmodule Pinchflat.YtDlp.CommandRunner do
output_filepath = generate_output_filepath(addl_opts) output_filepath = generate_output_filepath(addl_opts)
print_to_file_opts = [{:print_to_file, output_template}, output_filepath] print_to_file_opts = [{:print_to_file, output_template}, output_filepath]
cookie_opts = build_cookie_options() 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, " ")}") Logger.info("[yt-dlp] called with: #{Enum.join(formatted_command_opts, " ")}")
@ -48,6 +48,11 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
end end
@doc """
Returns the version of yt-dlp as a string
Returns {:ok, binary()} | {:error, binary()}
"""
@impl YtDlpCommandRunner @impl YtDlpCommandRunner
def version do def version do
command = backend_executable() command = backend_executable()
@ -81,36 +86,6 @@ defmodule Pinchflat.YtDlp.CommandRunner do
end end
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 defp backend_executable do
Application.get_env(:pinchflat, :yt_dlp_executable) Application.get_env(:pinchflat, :yt_dlp_executable)
end end

View file

@ -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

View file

@ -1,11 +1,19 @@
defmodule Pinchflat.Boot.PreJobStartupTasksTest do defmodule Pinchflat.Boot.PreJobStartupTasksTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Mox
import Pinchflat.JobFixtures import Pinchflat.JobFixtures
alias Pinchflat.Settings alias Pinchflat.Settings
alias Pinchflat.Boot.PreJobStartupTasks 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 describe "reset_executing_jobs" do
test "resets executing jobs" do test "resets executing jobs" do
job = job_fixture() job = job_fixture()
@ -13,7 +21,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
assert Repo.reload!(job).state == "executing" assert Repo.reload!(job).state == "executing"
PreJobStartupTasks.start_link() PreJobStartupTasks.init(%{})
assert Repo.reload!(job).state == "retryable" assert Repo.reload!(job).state == "retryable"
end end
@ -27,21 +35,31 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
refute File.exists?(filepath) refute File.exists?(filepath)
PreJobStartupTasks.start_link() PreJobStartupTasks.init(%{})
assert File.exists?(filepath) assert File.exists?(filepath)
end end
end end
describe "apply_default_settings" do describe "apply_default_settings" do
test "sets default settings" do test "sets yt_dlp version" do
Settings.set(yt_dlp_version: nil) Settings.set(yt_dlp_version: nil)
refute Settings.get!(:yt_dlp_version) refute Settings.get!(:yt_dlp_version)
PreJobStartupTasks.start_link() PreJobStartupTasks.init(%{})
assert Settings.get!(:yt_dlp_version) assert Settings.get!(:yt_dlp_version)
end 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
end end

View file

@ -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

View file

@ -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

View file

@ -17,31 +17,6 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
assert {:ok, _output} = Runner.run(@media_url, [], "") assert {:ok, _output} = Runner.run(@media_url, [], "")
end 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 test "it includes the media url as the first argument" do
assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "") assert {:ok, output} = Runner.run(@media_url, [:ignore_errors], "")

View file

@ -1,6 +1,9 @@
Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner) Mox.defmock(YtDlpRunnerMock, for: Pinchflat.YtDlp.YtDlpCommandRunner)
Application.put_env(:pinchflat, :yt_dlp_runner, YtDlpRunnerMock) 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) Mox.defmock(HTTPClientMock, for: Pinchflat.HTTP.HTTPBehaviour)
Application.put_env(:pinchflat, :http_client, HTTPClientMock) Application.put_env(:pinchflat, :http_client, HTTPClientMock)