defmodule AirPlay.Rtsp do
@moduledoc """
Minimal RTSP/1.0 client for the AirPlay/RAOP control plane.
RTSP looks like HTTP: `METHOD url RTSP/1.0\\r\\n` + headers + blank line + body.
This module owns one TCP connection to the receiver and does synchronous
request/response with an incrementing `CSeq`. Header set + behaviour matches a sender
posing as `iTunes/12.6` — see the protocol notes.
"""
@user_agent "iTunes/12.6 (Macintosh; OS X 10.12.4)"
defstruct [
:sock,
:host,
:port,
:url,
:session_id,
:client_instance,
:dacp_id,
:active_remote,
cseq: 0
]
@type t :: %__MODULE__{}
@doc "Open the RTSP TCP connection and seed the random per-session identifiers."
@spec connect(:inet.ip_address() | String.t(), :inet.port_number()) ::
{:ok, t()} | {:error, term()}
def connect(host, port \\ 7000) do
host_charlist = host |> to_string() |> String.to_charlist()
# The RAOP session id is the RTSP URL path AND the SDP `o=` session — they must
# match, or the receiver rejects SETUP (520). Use one id for both.
session_id = rand_u64()
case :gen_tcp.connect(host_charlist, port, [:binary, active: false, packet: :raw], 5000) do
{:ok, sock} ->
{:ok,
%__MODULE__{
sock: sock,
host: to_string(host),
port: port,
session_id: session_id,
url: "rtsp://#{host}/#{session_id}",
client_instance: rand_hex(16),
dacp_id: rand_hex(8) |> String.upcase(),
active_remote: Integer.to_string(:rand.uniform(4_000_000_000))
}}
err ->
err
end
end
def close(%__MODULE__{sock: sock}), do: :gen_tcp.close(sock)
@doc """
Send an RTSP request and read the response. `method` like `"OPTIONS"`; `path`
defaults to the session URL (pass `"*"` for OPTIONS). `headers` is a keyword/list
of `{name, value}`. Returns `{:ok, status, resp_headers_map, body, state}`.
"""
@spec request(t(), String.t(), keyword() | list(), binary(), String.t() | nil) ::
{:ok, non_neg_integer(), map(), binary(), t()} | {:error, term()}
def request(state, method, headers \\ [], body \\ "", path \\ nil) do
state = %{state | cseq: state.cseq + 1}
target = path || state.url
# Content-Length ONLY for requests with a body. AP2-capable receivers reject
# a bodyless SETUP/OPTIONS that carries `Content-Length: 0` with 520 — the real
# core omits it (verified against frock's capture); that omission is what lets
# the AirPlay-1 fallback SETUP return 200 instead of 520.
base =
[
{"CSeq", Integer.to_string(state.cseq)},
{"User-Agent", @user_agent},
{"DACP-ID", state.dacp_id},
{"Active-Remote", state.active_remote},
{"Client-Instance", state.client_instance}
] ++ if(body == "", do: [], else: [{"Content-Length", Integer.to_string(byte_size(body))}])
lines =
["#{method} #{target} RTSP/1.0"] ++
Enum.map(base ++ headers, fn {k, v} -> "#{k}: #{v}" end)
msg = Enum.join(lines, "\r\n") <> "\r\n\r\n" <> body
with :ok <- :gen_tcp.send(state.sock, msg),
{:ok, status, resp_headers, resp_body} <- recv_response(state.sock) do
{:ok, status, resp_headers, resp_body, state}
end
end
# ── response parsing ────────────────────────────────────────────────────────
defp recv_response(sock) do
with {:ok, head, body0} <- recv_until_headers(sock, "") do
[status_line | header_lines] = String.split(head, "\r\n", trim: false)
status = status_line |> String.split(" ", parts: 3) |> Enum.at(1) |> to_int()
headers =
header_lines
|> Enum.reject(&(&1 == ""))
|> Map.new(fn line ->
[k, v] = String.split(line, ":", parts: 2)
{String.downcase(String.trim(k)), String.trim(v)}
end)
len = headers |> Map.get("content-length", "0") |> to_int()
# body0 = bytes that arrived alongside the header terminator; keep them.
{:ok, body} = recv_body(sock, len, body0)
{:ok, status, headers, body}
end
end
# Read until the blank line that terminates the headers (CRLFCRLF); return the
# header block AND any body bytes that came in the same read.
defp recv_until_headers(sock, acc) do
case String.split(acc, "\r\n\r\n", parts: 2) do
[head, rest] ->
{:ok, head, rest}
[_] ->
case :gen_tcp.recv(sock, 0, 5000) do
{:ok, data} -> recv_until_headers(sock, acc <> data)
err -> err
end
end
end
# Read `len` bytes of body, given `acc` already-received body bytes.
defp recv_body(_sock, len, acc) when byte_size(acc) >= len, do: {:ok, binary_part(acc, 0, len)}
defp recv_body(sock, len, acc) do
case :gen_tcp.recv(sock, len - byte_size(acc), 5000) do
{:ok, data} -> {:ok, acc <> data}
err -> err
end
end
defp to_int(s) do
case Integer.parse(to_string(s)) do
{n, _} -> n
:error -> 0
end
end
defp rand_hex(bytes), do: :crypto.strong_rand_bytes(bytes) |> Base.encode16(case: :lower)
defp rand_u64, do: :rand.uniform(1_000_000_000_000_000_000) |> Integer.to_string()
end