Skip to main content

lib/jido/chat/media.ex

defmodule Jido.Chat.Media do
  @moduledoc """
  Normalized media entry used in `Jido.Chat.Incoming`.
  """

  alias Jido.Chat.Content.{Audio, File, Image, Video}

  @schema Zoi.struct(
            __MODULE__,
            %{
              kind: Zoi.enum([:image, :audio, :video, :file]) |> Zoi.default(:file),
              url: Zoi.string() |> Zoi.nullish(),
              media_type: Zoi.string() |> Zoi.nullish(),
              filename: Zoi.string() |> Zoi.nullish(),
              size_bytes: Zoi.integer() |> Zoi.nullish(),
              width: Zoi.integer() |> Zoi.nullish(),
              height: Zoi.integer() |> Zoi.nullish(),
              duration: Zoi.integer() |> Zoi.nullish(),
              metadata: Zoi.map() |> Zoi.default(%{})
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))
  @type input :: t() | Image.t() | Audio.t() | Video.t() | File.t() | map() | String.t()

  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @doc "Returns the Zoi schema for Media."
  def schema, do: @schema

  @doc "Creates a media struct from map input."
  def new(%__MODULE__{} = media), do: media

  def new(attrs) when is_map(attrs),
    do: attrs |> normalize_map() |> then(&Jido.Chat.Schema.parse!(__MODULE__, @schema, &1))

  @doc "Normalizes supported attachment inputs into a canonical media struct."
  @spec normalize(input()) :: t()
  def normalize(%__MODULE__{} = media), do: media

  def normalize(%Image{} = image) do
    new(%{
      kind: :image,
      url: image.url,
      media_type: image.media_type,
      width: image.width,
      height: image.height,
      metadata: compact_metadata(%{data: image.data, alt_text: image.alt_text})
    })
  end

  def normalize(%Audio{} = audio) do
    new(%{
      kind: :audio,
      url: audio.url,
      media_type: audio.media_type,
      duration: audio.duration,
      metadata: compact_metadata(%{data: audio.data, transcript: audio.transcript})
    })
  end

  def normalize(%Video{} = video) do
    new(%{
      kind: :video,
      url: video.url,
      media_type: video.media_type,
      width: video.width,
      height: video.height,
      duration: video.duration,
      metadata:
        compact_metadata(%{
          data: video.data,
          thumbnail_url: video.thumbnail_url
        })
    })
  end

  def normalize(%File{} = file) do
    new(%{
      kind: infer_kind(file.media_type, file.filename, file.url),
      url: file.url,
      media_type: file.media_type,
      filename: file.filename,
      size_bytes: file.size,
      metadata: compact_metadata(%{data: file.data})
    })
  end

  def normalize(attrs) when is_map(attrs), do: new(attrs)

  def normalize(reference) when is_binary(reference) do
    new(%{
      kind: infer_kind(nil, filename_from_reference(reference), reference),
      url: if(remote_reference?(reference), do: reference, else: nil),
      filename: filename_from_reference(reference),
      metadata:
        compact_metadata(%{
          path: if(remote_reference?(reference), do: nil, else: reference)
        })
    })
  end

  @doc "Normalizes a list of attachments into canonical media structs."
  @spec normalize_many([input()]) :: [t()]
  def normalize_many(media) when is_list(media), do: Enum.map(media, &normalize/1)

  defp normalize_map(attrs) do
    metadata =
      attrs[:metadata] || attrs["metadata"] ||
        %{}
        |> merge_extra_metadata(attrs)

    media_type =
      attrs[:media_type] || attrs["media_type"] || attrs[:mime_type] || attrs["mime_type"]

    filename = attrs[:filename] || attrs["filename"] || attrs[:name] || attrs["name"]
    url = attrs[:url] || attrs["url"]

    %{
      kind:
        normalize_kind(attrs[:kind] || attrs["kind"] || attrs[:type] || attrs["type"]) ||
          infer_kind(media_type, filename, url),
      url: url,
      media_type: media_type,
      filename: filename,
      size_bytes: attrs[:size_bytes] || attrs["size_bytes"] || attrs[:size] || attrs["size"],
      width: attrs[:width] || attrs["width"],
      height: attrs[:height] || attrs["height"],
      duration: attrs[:duration] || attrs["duration"],
      metadata: metadata
    }
  end

  defp merge_extra_metadata(metadata, attrs) do
    metadata
    |> maybe_put_metadata(:data, attrs[:data] || attrs["data"])
    |> maybe_put_metadata(:alt_text, attrs[:alt_text] || attrs["alt_text"])
    |> maybe_put_metadata(:transcript, attrs[:transcript] || attrs["transcript"])
    |> maybe_put_metadata(:thumbnail_url, attrs[:thumbnail_url] || attrs["thumbnail_url"])
    |> maybe_put_metadata(:path, attrs[:path] || attrs["path"])
  end

  defp maybe_put_metadata(metadata, _key, nil), do: metadata
  defp maybe_put_metadata(metadata, key, value), do: Map.put(metadata, key, value)

  defp compact_metadata(entries) do
    entries
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
    |> Map.new()
  end

  defp normalize_kind(kind) when kind in [:image, :audio, :video, :file], do: kind

  defp normalize_kind(kind) when is_binary(kind) do
    case kind do
      "image" -> :image
      "audio" -> :audio
      "video" -> :video
      "file" -> :file
      _ -> nil
    end
  end

  defp normalize_kind(_kind), do: nil

  defp infer_kind(media_type, filename, url) do
    cond do
      is_binary(media_type) and String.starts_with?(media_type, "image/") -> :image
      is_binary(media_type) and String.starts_with?(media_type, "audio/") -> :audio
      is_binary(media_type) and String.starts_with?(media_type, "video/") -> :video
      extension_kind(filename || url) != :file -> extension_kind(filename || url)
      true -> :file
    end
  end

  defp extension_kind(nil), do: :file

  defp extension_kind(value) when is_binary(value) do
    case value |> Path.extname() |> String.downcase() do
      ext when ext in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"] -> :image
      ext when ext in [".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"] -> :audio
      ext when ext in [".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"] -> :video
      _ -> :file
    end
  end

  defp filename_from_reference(reference) when is_binary(reference) do
    case remote_reference?(reference) do
      true ->
        reference
        |> URI.parse()
        |> Map.get(:path)
        |> basename_or_nil()

      false ->
        basename_or_nil(reference)
    end
  end

  defp basename_or_nil(nil), do: nil
  defp basename_or_nil(""), do: nil

  defp basename_or_nil(path) do
    case Path.basename(path) do
      "." -> nil
      "/" -> nil
      value -> value
    end
  end

  defp remote_reference?(reference) when is_binary(reference) do
    case URI.parse(reference) do
      %URI{scheme: scheme} when is_binary(scheme) and scheme != "" -> true
      _ -> false
    end
  end
end