Skip to main content

lib/air_play/discovery.ex

defmodule AirPlay.Discovery do
  @moduledoc """
  Discover AirPlay/RAOP receivers on the LAN via **mDNS** (multicast DNS,
  `224.0.0.251:5353`) — the Bonjour service browse Apple devices use. Queries the
  `_raop._tcp.local` service and resolves each instance's SRV (host/port) + A
  (address) from the responses.

  AirPlay is a
  different discovery + streaming stack. Returns
  `%{name: String.t(), host: String.t(), port: non_neg_integer()}` per receiver,
  plus any advertised TXT metadata such as `:txt`, `:model_identifier`,
  `:model`, `:manufacturer`, `:features`, `:source_version`, `:os_version`,
  `:protocol_version`, and `:airplay_protocol`.
  """

  @mdns {224, 0, 0, 251}
  @port 5353
  # _raop._tcp.local as length-prefixed DNS labels.
  @service "_raop._tcp.local"

  @doc "Browse for AirPlay/RAOP receivers for `timeout_ms` (default 2500)."
  @spec receivers(non_neg_integer()) :: [map()]
  def receivers(timeout_ms \\ 2_500) do
    opts = [
      :binary,
      active: false,
      reuseaddr: true,
      multicast_ttl: 4,
      multicast_loop: true,
      add_membership: {@mdns, {0, 0, 0, 0}}
    ]

    # Bind 5353 if we can (best for catching multicast replies); fall back to an
    # ephemeral port with unicast-requested responses if 5353 is taken.
    {sock, query} =
      case :gen_udp.open(@port, [{:reuseport, true} | opts]) do
        {:ok, s} -> {s, ptr_query(false)}
        {:error, _} -> {open_ephemeral(), ptr_query(true)}
      end

    :gen_udp.send(sock, @mdns, @port, query)
    deadline = System.monotonic_time(:millisecond) + timeout_ms
    found = collect(sock, %{}, deadline, query)
    :gen_udp.close(sock)

    found
    |> Map.values()
    |> Enum.filter(&(&1[:host] && &1[:port]))
    |> Enum.sort_by(& &1.name)
  end

  defp open_ephemeral do
    {:ok, s} =
      :gen_udp.open(0, [
        :binary,
        active: false,
        multicast_ttl: 4,
        add_membership: {@mdns, {0, 0, 0, 0}}
      ])

    s
  end

  # Accumulate records into a map keyed by SRV/instance target so PTR + SRV + A +
  # TXT spread across packets merge into one receiver.
  defp collect(sock, acc, deadline, query) do
    case deadline - System.monotonic_time(:millisecond) do
      remaining when remaining <= 0 ->
        acc

      remaining ->
        case :gen_udp.recv(sock, 0, min(300, remaining)) do
          {:ok, {_ip, _port, data}} ->
            collect(sock, merge_packet(acc, data), deadline, query)

          {:error, :timeout} ->
            :gen_udp.send(sock, @mdns, @port, query)
            collect(sock, acc, deadline, query)

          {:error, _} ->
            acc
        end
    end
  end

  defp merge_packet(acc, data) do
    case safe_decode(data) do
      {:ok, rec} ->
        Enum.reduce(resource_records(rec), acc, &merge_rr/2)

      :error ->
        acc
    end
  end

  @doc false
  @spec decode_packet(binary()) :: [map()]
  def decode_packet(data) when is_binary(data) do
    case safe_decode(data) do
      {:ok, rec} ->
        rec
        |> resource_records()
        |> Enum.reduce(%{}, &merge_rr/2)
        |> Map.values()

      :error ->
        []
    end
  end

  def decode_packet(_data), do: []

  defp resource_records(rec) do
    :inet_dns.msg(rec, :anlist) ++ :inet_dns.msg(rec, :arlist)
  end

  # SRV: instance -> {host(target), port}. A: hostname -> ip. PTR: service -> instance.
  defp merge_rr(rr, acc) do
    type = :inet_dns.rr(rr, :type)
    domain = :inet_dns.rr(rr, :domain) |> to_string()
    data = :inet_dns.rr(rr, :data)

    case type do
      # Only RAOP service instances — ignore SRVs from other services (_meshcop, …)
      # that happen to share a host.
      :srv ->
        if String.ends_with?(domain, ".#{@service}") do
          {_prio, _w, port, target} = data

          upsert(acc, domain, %{
            name: instance_label(domain),
            device_id: instance_device_id(domain),
            port: port,
            target: to_string(target),
            advertised_services: ["raop"],
            airplay_protocol: "airplay1"
          })
        else
          acc
        end

      :a ->
        ip = data |> :inet.ntoa() |> to_string()
        # attach this address to any receiver whose SRV target is this host
        Enum.reduce(acc, acc, fn {k, v}, a ->
          if v[:target] == domain, do: Map.put(a, k, Map.put(v, :host, ip)), else: a
        end)

      :txt ->
        if String.ends_with?(domain, ".#{@service}") do
          upsert(
            acc,
            domain,
            Map.merge(
              %{advertised_services: ["raop"], airplay_protocol: "airplay1"},
              txt_metadata(data)
            )
          )
        else
          acc
        end

      _ ->
        acc
    end
  end

  defp upsert(acc, key, fields), do: Map.update(acc, key, fields, &merge_receiver(&1, fields))

  defp merge_receiver(existing, fields) do
    Map.merge(existing, fields, fn
      :txt, left, right when is_map(left) and is_map(right) -> Map.merge(left, right)
      :advertised_services, left, right -> merge_lists(left, right)
      _key, _left, right -> right
    end)
  end

  defp merge_lists(left, right) do
    (List.wrap(left) ++ List.wrap(right))
    |> Enum.filter(&is_binary/1)
    |> Enum.uniq()
  end

  # "AABBCCDDEEFF@Living Room._raop._tcp.local" -> "Living Room"
  defp instance_label(domain) do
    domain
    |> String.replace_suffix(".#{@service}", "")
    |> String.split("@", parts: 2)
    |> List.last()
  end

  defp instance_device_id(domain) do
    domain
    |> String.replace_suffix(".#{@service}", "")
    |> String.split("@", parts: 2)
    |> case do
      [device_id, _name] -> blank_to_nil(device_id)
      _other -> nil
    end
  end

  defp txt_metadata(data) do
    txt = txt_map(data)

    %{}
    |> maybe_put(:txt, txt, map_size(txt) > 0)
    |> maybe_put(:model_identifier, first_text(txt, ["am", "model"]))
    |> maybe_put(:model, first_text(txt, ["model"]))
    |> maybe_put(:manufacturer, first_text(txt, ["manufacturer", "mf", "mfg"]))
    |> maybe_put(:features, first_text(txt, ["ft", "features"]))
    |> maybe_put(:source_version, first_text(txt, ["srcvers", "vs"]))
    |> maybe_put(:os_version, first_text(txt, ["osvers", "ov"]))
    |> maybe_put(:protocol_version, first_text(txt, ["protovers", "vn"]))
    |> maybe_put(:firmware_version, first_text(txt, ["fv"]))
    |> maybe_put(:device_id, first_text(txt, ["deviceid", "device_id"]))
  end

  defp txt_map(data) when is_list(data) do
    data
    |> Enum.flat_map(&txt_entries/1)
    |> Map.new()
  end

  defp txt_map(data), do: txt_entries(data) |> Map.new()

  defp txt_entries(value) when is_list(value), do: value |> IO.iodata_to_binary() |> txt_entries()

  defp txt_entries(value) when is_binary(value) do
    value
    |> String.split("=", parts: 2)
    |> case do
      [key, value] -> txt_entry(key, value)
      [key] -> txt_entry(key, true)
    end
  end

  defp txt_entries(_value), do: []

  defp txt_entry(key, value) do
    case key |> String.trim() |> String.downcase() |> blank_to_nil() do
      nil -> []
      key -> [{key, value}]
    end
  end

  defp first_text(map, keys) do
    Enum.find_value(keys, fn key ->
      map
      |> Map.get(key)
      |> blank_to_nil()
    end)
  end

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
  defp maybe_put(map, key, value, true), do: Map.put(map, key, value)
  defp maybe_put(map, _key, _value, false), do: map

  defp blank_to_nil(value) when is_binary(value) do
    case String.trim(value) do
      "" -> nil
      value -> value
    end
  end

  defp blank_to_nil(value), do: value

  defp safe_decode(data) do
    case :inet_dns.decode(data) do
      {:ok, rec} -> {:ok, rec}
      _ -> :error
    end
  rescue
    _ -> :error
  end

  # Minimal mDNS PTR query for @service. `unicast?` sets the QU bit (top bit of
  # qclass) so responders may reply directly to our ephemeral port.
  defp ptr_query(unicast?) do
    qname =
      @service
      |> String.split(".")
      |> Enum.map(&<<byte_size(&1), &1::binary>>)
      |> IO.iodata_to_binary()

    qclass = if unicast?, do: 0x8001, else: 0x0001
    header = <<0::16, 0::16, 1::16, 0::16, 0::16, 0::16>>
    header <> qname <> <<0, 12::16, qclass::16>>
  end
end