lib/whispex.ex

defmodule Whispex do
  @moduledoc """
  Create and execute whisper CLI commands.

  Note that adding options is backwards from using
  the whisper CLI; when using whisper CLI, you specify the options
  before each file.
  But with Whispex (this library), you add the file first, then
  add the relevant options afterward.

  ## Example

      import Whispex
      use Whispex.Options

      command =
        Whispex.new_command
        |> add_input_file("/path/to/input.avi")
        |> add_file_option(option_language("English"))
        |> add_file_option(option_output_dir("other_dir"))
        |> add_file_option(option_output_format("vtt"))
        |> add_file_option(option_model("tiny"))

      :ok = execute(command)
  """
  alias Whispex.Command
  alias Whispex.File

  @doc """
  Begin a new blank (no options) whisper command.
  """
  def new_command, do: %Command{}

  @doc """
  Add an input file to the command.
  """
  def add_input_file(%Command{files: files} = command, %File{} = file) do
    file = %File{file | type: :input}
    %Command{command | files: [file | files]}
  end

  def add_input_file(%Command{files: files} = command, file_path) when is_binary(file_path) do
    file = %File{type: :input, path: file_path}
    %Command{command | files: [file | files]}
  end

  @doc """
  Add an output file to the command.
  """
  def add_output_file(%Command{files: files} = command, %File{} = file) do
    file = %File{file | type: :output}
    %Command{command | files: [file | files]}
  end

  def add_output_file(%Command{files: files} = command, file_path) when is_binary(file_path) do
    file = %File{type: :output, path: file_path}
    %Command{command | files: [file | files]}
  end

  @doc """
  Add a per-file option to the command.

  Applies to the most recently added file.
  """
  def add_file_option(
        %Command{files: [file | files]} = command,
        option
      ) do
    file = %File{file | options: [option | file.options]}
    %Command{command | files: [file | files]}
  end

  @doc """
  Execute the command using whisper CLI.

  Returns `{:ok, output}` on success, or `{:error, {cmd_output, exit_status}}` on error.
  """
  @spec execute(command :: Command.t()) ::
          {:ok, binary()} | {:error, {Collectable.t(), exit_status :: non_neg_integer}}
  def execute(%Command{} = command) do
    {executable, args} = prepare(command)

    Rambo.run(executable, args, log: false)
    |> format_output()
  end

  @doc """
  Prepares the command to be executed, by converting the `%Command{}` into
  proper parameters to be feeded to `System.cmd/3` or `Port.open/2`.

  Under normal circumstances `Whispex.execute/1` should be used, use `prepare`
  only when converted args are needed to be feeded in a custom execution method.
  """
  @spec prepare(command :: Command.t()) :: {binary() | nil, list(binary)}
  def prepare(%Command{files: files}) do
    cmd_args = List.flatten([options_list(files)])
    {whisper_path(), cmd_args}
  end

  defp options_list(files) do
    input_files = Enum.filter(files, fn %File{type: type} -> type == :input end)
    output_files = Enum.filter(files, fn %File{type: type} -> type == :output end)
    options_list(input_files, output_files)
  end

  defp options_list(input_files, output_files, acc \\ [])
  defp options_list([], [], acc), do: List.flatten(acc)

  defp options_list(input_files, [output_file | output_files], acc) do
    acc = [File.command_arguments(output_file), output_file.path | acc]
    options_list(input_files, output_files, acc)
  end

  defp options_list([input_file | input_files], [], acc) do
    acc = [File.command_arguments(input_file), input_file.path | acc]
    options_list(input_files, [], acc)
  end

  defp format_output({:ok, %{out: stdout}}), do: {:ok, stdout}
  defp format_output({:error, reason}) when is_binary(reason), do: {:error, {reason, 1}}

  defp format_output({:error, %{err: stderr, status: exit_status}}),
    do: {:error, {stderr, exit_status}}

  # Read whisper path from config. If unspecified, assume `whisper` is in env $PATH.
  defp whisper_path do
    case Application.get_env(:whisper, :whisper_path, nil) do
      nil -> System.find_executable("whisper")
      path -> path
    end
  end
end