lib/ffprobe.ex

defmodule FFprobe do
  @moduledoc """
  Execute `ffprobe` CLI commands.

  > `ffprobe` is a simple multimedia streams analyzer. You can use it to output
  all kinds of information about an input including duration, frame rate, frame size, etc.
  It is also useful for gathering specific information about an input to be used in a script.

  (from https://trac.ffmpeg.org/wiki/FFprobeTips)
  """

  @type format_map :: %{}
  @type streams_list :: [%{}]

  @doc """
  Get the duration in seconds, as a float.

  If no duration (e.g., a still image), returns `:no_duration`.
  If the file does not exist, returns { :error, :no_such_file }
  """
  @spec duration(binary | format_map) :: float | :no_duration | {:error, :no_such_file}
  def duration(file_path) when is_binary(file_path) do
    case format(file_path) do
      {:ok, format} ->
        duration(format)

      {:error, :invalid_file} ->
        duration(%{"duration" => nil})

      error ->
        error
    end
  end

  def duration(format_map) when is_map(format_map) do
    case format_map["duration"] do
      nil -> :no_duration
      result -> Float.parse(result) |> elem(0)
    end
  end

  @doc """
  Get a list of formats for the file.

  If the file does not exist, returns `{:error, :no_such_file}`.
  If the file is a non media file, returns `{:error, :invalid_file}`.
  """
  @spec format_names(binary | format_map) ::
          {:ok, [binary]} | {:error, :invalid_file} | {:error, :no_such_file}
  def format_names(file_path) when is_binary(file_path) do
    case format(file_path) do
      {:ok, format} ->
        format_names(format)

      error ->
        error
    end
  end

  def format_names(format_map) when is_map(format_map) do
    {:ok, String.split(format_map["format_name"], ",")}
  end

  @doc """
  Get the "format" map, containing general info for the specified file,
  such as number of streams, duration, file size, and more.

  If the file does not exist, returns `{:error, :no_such_file}`.
  If the file is a non media file, returns `{:error, :invalid_file}`.
  """
  @spec format(binary) :: {:ok, format_map} | {:error, :invalid_file} | {:error, :no_such_file}
  def format(file_path) do
    cmd_args = ["-print_format", "json", "-show_format", file_path]

    case Rambo.run(ffprobe_path(), cmd_args, log: false) do
      {:ok, %{out: result}} ->
        format =
          result
          |> Jason.decode!()
          |> Map.get("format", %{})

        {:ok, format}

      {:error, %{err: result}} ->
        file_error(file_path, result)
    end
  end

  @doc """
  Get a list a of streams from the file.

  If the file does not exist, returns `{:error, :no_such_file}`.
  If the file is a non media file, returns `{:error, :invalid_file}`.
  """
  @spec streams(binary) :: {:ok, streams_list} | {:error, :invalid_file} | {:error, :no_such_file}
  def streams(file_path) do
    cmd_args = ["-print_format", "json", "-show_streams", file_path]

    case Rambo.run(ffprobe_path(), cmd_args, log: false) do
      {:ok, %{out: result}} ->
        streams =
          result
          |> Jason.decode!()
          |> Map.get("streams", [])

        {:ok, streams}

      {:error, %{err: result}} ->
        file_error(file_path, result)
    end
  end

  defp file_error(file_path, error_text) do
    cond do
      File.exists?(file_path) -> {:error, :invalid_file}
      String.contains?(error_text, "Invalid data found when processing input") -> {:error, :invalid_file}
      String.contains?(error_text, "404 Not Found") -> {:error, :no_such_file}
      true -> {:error, :no_such_file}
    end
  end

  # Read ffprobe path from config. If unspecified, check if `ffprobe` is in env $PATH.
  # If it is not, then raise a error.
  defp ffprobe_path do
    case Application.get_env(:ffmpex, :ffprobe_path, nil) do
      nil ->
        case System.find_executable("ffprobe") do
          nil ->
            raise "FFmpeg not installed"

          path ->
            path
        end

      path ->
        path
    end
  end
end