Skip to main content

lib/air_play/rtsp.ex

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