diff --git a/lib/ex_webrtc_recorder.ex b/lib/ex_webrtc_recorder.ex index 057535f..05993b6 100644 --- a/lib/ex_webrtc_recorder.ex +++ b/lib/ex_webrtc_recorder.ex @@ -7,7 +7,6 @@ defmodule ExWebRTC.Recorder do Can optionally upload the saved files to S3-compatible storage. See `ExWebRTC.Recorder.S3` and `t:options/0` for more info. """ - alias ExWebRTC.MediaStreamTrack alias __MODULE__.S3 @@ -121,11 +120,12 @@ defmodule ExWebRTC.Recorder do recorder(), MediaStreamTrack.id(), MediaStreamTrack.rid() | nil, + ExWebRTC.RTPCodecParameters.t() | nil, ExRTP.Packet.t() ) :: :ok - def record(recorder, track_id, rid, %ExRTP.Packet{} = packet) do + def record(recorder, track_id, rid, codec, %ExRTP.Packet{} = packet) do recv_time = System.monotonic_time(:millisecond) - GenServer.cast(recorder, {:record, track_id, rid, recv_time, packet}) + GenServer.cast(recorder, {:record, track_id, rid, codec, recv_time, packet}) end @doc """ @@ -220,10 +220,12 @@ defmodule ExWebRTC.Recorder do end @impl true - def handle_cast({:record, track_id, rid, recv_time, packet}, state) + def handle_cast({:record, track_id, rid, codec, recv_time, packet}, state) when is_map_key(state.track_data, track_id) do %{file: file, rid_map: rid_map} = state.track_data[track_id] + state = if codec, do: update_codec(state, track_id, codec), else: state + with {:ok, rid_idx} <- Map.fetch(rid_map, rid), false <- is_nil(file) do :ok = IO.binwrite(file, serialize_packet(packet, rid_idx, recv_time)) @@ -243,7 +245,7 @@ defmodule ExWebRTC.Recorder do end @impl true - def handle_cast({:record, track_id, _rid, _recv_time, _packet}, state) do + def handle_cast({:record, track_id, _rid, _codec, _recv_time, _packet}, state) do Logger.warning(""" Tried to save packet for unknown track id. Ignoring. Track id: #{inspect(track_id)}.\ """) @@ -290,6 +292,7 @@ defmodule ExWebRTC.Recorder do start_time: start_time, kind: track.kind, streams: track.streams, + codec: nil, rid_map: (track.rids || [nil]) |> Enum.with_index() |> Map.new(), location: file_path, file: File.open!(file_path, [:write]) @@ -302,7 +305,7 @@ defmodule ExWebRTC.Recorder do state = %{state | track_data: Map.merge(state.track_data, new_track_data)} - :ok = File.write!(state.manifest_path, state.track_data |> to_manifest() |> Jason.encode!()) + :ok = write_manifest(state) {manifest_diff, state} end @@ -342,6 +345,20 @@ defmodule ExWebRTC.Recorder do end) end + defp update_codec(state, track_id, codec) do + case get_in(state, [:track_data, track_id, :codec]) do + nil -> + state = put_in(state, [:track_data, track_id, :codec], codec) + + :ok = write_manifest(state) + Logger.info("Updated manifest with codec info for track #{track_id}") + state + + _ -> + state + end + end + defp serialize_packet(packet, rid_idx, recv_time) do packet = ExRTP.Packet.encode(packet) packet_size = byte_size(packet) @@ -355,4 +372,14 @@ defmodule ExWebRTC.Recorder do :io_lib.format("~4..0w~2..0w~2..0w-~2..0w~2..0w~2..0w", [y, mo, d, h, m, s]) |> to_string() end + + defp write_manifest(state) do + File.write!( + state.manifest_path, + state.track_data + |> to_manifest() + |> ExWebRTC.Recorder.Manifest.to_json!() + |> Jason.encode!() + ) + end end diff --git a/lib/ex_webrtc_recorder/manifest.ex b/lib/ex_webrtc_recorder/manifest.ex index 4776ec3..79df8c8 100644 --- a/lib/ex_webrtc_recorder/manifest.ex +++ b/lib/ex_webrtc_recorder/manifest.ex @@ -20,11 +20,63 @@ defmodule ExWebRTC.Recorder.Manifest do kind: :video | :audio, streams: [MediaStreamTrack.stream_id()], rid_map: %{MediaStreamTrack.rid() => integer()}, + codec: ExWebRTC.RTPCodecParameters.t() | nil, location: location() } @type t :: %{MediaStreamTrack.id() => track_manifest()} + @doc false + @spec to_json!(t()) :: map() + def to_json!(manifest) do + Map.new(manifest, fn {id, entry} -> + { + id, + %{ + "start_time" => DateTime.to_iso8601(entry.start_time), + "kind" => Atom.to_string(entry.kind), + "streams" => entry.streams, + "rid_map" => encode_rid_map(entry.rid_map), + "codec" => encode_codec(entry.codec), + "location" => entry.location + } + } + end) + end + + defp encode_rid_map(rid_map) do + Map.new(rid_map, fn + {nil, v} -> {"nil", v} + {layer, v} -> {layer, v} + end) + end + + defp encode_codec(nil), do: nil + + defp encode_codec(%ExWebRTC.RTPCodecParameters{} = codec) do + %{ + "payload_type" => codec.payload_type, + "mime_type" => codec.mime_type, + "clock_rate" => codec.clock_rate, + "channels" => codec.channels, + "sdp_fmtp_line" => fmtp_to_string(codec.sdp_fmtp_line), + "rtcp_fbs" => rtcp_fbs_to_strings(codec.rtcp_fbs) + } + end + + defp fmtp_to_string([]), do: nil + defp fmtp_to_string(nil), do: nil + defp fmtp_to_string(fmtp), do: fmtp |> to_string() |> String.replace_prefix("fmtp:", "") + + defp rtcp_fbs_to_strings(nil), do: nil + defp rtcp_fbs_to_strings([]), do: nil + + defp rtcp_fbs_to_strings(list) when is_list(list) do + list + |> Enum.map(&to_string/1) + |> Enum.map(&String.replace_prefix(&1, "rtcp-fb:", "")) + end + @doc false @spec from_json!(map()) :: t() def from_json!(json_manifest) do @@ -38,6 +90,7 @@ defmodule ExWebRTC.Recorder.Manifest do "kind" => kind, "streams" => streams, "rid_map" => rid_map, + "codec" => codec, "location" => location }) do %{ @@ -45,7 +98,8 @@ defmodule ExWebRTC.Recorder.Manifest do location: location, start_time: parse_start_time(start_time), rid_map: parse_rid_map(rid_map), - kind: parse_kind(kind) + kind: parse_kind(kind), + codec: parse_codec(codec) } end @@ -63,4 +117,40 @@ defmodule ExWebRTC.Recorder.Manifest do defp parse_kind("video"), do: :video defp parse_kind("audio"), do: :audio + + defp parse_codec(%{ + "payload_type" => payload_type, + "mime_type" => mime_type, + "clock_rate" => clock_rate, + "channels" => channels, + "sdp_fmtp_line" => sdp_fmtp_line, + "rtcp_fbs" => rtcp_fbs + }) do + %ExWebRTC.RTPCodecParameters{ + payload_type: payload_type, + mime_type: mime_type, + clock_rate: clock_rate, + channels: channels, + sdp_fmtp_line: parse_sdp_fmtp_line(sdp_fmtp_line), + rtcp_fbs: parse_rtcp_fbs(rtcp_fbs) + } + end + + defp parse_codec(nil), do: nil + + defp parse_sdp_fmtp_line(sdp_fmtp_line) when is_binary(sdp_fmtp_line) do + {:ok, fmtp} = ExSDP.Attribute.FMTP.parse(sdp_fmtp_line) + fmtp + end + + defp parse_sdp_fmtp_line(nil), do: [] + + defp parse_rtcp_fbs(rtcp_fbs) when is_list(rtcp_fbs) do + Enum.map(rtcp_fbs, fn fb -> + {:ok, rtcp_fb} = ExSDP.Attribute.RTCPFeedback.parse(fb) + rtcp_fb + end) + end + + defp parse_rtcp_fbs(nil), do: [] end diff --git a/test/ex_webrtc_recorder/manifest_test.exs b/test/ex_webrtc_recorder/manifest_test.exs new file mode 100644 index 0000000..547e559 --- /dev/null +++ b/test/ex_webrtc_recorder/manifest_test.exs @@ -0,0 +1,191 @@ +defmodule ExWebRTC.Recorder.Manifest.Test do + use ExUnit.Case, async: true + + alias ExWebRTC.Recorder.Manifest + alias ExWebRTC.RTPCodecParameters + alias ExSDP.Attribute.{FMTP, RTCPFeedback} + + describe "to_json!/1" do + test "empty manifest" do + manifest = %{} + json = Manifest.to_json!(manifest) + assert json == %{} + end + + test "two entries(audio,video) manifest" do + audio_id = 6_264_340_764_417_145_606_381_315_372 + video_id = 34_899_663_449_195_684_468_354_913_891 + base_dir = "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515" + audio_path = Path.join(base_dir, "6264340764417145606381315372.rtpx") + video_path = Path.join(base_dir, "34899663449195684468354913891.rtpx") + streams = ["{13d54720-6d00-45a9-b234-11f0e969f4b7}"] + start_time = ~U[2025-09-02 14:35:15.336022Z] + + manifest = %{ + audio_id => %{ + location: audio_path, + kind: :audio, + streams: streams, + start_time: start_time, + codec: audio_opus_codec(), + rid_map: %{nil: 0} + }, + video_id => %{ + location: video_path, + kind: :video, + streams: streams, + start_time: start_time, + codec: video_vp8_codec(), + rid_map: %{nil: 0} + } + } + + json = Manifest.to_json!(manifest) + + assert json == + %{ + audio_id => %{ + "codec" => %{ + "channels" => 2, + "clock_rate" => 48_000, + "mime_type" => "audio/opus", + "payload_type" => 109, + "rtcp_fbs" => nil, + "sdp_fmtp_line" => "109 maxplaybackrate=48000;stereo=1;useinbandfec=1" + }, + "kind" => "audio", + "location" => audio_path, + "rid_map" => %{"nil" => 0}, + "start_time" => "2025-09-02T14:35:15.336022Z", + "streams" => streams + }, + video_id => %{ + "codec" => %{ + "channels" => nil, + "clock_rate" => 90_000, + "mime_type" => "video/VP8", + "payload_type" => 120, + "rtcp_fbs" => ["120 nack", "120 nack pli", "120 ccm fir", "120 transport-cc"], + "sdp_fmtp_line" => "120 max-fs=12288;max-fr=60" + }, + "kind" => "video", + "location" => video_path, + "rid_map" => %{"nil" => 0}, + "start_time" => "2025-09-02T14:35:15.336022Z", + "streams" => streams + } + } + end + end + + describe "from_json!/1" do + test "empty manifest" do + json = %{} + manifest = Manifest.from_json!(json) + assert manifest == %{} + end + + test "two entries(audio,video) manifest" do + json = + Jason.decode!(""" + { + "6264340764417145606381315372": { + "codec": { + "channels": 2, + "clock_rate": 48000, + "mime_type": "audio/opus", + "payload_type": 109, + "rtcp_fbs": null, + "sdp_fmtp_line": "109 maxplaybackrate=48000;stereo=1;useinbandfec=1" + }, + "kind": "audio", + "location": "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/6264340764417145606381315372.rtpx", + "rid_map": { + "nil": 0 + }, + "start_time": "2025-09-02T14:35:15.336022Z", + "streams": [ + "{13d54720-6d00-45a9-b234-11f0e969f4b7}" + ] + }, + "34899663449195684468354913891": { + "codec": { + "channels": null, + "clock_rate": 90000, + "mime_type": "video/VP8", + "payload_type": 120, + "rtcp_fbs": [ + "120 nack", + "120 nack pli", + "120 ccm fir", + "120 transport-cc" + ], + "sdp_fmtp_line": "120 max-fs=12288;max-fr=60" + }, + "kind": "video", + "location": "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/34899663449195684468354913891.rtpx", + "rid_map": { + "nil": 0 + }, + "start_time": "2025-09-02T14:35:15.336022Z", + "streams": [ + "{13d54720-6d00-45a9-b234-11f0e969f4b7}" + ] + } + } + """) + + manifest = Manifest.from_json!(json) + + assert manifest == + %{ + "34899663449195684468354913891" => %{ + location: + "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/34899663449195684468354913891.rtpx", + kind: :video, + streams: ["{13d54720-6d00-45a9-b234-11f0e969f4b7}"], + start_time: ~U[2025-09-02 14:35:15.336022Z], + codec: video_vp8_codec(), + rid_map: %{nil: 0} + }, + "6264340764417145606381315372" => %{ + location: + "/Users/bernardgawor/Projects/swm/broadcaster/recordings/20250902-163515/6264340764417145606381315372.rtpx", + kind: :audio, + streams: ["{13d54720-6d00-45a9-b234-11f0e969f4b7}"], + start_time: ~U[2025-09-02 14:35:15.336022Z], + codec: audio_opus_codec(), + rid_map: %{nil: 0} + } + } + end + end + + # Helpers + defp audio_opus_codec do + %RTPCodecParameters{ + payload_type: 109, + mime_type: "audio/opus", + clock_rate: 48_000, + channels: 2, + sdp_fmtp_line: %FMTP{pt: 109, maxplaybackrate: 48_000, stereo: true, useinbandfec: true}, + rtcp_fbs: [] + } + end + + defp video_vp8_codec do + %RTPCodecParameters{ + payload_type: 120, + mime_type: "video/VP8", + clock_rate: 90_000, + channels: nil, + sdp_fmtp_line: %FMTP{pt: 120, max_fs: 12_288, max_fr: 60}, + rtcp_fbs: [ + %RTCPFeedback{pt: 120, feedback_type: :nack}, + %RTCPFeedback{pt: 120, feedback_type: :pli}, + %RTCPFeedback{pt: 120, feedback_type: :fir}, + %RTCPFeedback{pt: 120, feedback_type: :twcc} + ] + } + end +end