defmodule AirPlay.Session do
@moduledoc """
Drives the AirPlay/RAOP control handshake against a receiver.
`establish/2` runs OPTIONS → ANNOUNCE(SDP) → SETUP → RECORD → SET_PARAMETER(volume)
exactly as an AirPlay sender does, returning the negotiated
server/control/timing ports needed to start the RTP audio stream. Targets classic
RAOP with **unencrypted ALAC** (no rsaaeskey/aesiv), which every receiver supports.
"""
alias AirPlay.Rtsp
# ALAC fmtp from the capture: 352-sample frames, 16-bit, stereo, 44100 Hz.
@frames_per_packet 352
@sample_rate 44_100
@fmtp "#{@frames_per_packet} 0 16 40 10 14 2 255 0 0 #{@sample_rate}"
@type ports :: %{server: integer(), control: integer(), timing: integer(), session: String.t()}
@doc """
Connect + handshake. `opts[:volume]` is dB (default -20). Returns
`{:ok, rtsp_state, ports}` with the receiver's RTP ports, or `{:error, reason}`.
The RTSP connection stays open (keepalive/teardown via the returned state).
"""
@spec establish(String.t(), keyword()) :: {:ok, Rtsp.t(), ports()} | {:error, term()}
def establish(host, opts \\ []) do
seq = :rand.uniform(65_535)
rtptime = :rand.uniform(4_000_000_000)
lcontrol = Keyword.get(opts, :local_control_port, 6001)
ltiming = Keyword.get(opts, :local_timing_port, 6002)
with {:ok, s} <- Rtsp.connect(host, Keyword.get(opts, :port, 7000)),
{:ok, s} <- options(s),
{:ok, s} <- announce(s),
{:ok, s, ports} <- setup(s, lcontrol, ltiming),
{:ok, s} <- record(s, seq, rtptime),
{:ok, s} <- set_volume(s, Keyword.get(opts, :volume, -20.0)) do
{:ok, s,
Map.merge(ports, %{
seq: seq,
rtptime: rtptime,
local_control: lcontrol,
local_timing: ltiming
})}
end
end
defp options(s) do
challenge = :crypto.strong_rand_bytes(16) |> Base.encode64()
case Rtsp.request(s, "OPTIONS", [{"Apple-Challenge", challenge}], "", "*") do
{:ok, 200, _h, _b, s} -> {:ok, s}
other -> fail("OPTIONS", other)
end
end
defp announce(s) do
core_ip = local_ip()
sdp =
"""
v=0\r
o=AirTunes #{s.session_id} 0 IN IP4 ::ffff:#{core_ip}\r
s=AirTunes\r
c=IN IP4 #{s.host}\r
t=0 0\r
m=audio 0 RTP/AVP 96\r
a=rtpmap:96 AppleLossless\r
a=fmtp:96 #{@fmtp}\r
a=min-latency:11025\r
"""
case Rtsp.request(s, "ANNOUNCE", [{"Content-Type", "application/sdp"}], sdp) do
{:ok, 200, _h, _b, s} -> {:ok, s}
other -> fail("ANNOUNCE", other)
end
end
defp setup(s, lcontrol, ltiming) do
transport =
"RTP/AVP/UDP;unicast;interleaved=0-1;mode=record;control_port=#{lcontrol};timing_port=#{ltiming}"
case Rtsp.request(s, "SETUP", [{"Transport", transport}]) do
{:ok, 200, h, _b, s} ->
ports = parse_transport(h["transport"] || "")
{:ok, %{s | session_id: h["session"] || s.session_id}, ports}
other ->
fail("SETUP", other)
end
end
defp record(s, seq, rtptime) do
info = "seq=#{seq};rtptime=#{rtptime}"
case Rtsp.request(s, "RECORD", [{"Range", "npt=0-"}, {"RTP-Info", info}]) do
{:ok, 200, _h, _b, s} -> {:ok, s}
other -> fail("RECORD", other)
end
end
@doc "Set playback volume in dB (~ -30 silent .. 0 max; -144 = mute)."
def set_volume(s, db) do
body = "volume: #{:erlang.float_to_binary(db / 1.0, decimals: 6)}\r\n"
case Rtsp.request(s, "SET_PARAMETER", [{"Content-Type", "text/parameters"}], body) do
{:ok, 200, _h, _b, s} -> {:ok, s}
other -> fail("SET_PARAMETER volume", other)
end
end
@doc """
Send a no-op `OPTIONS *` to keep the RTSP control connection alive.
RAOP receivers tear down the session (fading the audio out) if the RTSP
channel sits idle past their timeout — ~30s on AirPort Express / HomePods —
even while RTP audio keeps flowing. Real senders poll `OPTIONS` every couple
of seconds; `AirPlay.Cast` drives this on a timer. Returns the updated state
so the caller can thread the bumped CSeq.
"""
@spec keepalive(Rtsp.t()) :: {:ok, Rtsp.t()} | {:error, term()}
def keepalive(s) do
case Rtsp.request(s, "OPTIONS", [], "", "*") do
{:ok, 200, _h, _b, s} -> {:ok, s}
other -> fail("OPTIONS keepalive", other)
end
end
def teardown(s) do
Rtsp.request(s, "TEARDOWN")
Rtsp.close(s)
end
# ── helpers ───────────────────────────────────────────────────────────────
# Transport: ...;server_port=49571;control_port=55085;timing_port=0
defp parse_transport(transport) do
parts =
transport
|> String.split(";")
|> Enum.flat_map(fn p ->
case String.split(p, "=", parts: 2) do
[k, v] -> [{String.trim(k), v}]
_ -> []
end
end)
|> Map.new()
%{
server: int(parts["server_port"]),
control: int(parts["control_port"]),
timing: int(parts["timing_port"])
}
end
defp int(nil), do: 0
defp int(s), do: s |> Integer.parse() |> then(fn {n, _} -> n end)
defp local_ip do
{:ok, addrs} = :inet.getifaddrs()
addrs
|> Enum.flat_map(fn {_if, props} -> Keyword.get_values(props, :addr) end)
|> Enum.find(fn
{a, _, _, _} when a not in [127] -> true
_ -> false
end)
|> case do
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
_ -> "0.0.0.0"
end
end
defp fail(step, {:ok, status, _h, _b, _s}), do: {:error, {step, :status, status}}
defp fail(step, other), do: {:error, {step, other}}
end