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