Skip to main content

lib/air_play/session.ex

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