lib/membrane_mp4/container.ex

defmodule Membrane.MP4.Container do
  @moduledoc """
  Module for parsing and serializing MP4 files.

  Bases on MP4 structure specification from `#{inspect(__MODULE__)}.Schema`.
  """
  use Bunch
  alias __MODULE__.{ParseHelper, Schema, SerializeHelper}

  @schema Schema.schema()

  @type box_name_t :: atom
  @type field_name_t :: atom
  @type fields_t :: %{field_name_t => term | [term] | fields_t()}
  @type t :: [{box_name_t, %{content: binary} | %{fields: fields_t, children: t}}]

  @type parse_error_context_t :: [
          {:box, box_name_t}
          | {:field, field_name_t}
          | {:data, bitstring}
          | {:reason, :box_header | {:box_size, header: pos_integer, actual: pos_integer}}
        ]

  @type serialize_error_context_t :: [{:box, box_name_t} | {:field, field_name_t}]

  @doc """
  Parses binary data to MP4 according to `#{inspect(Schema)}.schema/0`.
  """
  @spec parse(binary) :: {:ok, t} | {:error, parse_error_context_t}
  def parse(data) do
    parse(data, @schema)
  end

  @doc """
  Parses binary data to MP4 according to a custom schema.
  """
  @spec parse(binary, Schema.t()) :: {:ok, t} | {:error, parse_error_context_t}
  def parse(data, schema) do
    ParseHelper.parse_boxes(data, schema, [])
  end

  @doc """
  Same as `parse/1`, raises on error.
  """
  @spec parse!(binary) :: t
  def parse!(data) do
    parse!(data, @schema)
  end

  @doc """
  Same as `parse/2`, raises on error.
  """
  @spec parse!(binary, Schema.t()) :: t
  def parse!(data, schema) do
    case ParseHelper.parse_boxes(data, schema, []) do
      {:ok, mp4} ->
        mp4

      {:error, context} ->
        raise """
        Error parsing MP4
        box: #{Keyword.get_values(context, :box) |> Enum.join(" / ")}
        field: #{Keyword.get_values(context, :field) |> Enum.join(" / ")}
        data: #{Keyword.get(context, :data) |> inspect()}
        reason: #{Keyword.get(context, :reason) |> inspect(pretty: true)}
        """
    end
  end

  @doc """
  Serializes MP4 to a binary according to `#{inspect(Schema)}.schema/0`.
  """
  @spec serialize(t) :: {:ok, binary} | {:error, serialize_error_context_t}
  def serialize(mp4) do
    serialize(mp4, @schema)
  end

  @doc """
  Serializes MP4 to a binary according to a custom schema.
  """
  @spec serialize(t, Schema.t()) :: {:ok, binary} | {:error, serialize_error_context_t}
  def serialize(mp4, schema) do
    SerializeHelper.serialize_boxes(mp4, schema)
  end

  @doc """
  Same as `serialize/1`, raises on error
  """
  @spec serialize!(t) :: binary
  def serialize!(mp4) do
    serialize!(mp4, @schema)
  end

  @doc """
  Same as `serialize/2`, raises on error
  """
  @spec serialize!(t, Schema.t()) :: binary
  def serialize!(mp4, schema) do
    case SerializeHelper.serialize_boxes(mp4, schema) do
      {:ok, data} ->
        data

      {:error, context} ->
        box = Keyword.get_values(context, :box)

        raise """
        Error serializing MP4
        box: #{Enum.join(box, " / ")}
        field: #{Keyword.get_values(context, :field) |> Enum.join(" / ")}
        box contents:
        #{get_box(mp4, box) |> inspect(pretty: true)}
        """
    end
  end

  @doc """
  Maps a path in the MP4 box tree into sequence of keys under which that
  box resides in MP4.
  """
  @spec box_path(box_name_t | [box_name_t]) :: [atom]
  def box_path(path) do
    path |> Bunch.listify() |> Enum.flat_map(&[:children, &1]) |> Enum.drop(1)
  end

  @doc """
  Gets a box from a given path in a parsed MP4.
  """
  @spec get_box(t, box_name_t | [box_name_t]) :: t
  def get_box(mp4, path) do
    Bunch.Access.get_in(mp4, box_path(path))
  end

  @doc """
  Updates a box at a given path in a parsed MP4.

  If `parameter_path` is set, a parameter within a box is updated.
  """
  @spec update_box(t, box_name_t | [box_name_t], [atom], (term -> term)) :: t
  def update_box(mp4, path, parameter_path \\ [], f) do
    Bunch.Access.update_in(mp4, box_path(path) ++ Bunch.listify(parameter_path), f)
  end
end