From 9515da2bb57ed5758b5e968e9b234ce217b7d1ac Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:13:09 +0100 Subject: [PATCH 1/4] Changes from https://github.com/elixir-webrtc/ex_webrtc/pull/197/files --- README.md | 38 +- lib/ex_webrtc_recorder.ex | 351 ++++++++++++++++ lib/ex_webrtc_recorder/converter.ex | 410 +++++++++++++++++++ lib/ex_webrtc_recorder/converter/ffmpeg.ex | 63 +++ lib/ex_webrtc_recorder/converter/manifest.ex | 48 +++ lib/ex_webrtc_recorder/manifest.ex | 66 +++ lib/ex_webrtc_recorder/s3.ex | 36 ++ lib/ex_webrtc_recorder/s3/upload_handler.ex | 135 ++++++ lib/ex_webrtc_recorder/s3/utils.ex | 83 ++++ mix.exs | 72 ++++ mix.lock | 46 +++ 11 files changed, 1347 insertions(+), 1 deletion(-) create mode 100644 lib/ex_webrtc_recorder.ex create mode 100644 lib/ex_webrtc_recorder/converter.ex create mode 100644 lib/ex_webrtc_recorder/converter/ffmpeg.ex create mode 100644 lib/ex_webrtc_recorder/converter/manifest.ex create mode 100644 lib/ex_webrtc_recorder/manifest.ex create mode 100644 lib/ex_webrtc_recorder/s3.ex create mode 100644 lib/ex_webrtc_recorder/s3/upload_handler.ex create mode 100644 lib/ex_webrtc_recorder/s3/utils.ex create mode 100644 mix.exs create mode 100644 mix.lock diff --git a/README.md b/README.md index 4b9326a..4bdc6c5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,39 @@ # ExWebRTC Recorder -TODO WRITEME +[![Hex.pm](https://img.shields.io/hexpm/v/ex_webrtc_recorder.svg)](https://hex.pm/packages/ex_webrtc_recorder) +[![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](https://hexdocs.pm/ex_webrtc_recorder) + +Records and processes RTP packets sent and received using [ExWebRTC](https://github.com/elixir-webrtc/ex_webrtc). + +## Installation + +Add `:ex_webrtc_recorder` to your list of dependencies + +```elixir +def deps do + [ + {:ex_webrtc_recorder, "~> 0.1.0"} + ] +end +``` + +TODO add necessary steps + +ExWebRTC Recorder comes with optional support for uploading the recordings to S3-compatible storage, +but it must be explicitely turned on by adding the following optional dependencies: +* `:ex_aws_s3` +* `:ex_aws` +* `:sweet_xml` +* an HTTP client (e.g. `:req`) + +```elixir +def deps do + [ + {:ex_webrtc_recorder, "~> 0.1.0"}, + {:ex_aws_s3, "~> 2.5"}, + {:ex_aws, "~> 2.5"}, + {:sweet_xml, "~> 0.7"}, + {:req, "~> 0.5"} + ] +end +``` diff --git a/lib/ex_webrtc_recorder.ex b/lib/ex_webrtc_recorder.ex new file mode 100644 index 0000000..1c9c664 --- /dev/null +++ b/lib/ex_webrtc_recorder.ex @@ -0,0 +1,351 @@ +defmodule ExWebRTC.Recorder do + @moduledoc """ + Saves received RTP packets to a file for later processing/analysis. + + Dumps raw RTP packets fed to it in a custom format. Use `ExWebRTC.Recorder.Converter` to process them. + + 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 + + require Logger + + use GenServer + + @default_base_dir "./recordings" + + @type recorder :: GenServer.server() + + @typedoc """ + Options that can be passed to `start_link/1`. + + * `:base_dir` - Base directory where Recorder will save its artifacts. `#{@default_base_dir}` by default. + * `:on_start` - Callback that will be executed just after the Recorder is (re)started. + It should return the initial list of tracks to be added. + * `:controlling_process` - PID of a process where all messages will be sent. `self()` by default. + * `:s3_upload_config` - If passed, finished recordings will be uploaded to S3-compatible storage. + See `t:ExWebRTC.Recorder.S3.upload_config/0` for more info. + """ + @type option :: + {:base_dir, String.t()} + | {:on_start, (-> [MediaStreamTrack.t()])} + | {:controlling_process, Process.dest()} + | {:s3_upload_config, S3.upload_config()} + + @type options :: [option()] + + @typedoc """ + Messages sent by the `ExWebRTC.Recorder` process to its controlling process. + + * `:upload_complete`, `:upload_failed` - Sent after the completion of the upload task, identified by its reference. + Contains the updated manifest with `s3://` scheme URLs to uploaded files. + """ + @type message :: + {:ex_webrtc_recorder, pid(), + {:upload_complete, S3.upload_task_ref(), __MODULE__.Manifest.t()} + | {:upload_failed, S3.upload_task_ref(), __MODULE__.Manifest.t()}} + + # Necessary to start Recorder under a supervisor using `{Recorder, [recorder_opts, gen_server_opts]}` + @doc false + @spec child_spec(list()) :: Supervisor.child_spec() + def child_spec(args) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, args} + } + end + + @doc """ + Starts a new `ExWebRTC.Recorder` process. + + `ExWebRTC.Recorder` is a `GenServer` under the hood, thus this function allows for + passing the generic `t:GenServer.options/0` as an argument. + """ + @spec start(options(), GenServer.options()) :: GenServer.on_start() + def start(recorder_opts \\ [], gen_server_opts \\ []) do + config = + recorder_opts + |> Keyword.put_new(:controlling_process, self()) + + GenServer.start(__MODULE__, config, gen_server_opts) + end + + @doc """ + Starts a new `ExWebRTC.Recorder` process. + + Works identically to `start/2`, but links to the calling process. + """ + @spec start_link(options(), GenServer.options()) :: GenServer.on_start() + def start_link(recorder_opts \\ [], gen_server_opts \\ []) do + config = + recorder_opts + |> Keyword.put_new(:controlling_process, self()) + + GenServer.start_link(__MODULE__, config, gen_server_opts) + end + + @doc """ + Adds new tracks to the recording. + + Returns the part of the recording manifest that's relevant to the freshly added tracks. + See `t:ExWebRTC.Recorder.Manifest.t/0` for more info. + """ + @spec add_tracks(recorder(), [MediaStreamTrack.t()]) :: {:ok, __MODULE__.Manifest.t()} + def add_tracks(recorder, tracks) do + GenServer.call(recorder, {:add_tracks, tracks}) + end + + @doc """ + Records a received packet on the given track. + """ + @spec record( + recorder(), + MediaStreamTrack.id(), + MediaStreamTrack.rid() | nil, + ExRTP.Packet.t() + ) :: :ok + def record(recorder, track_id, rid, %ExRTP.Packet{} = packet) do + recv_time = System.monotonic_time(:millisecond) + GenServer.cast(recorder, {:record, track_id, rid, recv_time, packet}) + end + + @doc """ + Changes the controlling process of this `recorder` process. + + Controlling process is a process that receives all of the messages (described + by `t:message/0`) from this Recorder. + """ + @spec controlling_process(recorder(), Process.dest()) :: :ok + def controlling_process(recorder, controlling_process) do + GenServer.call(recorder, {:controlling_process, controlling_process}) + end + + @doc """ + Finishes the recording for the given tracks and optionally uploads the result files. + + Returns the part of the recording manifest that's relevant to the freshly ended tracks. + See `t:ExWebRTC.Recorder.Manifest.t/0` for more info. + + If uploads are configured: + * Returns the reference to the upload task that was spawned. + * Will send the `:upload_complete`/`:upload_failed` message with this reference + to the controlling process when the task finishes. + + Note that the manifest returned by this function always contains local paths to files. + The updated manifest with `s3://` scheme URLs is sent in the aforementioned message. + """ + @spec end_tracks(recorder(), [MediaStreamTrack.id()]) :: + {:ok, __MODULE__.Manifest.t(), S3.upload_task_ref() | nil} | {:error, :tracks_not_found} + def end_tracks(recorder, track_ids) do + GenServer.call(recorder, {:end_tracks, track_ids}) + end + + @impl true + def init(config) do + base_dir = + (config[:base_dir] || @default_base_dir) + |> Path.join(current_datetime()) + |> Path.expand() + + :ok = File.mkdir_p!(base_dir) + Logger.info("Starting recorder. Recordings will be saved under: #{base_dir}") + + upload_handler = + if config[:s3_upload_config] do + Logger.info("Recordings will be uploaded to S3") + S3.UploadHandler.new(config[:s3_upload_config]) + end + + state = %{ + owner: config[:controlling_process], + base_dir: base_dir, + manifest_path: Path.join(base_dir, "manifest.json"), + track_data: %{}, + upload_handler: upload_handler + } + + case config[:on_start] do + nil -> + {:ok, state} + + callback -> + {:ok, state, {:continue, {:on_start, callback}}} + end + end + + @impl true + def handle_continue({:on_start, on_start}, state) do + case on_start.() do + [] -> + {:noreply, state} + + tracks -> + {_manifest_diff, state} = do_add_tracks(tracks, state) + {:noreply, state} + end + end + + @impl true + def handle_call({:controlling_process, controlling_process}, _from, state) do + state = %{state | owner: controlling_process} + {:reply, :ok, state} + end + + @impl true + def handle_call({:add_tracks, tracks}, _from, state) do + {manifest_diff, state} = do_add_tracks(tracks, state) + {:reply, {:ok, manifest_diff}, state} + end + + @impl true + def handle_call({:end_tracks, track_ids}, _from, state) do + case Enum.filter(track_ids, &Map.has_key?(state.track_data, &1)) do + [] -> + {:reply, {:error, :tracks_not_found}, state} + + track_ids -> + {manifest_diff, ref, state} = do_end_tracks(track_ids, state) + {:reply, {:ok, manifest_diff, ref}, state} + end + end + + @impl true + def handle_cast({:record, track_id, rid, 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] + + 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)) + else + :error -> + Logger.warning(""" + Tried to save packet for unknown rid. Ignoring. Track id: #{inspect(track_id)}, rid: #{inspect(rid)}.\ + """) + + true -> + Logger.warning(""" + Tried to save packet for track which has been ended. Ignoring. Track id: #{inspect(track_id)} \ + """) + end + + {:noreply, state} + end + + @impl true + def handle_cast({:record, track_id, _rid, _recv_time, _packet}, state) do + Logger.warning(""" + Tried to save packet for unknown track id. Ignoring. Track id: #{inspect(track_id)}.\ + """) + + {:noreply, state} + end + + @impl true + def handle_info({ref, _res} = task_result, state) when is_reference(ref) do + if state.upload_handler do + {result, manifest, handler} = + S3.UploadHandler.process_result(state.upload_handler, task_result) + + case result do + :ok -> + send(state.owner, {:ex_webrtc_recorder, self(), {:upload_complete, ref, manifest}}) + + {:error, :upload_failed} -> + send(state.owner, {:ex_webrtc_recorder, self(), {:upload_failed, ref, manifest}}) + + {:error, :unknown_task} -> + raise "Upload handler encountered result of unknown task" + end + + {:noreply, %{state | upload_handler: handler}} + else + {:noreply, state} + end + end + + @impl true + def handle_info(_msg, state) do + {:noreply, state} + end + + defp do_add_tracks(tracks, state) do + start_time = DateTime.utc_now() + + new_track_data = + Map.new(tracks, fn track -> + file_path = Path.join(state.base_dir, "#{track.id}.rtpx") + + track_entry = %{ + start_time: start_time, + kind: track.kind, + streams: track.streams, + rid_map: (track.rids || [nil]) |> Enum.with_index() |> Map.new(), + location: file_path, + file: File.open!(file_path, [:write]) + } + + {track.id, track_entry} + end) + + manifest_diff = to_manifest(new_track_data) + + 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!()) + + {manifest_diff, state} + end + + defp do_end_tracks(track_ids, state) do + # We're keeping entries from `track_data` for ended tracks + # because they need to be present in the global manifest, + # which gets recreated on each call to `add_tracks` + state = + Enum.reduce(track_ids, state, fn track_id, state -> + %{file: file} = state.track_data[track_id] + File.close(file) + + put_in(state, [:track_data, track_id, :file], nil) + end) + + manifest_diff = to_manifest(state.track_data, track_ids) + + case state.upload_handler do + nil -> + {manifest_diff, nil, state} + + handler -> + {ref, handler} = S3.UploadHandler.spawn_task(handler, manifest_diff) + + {manifest_diff, ref, %{state | upload_handler: handler}} + end + end + + defp to_manifest(track_data, track_ids) do + track_data |> Map.take(track_ids) |> to_manifest() + end + + defp to_manifest(track_data) do + Map.new(track_data, fn {id, track} -> + {id, Map.delete(track, :file)} + end) + end + + defp serialize_packet(packet, rid_idx, recv_time) do + packet = ExRTP.Packet.encode(packet) + packet_size = byte_size(packet) + <> + end + + defp current_datetime() do + {{y, mo, d}, {h, m, s}} = :calendar.local_time() + + # e.g. 20240130-120315 + :io_lib.format("~4..0w~2..0w~2..0w-~2..0w~2..0w~2..0w", [y, mo, d, h, m, s]) + |> to_string() + end +end diff --git a/lib/ex_webrtc_recorder/converter.ex b/lib/ex_webrtc_recorder/converter.ex new file mode 100644 index 0000000..7954fa4 --- /dev/null +++ b/lib/ex_webrtc_recorder/converter.ex @@ -0,0 +1,410 @@ +defmodule ExWebRTC.Recorder.Converter do + @moduledoc """ + Processes RTP packet files saved by `ExWebRTC.Recorder`. + + Requires the `ffmpeg` binary with the relevant libraries present in `PATH`. + + At the moment, `ExWebRTC.Recorder.Converter` works only with VP8 video and Opus audio. + + Can optionally download/upload the source/result files from/to S3-compatible storage. + See `ExWebRTC.Recorder.S3` and `t:options/0` for more info. + """ + + alias ExWebRTC.RTP.JitterBuffer.PacketStore + alias ExWebRTC.RTP.Depayloader + alias ExWebRTC.Media.{IVF, Ogg} + + alias ExWebRTC.Recorder.S3 + alias ExWebRTC.{Recorder, RTPCodecParameters} + + alias __MODULE__.FFmpeg + + require Logger + + # TODO: Allow changing these values + @ivf_header_opts [ + # <> = "VP80" + fourcc: 808_996_950, + height: 720, + width: 1280, + num_frames: 1024, + timebase_denum: 30, + timebase_num: 1 + ] + + # TODO: Support codecs other than VP8/Opus + @video_codec_params %RTPCodecParameters{ + payload_type: 96, + mime_type: "video/VP8", + clock_rate: 90_000 + } + + @audio_codec_params %RTPCodecParameters{ + payload_type: 111, + mime_type: "audio/opus", + clock_rate: 48_000, + channels: 2 + } + + @default_output_path "./converter/output" + @default_download_path "./converter/download" + @default_thumbnail_width 640 + @default_thumbnail_height -1 + + @typedoc """ + Context for the thumbnail generation. + + * `:width` - Thumbnail width. #{@default_thumbnail_width} by default. + * `:height` - Thumbnail height. #{@default_thumbnail_height} by default. + + Setting either of the values to `-1` will fit the size to the aspect ratio. + """ + @type thumbnails_ctx :: %{ + optional(:width) => pos_integer() | -1, + optional(:height) => pos_integer() | -1 + } + + @typedoc """ + Options that can be passed to `convert_manifest!/2` and `convert_path!/2`. + + * `:output_path` - Directory where Converter will save its artifacts. `#{@default_output_path}` by default. + * `:s3_upload_config` - If passed, processed recordings will be uploaded to S3-compatible storage. + See `t:ExWebRTC.Recorder.S3.upload_config/0` for more info. + * `:download_path` - Directory where Converter will save files fetched from S3. `#{@default_download_path}` by default. + * `:s3_download_config` - Optional S3 config overrides used when fetching files. + See `t:ExWebRTC.Recorder.S3.override_config/0` for more info. + * `:thumbnails_ctx` - If passed, Converter will generate thumbnails for the output files. + See `t:thumbnails_ctx/0` for more info. + """ + @type option :: + {:output_path, Path.t()} + | {:s3_upload_config, keyword()} + | {:download_path, Path.t()} + | {:s3_download_config, keyword()} + | {:thumbnails_ctx, thumbnails_ctx()} + + @type options :: [option()] + + @doc """ + Loads the recording manifest from file, then proceeds with `convert_manifest!/2`. + """ + @spec convert_path!(Path.t(), options()) :: __MODULE__.Manifest.t() | no_return() + def convert_path!(recorder_manifest_path, options \\ []) do + recorder_manifest_path = + recorder_manifest_path + |> Path.expand() + |> then( + &if(File.dir?(&1), + do: Path.join(&1, "manifest.json"), + else: &1 + ) + ) + + recorder_manifest = + recorder_manifest_path + |> File.read!() + |> Jason.decode!() + |> Recorder.Manifest.from_json!() + + convert_manifest!(recorder_manifest, options) + end + + @doc """ + Converts the saved dumps of tracks in the manifest to WEBM files. + """ + @spec convert_manifest!(Recorder.Manifest.t(), options()) :: + __MODULE__.Manifest.t() | no_return() + def convert_manifest!(recorder_manifest, options \\ []) + + def convert_manifest!(manifest, options) when map_size(manifest) > 0 do + thumbnails_ctx = + case Keyword.get(options, :thumbnails_ctx, nil) do + nil -> + nil + + ctx -> + %{ + width: ctx[:width] || @default_thumbnail_width, + height: ctx[:height] || @default_thumbnail_height + } + end + + output_path = Keyword.get(options, :output_path, @default_output_path) |> Path.expand() + download_path = Keyword.get(options, :download_path, @default_download_path) |> Path.expand() + File.mkdir_p!(output_path) + File.mkdir_p!(download_path) + + download_config = Keyword.get(options, :s3_download_config, []) + + upload_handler = + if options[:s3_upload_config] do + Logger.info("Converted recordings will be uploaded to S3") + S3.UploadHandler.new(options[:s3_upload_config]) + end + + output_manifest = + manifest + |> fetch_remote_files!(download_path, download_config) + |> do_convert_manifest!(output_path, thumbnails_ctx) + + result_manifest = + if upload_handler != nil do + {ref, upload_handler} = + output_manifest + |> __MODULE__.Manifest.to_upload_handler_manifest() + |> then(&S3.UploadHandler.spawn_task(upload_handler, &1)) + + # FIXME: Add descriptive errors + {:ok, upload_handler_result_manifest, _handler} = + receive do + {^ref, _res} = task_result -> + S3.UploadHandler.process_result(upload_handler, task_result) + end + + upload_handler_result_manifest + |> __MODULE__.Manifest.from_upload_handler_manifest(output_manifest) + else + output_manifest + end + + result_manifest + end + + def convert_manifest!(_empty_manifest, _options), do: %{} + + defp fetch_remote_files!(manifest, dl_path, dl_config) do + Map.new(manifest, fn {track_id, %{location: location} = track_data} -> + scheme = URI.parse(location).scheme || "file" + + {:ok, local_path} = + case scheme do + "file" -> {:ok, String.replace_prefix(location, "file://", "")} + "s3" -> fetch_from_s3(location, dl_path, dl_config) + end + + {track_id, %{track_data | location: Path.expand(local_path)}} + end) + end + + defp fetch_from_s3(url, dl_path, dl_config) do + Logger.info("Fetching file #{url}") + + with {:ok, bucket_name, s3_path} <- S3.Utils.parse_url(url), + out_path <- Path.join(dl_path, Path.basename(s3_path)), + {:ok, _result} <- S3.Utils.fetch_file(bucket_name, s3_path, out_path, dl_config) do + {:ok, out_path} + else + # FIXME: Add descriptive errors + _other -> :error + end + end + + defp do_convert_manifest!(manifest, output_path, thumbnails_ctx) do + stream_map = + Enum.reduce(manifest, %{}, fn {id, track}, stream_map -> + %{ + location: path, + kind: kind, + streams: streams, + rid_map: rid_map + } = track + + file = File.open!(path) + + packets = + read_packets( + file, + Map.new(rid_map, fn {_rid, rid_idx} -> {rid_idx, %PacketStore{}} end) + ) + + output_metadata = + case kind do + :video -> + convert_video_track(id, rid_map, output_path, packets) + + :audio -> + %{nil: convert_audio_track(id, output_path, packets |> Map.values() |> hd())} + end + + stream_id = List.first(streams) + + stream_map + |> Map.put_new(stream_id, %{video: %{}, audio: %{}}) + |> Map.update!(stream_id, &Map.put(&1, kind, output_metadata)) + end) + + # FIXME: This won't work if we have audio/video only + for {stream_id, %{video: video_files, audio: audio_files}} <- stream_map, + {rid, %{filename: video_file, start_time: video_start}} <- video_files, + {nil, %{filename: audio_file, start_time: audio_start}} <- audio_files, + into: %{} do + output_id = if rid == nil, do: stream_id, else: "#{stream_id}_#{rid}" + output_file = Path.join(output_path, "#{output_id}.webm") + + FFmpeg.combine_av!( + Path.join(output_path, video_file), + video_start, + Path.join(output_path, audio_file), + audio_start, + output_file + ) + + # TODO: Consider deleting the `.ivf` and `.ogg` files at this point + + stream_manifest = %{ + location: output_file, + duration_seconds: FFmpeg.get_duration_in_seconds!(output_file) + } + + stream_manifest = + if thumbnails_ctx do + thumbnail_file = FFmpeg.generate_thumbnail!(output_file, thumbnails_ctx) + Map.put(stream_manifest, :thumbnail_location, thumbnail_file) + else + stream_manifest + end + + {output_id, stream_manifest} + end + end + + defp convert_video_track(id, rid_map, output_path, packets) do + for {rid, rid_idx} <- rid_map, into: %{} do + filename = if rid == nil, do: "#{id}.ivf", else: "#{id}_#{rid}.ivf" + + {:ok, writer} = + output_path + |> Path.join(filename) + |> IVF.Writer.open(@ivf_header_opts) + + {:ok, depayloader} = Depayloader.new(@video_codec_params) + + conversion_state = %{ + depayloader: depayloader, + writer: writer, + frames_cnt: 0 + } + + # Returns the timestamp (in milliseconds) at which the first frame was received + start_time = do_convert_video_track(packets[rid_idx], conversion_state) + + {rid, %{filename: filename, start_time: start_time}} + end + end + + defp do_convert_video_track([], %{writer: writer} = state) do + IVF.Writer.close(writer) + + state[:first_frame_recv_time] + end + + defp do_convert_video_track([packet | rest], state) do + case Depayloader.depayload(state.depayloader, packet) do + {nil, depayloader} -> + do_convert_video_track(rest, %{state | depayloader: depayloader}) + + {vp8_frame, depayloader} -> + {:ok, %ExRTP.Packet.Extension{id: 1, data: <>}} = + ExRTP.Packet.fetch_extension(packet, 1) + + frame = %IVF.Frame{timestamp: state.frames_cnt, data: vp8_frame} + {:ok, writer} = IVF.Writer.write_frame(state.writer, frame) + + state = + %{state | depayloader: depayloader, writer: writer, frames_cnt: state.frames_cnt + 1} + |> Map.put_new(:first_frame_recv_time, recv_time) + + do_convert_video_track(rest, state) + end + end + + defp convert_audio_track(id, output_path, packets) do + filename = "#{id}.ogg" + + {:ok, writer} = + output_path + |> Path.join(filename) + |> Ogg.Writer.open() + + {:ok, depayloader} = Depayloader.new(@audio_codec_params) + + # Same behaviour as in `convert_video_track/4` + start_time = do_convert_audio_track(packets, %{depayloader: depayloader, writer: writer}) + + %{filename: filename, start_time: start_time} + end + + defp do_convert_audio_track([], %{writer: writer} = state) do + Ogg.Writer.close(writer) + + state[:first_frame_recv_time] + end + + defp do_convert_audio_track([packet | rest], state) do + {opus_packet, depayloader} = Depayloader.depayload(state.depayloader, packet) + + {:ok, %ExRTP.Packet.Extension{id: 1, data: <>}} = + ExRTP.Packet.fetch_extension(packet, 1) + + {:ok, writer} = Ogg.Writer.write_packet(state.writer, opus_packet) + + state = + %{state | depayloader: depayloader, writer: writer} + |> Map.put_new(:first_frame_recv_time, recv_time) + + do_convert_audio_track(rest, state) + end + + defp read_packets(file, stores) do + case read_packet(file) do + {:ok, rid_idx, recv_time, packet} -> + packet = + ExRTP.Packet.add_extension(packet, %ExRTP.Packet.Extension{ + id: 1, + data: <> + }) + + stores = Map.update!(stores, rid_idx, &insert_packet_to_store(&1, packet)) + read_packets(file, stores) + + {:error, reason} -> + Logger.warning("Error decoding RTP packet: #{inspect(reason)}") + read_packets(file, stores) + + :eof -> + Map.new(stores, fn {rid_idx, store} -> + {rid_idx, store |> PacketStore.dump() |> Enum.reject(&is_nil/1)} + end) + end + end + + defp read_packet(file) do + with {:ok, <>} <- read_exactly_n_bytes(file, 13), + {:ok, packet_data} <- read_exactly_n_bytes(file, packet_size), + {:ok, packet} <- ExRTP.Packet.decode(packet_data) do + {:ok, rid_idx, recv_time, packet} + end + end + + defp read_exactly_n_bytes(file, byte_cnt) do + with data when is_binary(data) <- IO.binread(file, byte_cnt), + true <- byte_cnt == byte_size(data) do + {:ok, data} + else + :eof -> :eof + false -> {:error, :not_enough_data} + {:error, _reason} = error -> error + end + end + + defp insert_packet_to_store(store, packet) do + case PacketStore.insert(store, packet) do + {:ok, store} -> + store + + {:error, :late_packet} -> + Logger.warning("Decoded late RTP packet") + store + end + end +end diff --git a/lib/ex_webrtc_recorder/converter/ffmpeg.ex b/lib/ex_webrtc_recorder/converter/ffmpeg.ex new file mode 100644 index 0000000..784856b --- /dev/null +++ b/lib/ex_webrtc_recorder/converter/ffmpeg.ex @@ -0,0 +1,63 @@ +defmodule ExWebRTC.Recorder.Converter.FFmpeg do + @moduledoc false + + alias ExWebRTC.Recorder.Converter + + @spec combine_av!(Path.t(), integer(), Path.t(), integer(), Path.t()) :: Path.t() | no_return() + def combine_av!( + video_file, + video_start_timestamp_ms, + audio_file, + audio_start_timestamp_ms, + output_file + ) do + {video_start_time, audio_start_time} = + calculate_start_times(video_start_timestamp_ms, audio_start_timestamp_ms) + + {_io, 0} = + System.cmd( + "ffmpeg", + ~w(-ss #{video_start_time} -i #{video_file} -ss #{audio_start_time} -i #{audio_file} -c:v copy -c:a copy -shortest #{output_file}), + stderr_to_stdout: true + ) + + output_file + end + + @spec generate_thumbnail!(Path.t(), Converter.thumbnails_ctx()) :: Path.t() | no_return() + def generate_thumbnail!(file, thumbnails_ctx) do + thumbnail_file = "#{file}_thumbnail.jpg" + + {_io, 0} = + System.cmd( + "ffmpeg", + ~w(-i #{file} -vf thumbnail,scale=#{thumbnails_ctx.width}:#{thumbnails_ctx.height} -frames:v 1 #{thumbnail_file}), + stderr_to_stdout: true + ) + + thumbnail_file + end + + @spec get_duration_in_seconds!(Path.t()) :: non_neg_integer() | no_return() + def get_duration_in_seconds!(file) do + {duration, 0} = + System.cmd( + "ffprobe", + ~w(-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 #{file}) + ) + + {duration_seconds, _rest} = Float.parse(duration) + round(duration_seconds) + end + + defp calculate_start_times(video_start_ms, audio_start_ms) do + diff = abs(video_start_ms - audio_start_ms) + s = div(diff, 1000) + ms = rem(diff, 1000) + delayed_start_time = :io_lib.format("00:00:~2..0w.~3..0w", [s, ms]) |> to_string() + + if video_start_ms > audio_start_ms, + do: {"00:00:00.000", delayed_start_time}, + else: {delayed_start_time, "00:00:00.000"} + end +end diff --git a/lib/ex_webrtc_recorder/converter/manifest.ex b/lib/ex_webrtc_recorder/converter/manifest.ex new file mode 100644 index 0000000..91b0aed --- /dev/null +++ b/lib/ex_webrtc_recorder/converter/manifest.ex @@ -0,0 +1,48 @@ +defmodule ExWebRTC.Recorder.Converter.Manifest do + @moduledoc """ + Lists the streams processed using the Converter. + + Converter combines the tracks from the Recording based on their `:streams` field. + """ + + alias ExWebRTC.{MediaStreamTrack, Recorder} + + @type file_manifest :: %{ + :location => Recorder.Manifest.location(), + :duration_seconds => non_neg_integer(), + optional(:thumbnail_location) => Recorder.Manifest.location() + } + + @type t :: %{MediaStreamTrack.stream_id() => file_manifest()} + + @doc false + @spec to_upload_handler_manifest(t()) :: Recorder.S3.UploadHandler.manifest() + def to_upload_handler_manifest(converter_manifest) do + Enum.reduce(converter_manifest, %{}, fn + {id, %{location: file, thumbnail_location: thumbnail}}, acc -> + acc + |> Map.put(id, %{location: file}) + |> Map.put("thumbnail_#{id}", %{location: thumbnail}) + + {id, %{location: file}}, acc -> + Map.put(acc, id, %{location: file}) + end) + end + + @doc false + @spec from_upload_handler_manifest(Recorder.S3.UploadHandler.manifest(), t()) :: t() + def from_upload_handler_manifest(upload_handler_manifest, original_converter_manifest) do + Enum.reduce(upload_handler_manifest, original_converter_manifest, fn + {"thumbnail_" <> id, %{location: thumbnail}}, acc -> + Map.update( + acc, + id, + %{thumbnail_location: thumbnail}, + &Map.put(&1, :thumbnail_location, thumbnail) + ) + + {id, %{location: file}}, acc -> + Map.update(acc, id, %{location: file}, &Map.put(&1, :location, file)) + end) + end +end diff --git a/lib/ex_webrtc_recorder/manifest.ex b/lib/ex_webrtc_recorder/manifest.ex new file mode 100644 index 0000000..4776ec3 --- /dev/null +++ b/lib/ex_webrtc_recorder/manifest.ex @@ -0,0 +1,66 @@ +defmodule ExWebRTC.Recorder.Manifest do + @moduledoc """ + Lists the tracks recorded by a specific Recorder instance. + """ + + alias ExWebRTC.MediaStreamTrack + + @typedoc """ + Location of a manifest entry. + + Can be one of the following: + * Local path, e.g. `"foo/bar/recording.webm"` + * URL with the `file://` scheme, e.g. `"file:///baz/qux/recording.webm"` + * URL with the `s3://` scheme, e.g. `"s3://my-bucket-name/abc/recording.webm"` + """ + @type location :: String.t() + + @type track_manifest :: %{ + start_time: DateTime.t(), + kind: :video | :audio, + streams: [MediaStreamTrack.stream_id()], + rid_map: %{MediaStreamTrack.rid() => integer()}, + location: location() + } + + @type t :: %{MediaStreamTrack.id() => track_manifest()} + + @doc false + @spec from_json!(map()) :: t() + def from_json!(json_manifest) do + Map.new(json_manifest, fn {id, entry} -> + {id, parse_entry(entry)} + end) + end + + defp parse_entry(%{ + "start_time" => start_time, + "kind" => kind, + "streams" => streams, + "rid_map" => rid_map, + "location" => location + }) do + %{ + streams: streams, + location: location, + start_time: parse_start_time(start_time), + rid_map: parse_rid_map(rid_map), + kind: parse_kind(kind) + } + end + + defp parse_start_time(start_time) do + {:ok, start_time, _offset} = DateTime.from_iso8601(start_time) + start_time + end + + defp parse_rid_map(rid_map) do + Map.new(rid_map, fn + {"nil", v} -> {nil, v} + {layer, v} -> {layer, v} + end) + end + + defp parse_kind("video"), do: :video + defp parse_kind("audio"), do: :audio +end diff --git a/lib/ex_webrtc_recorder/s3.ex b/lib/ex_webrtc_recorder/s3.ex new file mode 100644 index 0000000..a5ebda8 --- /dev/null +++ b/lib/ex_webrtc_recorder/s3.ex @@ -0,0 +1,36 @@ +defmodule ExWebRTC.Recorder.S3 do + @moduledoc """ + `ExWebRTC.Recorder` and `ExWebRTC.Recorder.Converter` can optionally upload/download files to/from S3-compatible storage. + + To use this functionality, you must add the following dependencies to your project: + * `:ex_aws_s3` + * `:ex_aws` + * `:sweet_xml` + * an HTTP client (e.g. `:req`) + """ + + @typedoc """ + Options described [here](https://hexdocs.pm/ex_aws_s3/ExAws.S3.html#module-configuration) + and [here](https://hexdocs.pm/ex_aws/readme.html#aws-key-configuration) + (e.g. `:access_key_id`, `:secret_access_key` `:scheme`, `:host`, `:port`, `:region`). + + They can be passed in order to override values defined in the application config (or environment variables). + """ + @type override_option :: {atom(), term()} + @type override_config :: [override_option()] + + @typedoc """ + Options for configuring upload of artifacts to S3-compatible storage. + + * `:bucket_name` (required) - Name of bucket objects will be uploaded to. + * `:base_path` - S3 path prefix used for objects uploaded to the bucket. `""` by default. + """ + @type upload_option :: {:bucket_name, String.t()} | {:base_path, String.t()} + + @type upload_config :: [upload_option() | override_option()] + + @typedoc """ + Reference to a started batch upload task. + """ + @opaque upload_task_ref :: __MODULE__.UploadHandler.ref() +end diff --git a/lib/ex_webrtc_recorder/s3/upload_handler.ex b/lib/ex_webrtc_recorder/s3/upload_handler.ex new file mode 100644 index 0000000..e92f773 --- /dev/null +++ b/lib/ex_webrtc_recorder/s3/upload_handler.ex @@ -0,0 +1,135 @@ +if Enum.each([ExAws.S3, ExAws, SweetXml], &Code.ensure_loaded?/1) do + defmodule ExWebRTC.Recorder.S3.UploadHandler do + @moduledoc false + + alias ExWebRTC.Recorder + require Logger + + @type object_manifest :: %{location: Recorder.Manifest.location()} + @type manifest :: %{String.t() => object_manifest()} + + @opaque ref :: Task.ref() + + @opaque t :: %__MODULE__{ + s3_config_overrides: keyword(), + bucket_name: String.t(), + base_path: Path.t(), + tasks: %{ref() => manifest()} + } + + @enforce_keys [:s3_config_overrides, :bucket_name, :base_path] + defstruct @enforce_keys ++ [tasks: %{}] + + @spec new(keyword()) :: t() + def new(config) do + {:ok, bucket_name} = + config |> Keyword.fetch!(:bucket_name) |> Recorder.S3.Utils.validate_bucket_name() + + base_path = Keyword.get(config, :base_path, "") + {:ok, _test_path} = base_path |> Path.join("a") |> Recorder.S3.Utils.validate_s3_path() + s3_config_overrides = Keyword.drop(config, [:bucket_name, :base_path]) + + %__MODULE__{ + bucket_name: bucket_name, + base_path: base_path, + s3_config_overrides: s3_config_overrides + } + end + + @spec spawn_task(t(), manifest()) :: {ref(), t()} + def spawn_task( + %__MODULE__{bucket_name: bucket_name, s3_config_overrides: s3_config_overrides} = + handler, + manifest + ) do + s3_paths = + Map.new(manifest, fn {id, %{location: path}} -> + s3_path = path |> Path.basename() |> then(&Path.join(handler.base_path, &1)) + + {id, s3_path} + end) + + download_manifest = + Map.new(manifest, fn {id, object_data} -> + {:ok, location} = Recorder.S3.Utils.to_url(bucket_name, s3_paths[id]) + + {id, %{object_data | location: location}} + end) + + # FIXME: this links, ideally we should spawn a supervised task instead + task = Task.async(fn -> upload(manifest, bucket_name, s3_paths, s3_config_overrides) end) + + {task.ref, + %__MODULE__{handler | tasks: Map.put(handler.tasks, task.ref, download_manifest)}} + end + + @spec process_result(t(), {ref(), term()}) :: {:ok | {:error, term()}, manifest(), t()} + def process_result(%__MODULE__{tasks: tasks} = handler, {ref, result}) do + {manifest, tasks} = Map.pop(tasks, ref) + + result = + with true <- manifest != nil, + 0 <- result |> Map.values() |> Enum.count(&match?({:error, _}, &1)) do + Logger.debug("Batch upload task of #{map_size(result)} files successful") + :ok + else + false -> + Logger.warning(""" + Upload handler unable to process result of unknown task with ref #{inspect(ref)}\ + """) + + {:error, :unknown_task} + + fail_count -> + Logger.warning(""" + Failed to upload #{fail_count} of #{map_size(result)} files attempted\ + """) + + {:error, :upload_failed} + end + + {result, manifest, %__MODULE__{handler | tasks: tasks}} + end + + defp upload(manifest, bucket_name, s3_paths, s3_config_overrides) do + Map.new(manifest, fn {id, %{location: path}} -> + %{^id => s3_path} = s3_paths + Logger.debug("Uploading `#{path}` to bucket `#{bucket_name}`, path `#{s3_path}`") + + result = Recorder.S3.Utils.upload_file(path, bucket_name, s3_path, s3_config_overrides) + + case result do + {:ok, _output} -> + Logger.debug( + "Successfully uploaded `#{path}` to bucket `#{bucket_name}`, path `#{s3_path}`" + ) + + {:error, reason} -> + Logger.warning(""" + Upload of `#{path}` to bucket `#{bucket_name}`, path `#{s3_path}` \ + failed with reason #{inspect(reason)}\ + """) + end + + {id, result} + end) + end + end +else + defmodule ExWebRTC.Recorder.S3.UploadHandler do + @moduledoc false + + @opaque ref :: term() + + def new(_), do: error() + def spawn_task(_, _), do: error() + def process_result(_, _), do: error() + + defp error do + raise """ + S3 support is turned off. Add the `:ex_aws_s3`, `:ex_aws` and `:sweet_xml` dependencies to your project \ + in order to upload recordings to S3-compatible storage\ + """ + end + end +end diff --git a/lib/ex_webrtc_recorder/s3/utils.ex b/lib/ex_webrtc_recorder/s3/utils.ex new file mode 100644 index 0000000..f9c035e --- /dev/null +++ b/lib/ex_webrtc_recorder/s3/utils.ex @@ -0,0 +1,83 @@ +if Enum.each([ExAws.S3, ExAws, SweetXml], &Code.ensure_loaded?/1) do + defmodule ExWebRTC.Recorder.S3.Utils do + @moduledoc false + + @spec upload_file(Path.t(), String.t(), String.t(), keyword()) :: {:ok | :error, term()} + def upload_file(path, s3_bucket_name, s3_path, s3_config \\ []) do + path + |> ExAws.S3.Upload.stream_file() + |> ExAws.S3.upload(s3_bucket_name, s3_path) + |> ExAws.request(s3_config) + end + + @spec fetch_file(String.t(), String.t(), Path.t(), keyword()) :: {:ok | :error, term()} + def fetch_file(s3_bucket_name, s3_path, output_path, s3_config \\ []) do + ExAws.S3.download_file(s3_bucket_name, s3_path, output_path) + |> ExAws.request(s3_config) + end + + @spec to_url(String.t(), String.t()) :: {:ok, String.t()} | :error + def to_url(s3_bucket_name, s3_path) do + with {:ok, bucket_name} <- validate_bucket_name(s3_bucket_name), + {:ok, s3_path} <- validate_s3_path(s3_path) do + {:ok, "s3://#{bucket_name}/#{s3_path}"} + else + _other -> :error + end + end + + @spec parse_url(String.t()) :: {:ok, String.t(), String.t()} | :error + def parse_url(url) + + def parse_url("s3://" <> rest) do + with [bucket_name, s3_path] <- String.split(rest, "/", parts: 2), + {:ok, bucket_name} <- validate_bucket_name(bucket_name), + {:ok, s3_path} <- validate_s3_path(s3_path) do + {:ok, bucket_name, s3_path} + else + _other -> :error + end + end + + def parse_url(_other), do: :error + + @spec validate_bucket_name(String.t()) :: {:ok, String.t()} | :error + def validate_bucket_name(name) do + # Based on https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html + # - between 3 and 63 chars + # - only lowercase letters, numbers, dots and hyphens + # - must begin and end with a letter or number + if Regex.match?(~r/^[a-z0-9][a-z0-9\.-]{1,61}[a-z0-9]$/, name), + do: {:ok, name}, + else: :error + end + + @spec validate_s3_path(String.t()) :: {:ok, String.t()} | :error + def validate_s3_path(path) do + # Based on https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html + # - between 1 and 1024 bytes + # - additionally, we're disallowing paths starting with a forward slash + if Regex.match?(~r|^[^/].{0,1023}$|, path), + do: {:ok, path}, + else: :error + end + end +else + defmodule ExWebRTC.Recorder.S3.Utils do + @moduledoc false + + def upload_file(_, _, _, _ \\ nil), do: error() + def fetch_file(_, _, _, _ \\ nil), do: error() + def to_url(_, _), do: error() + def parse_url(_), do: error() + def validate_bucket_name(_), do: error() + def validate_s3_path(_), do: error() + + defp error do + raise """ + S3 support is turned off. Add the `:ex_aws_s3`, `:ex_aws` and `:sweet_xml` dependencies to your project \ + in order to upload and fetch files from S3-compatible storage\ + """ + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..add1aff --- /dev/null +++ b/mix.exs @@ -0,0 +1,72 @@ +defmodule ExWebRTC.Recorder.MixProject do + use Mix.Project + + @version "0.1.0" + @source_url "https://github.com/elixir-webrtc/ex_webrtc_recorder" + + def project do + [ + app: :ex_webrtc_recorder, + version: @version, + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + description: "Records and processes RTP packets sent and received using ExWebRTC", + package: package(), + deps: deps(), + + # docs + source_url: @source_url, + + # dialyzer + dialyzer: [ + plt_local_path: "_dialyzer", + plt_core_path: "_dialyzer" + ], + + # code coverage + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.json": :test + ] + ] + end + + def application do + [ + mod: {ExWebRTC.Recorder.App, []}, + extra_applications: [:logger] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_env), do: ["lib"] + + def package do + [ + licenses: ["Apache-2.0"], + links: %{"GitHub" => @source_url} + ] + end + + defp deps do + [ + {:ex_webrtc, github: "elixir-webrtc/ex_webrtc", branch: "sgfn/extract-recorder"}, + {:jason, "~> 1.4"}, + {:ex_aws_s3, "~> 2.5", optional: true}, + {:ex_aws, "~> 2.5", optional: true}, + {:sweet_xml, "~> 0.7", optional: true}, + {:req, "~> 0.5", optional: true}, + + # dev/test + {:excoveralls, "~> 0.18.0", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..ef91ec0 --- /dev/null +++ b/mix.lock @@ -0,0 +1,46 @@ +%{ + "bunch": {:hex, :bunch, "1.6.1", "5393d827a64d5f846092703441ea50e65bc09f37fd8e320878f13e63d410aec7", [:mix], [], "hexpm", "286cc3add551628b30605efbe2fca4e38cc1bea89bcd0a1a7226920b3364fe4a"}, + "bunch_native": {:hex, :bunch_native, "0.5.0", "8ac1536789a597599c10b652e0b526d8833348c19e4739a0759a2bedfd924e63", [:mix], [{:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "24190c760e32b23b36edeb2dc4852515c7c5b3b8675b1a864e0715bdd1c8f80d"}, + "bundlex": {:hex, :bundlex, "1.5.4", "3726acd463f4d31894a59bbc177c17f3b574634a524212f13469f41c4834a1d9", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "e745726606a560275182a8ac1c8ebd5e11a659bb7460d8abf30f397e59b4c5d2"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "crc": {:hex, :crc, "0.10.5", "ee12a7c056ac498ef2ea985ecdc9fa53c1bfb4e53a484d9f17ff94803707dfd8", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3e673b6495a9525c5c641585af1accba59a1eb33de697bedf341e247012c2c7f"}, + "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_aws": {:hex, :ex_aws, "2.5.8", "0393cfbc5e4a9e7017845451a015d836a670397100aa4c86901980e2a2c5f7d4", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.3", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8f79777b7932168956c8cc3a6db41f5783aa816eb50de356aed3165a71e5f8c3"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.6", "d135983bbd8b6df6350dfd83999437725527c1bea151e5055760bfc9b2d17c20", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "9874e12847e469ca2f13a5689be04e546c16f63caf6380870b7f25bf7cb98875"}, + "ex_doc": {:hex, :ex_doc, "0.37.2", "2a3aa7014094f0e4e286a82aa5194a34dd17057160988b8509b15aa6c292720c", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4dfa56075ce4887e4e8b1dcc121cd5fcb0f02b00391fd367ff5336d98fa49049"}, + "ex_dtls": {:hex, :ex_dtls, "0.16.0", "3ae38025ccc77f6db573e2e391602fa9bbc02253c137d8d2d59469a66cbe806b", [:mix], [{:bundlex, "~> 1.5.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2a4e30d74c6ddf95cc5b796423293c06a0da295454c3823819808ff031b4b361"}, + "ex_ice": {:hex, :ex_ice, "0.9.4", "793121989164e49d8dc64b82bcb7842a4c2e0d224a2f00379ab415293a78c8e7", [:mix], [{:elixir_uuid, "~> 1.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}, {:ex_turn, "~> 0.2.0", [hex: :ex_turn, repo: "hexpm", optional: false]}], "hexpm", "fc328ed721c558440266def81a2cd5138d163164218ebe449fa9a10fcda72574"}, + "ex_libsrtp": {:hex, :ex_libsrtp, "0.7.2", "211bd89c08026943ce71f3e2c0231795b99cee748808ed3ae7b97cd8d2450b6b", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2e20645d0d739a4ecdcf8d4810a0c198120c8a2f617f2b75b2e2e704d59f492a"}, + "ex_rtcp": {:hex, :ex_rtcp, "0.4.0", "f9e515462a9581798ff6413583a25174cfd2101c94a2ebee871cca7639886f0a", [:mix], [], "hexpm", "28956602cf210d692fcdaf3f60ca49681634e1deb28ace41246aee61ee22dc3b"}, + "ex_rtp": {:hex, :ex_rtp, "0.4.0", "1f1b5c1440a904706011e3afbb41741f5da309ce251cb986690ce9fd82636658", [:mix], [], "hexpm", "0f72d80d5953a62057270040f0f1ee6f955c08eeae82ac659c038001d7d5a790"}, + "ex_sdp": {:hex, :ex_sdp, "1.1.1", "1a7b049491e5ec02dad9251c53d960835dc5631321ae978ec331831f3e4f6d5f", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "1b13a72ac9c5c695b8824dbdffc671be8cbb4c0d1ccb4ff76a04a6826759f233"}, + "ex_stun": {:hex, :ex_stun, "0.2.0", "feb1fc7db0356406655b2a617805e6c712b93308c8ea2bf0ba1197b1f0866deb", [:mix], [], "hexpm", "1e01ba8290082ccbf37acaa5190d1f69b51edd6de2026a8d6d51368b29d115d0"}, + "ex_turn": {:hex, :ex_turn, "0.2.0", "4e1f9b089e9a5ee44928d12370cc9ea7a89b84b2f6256832de65271212eb80de", [:mix], [{:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "08e884f0af2c4a147e3f8cd4ffe33e3452a256389f0956e55a8c4d75bf0e74cd"}, + "ex_webrtc": {:git, "https://github.com/elixir-webrtc/ex_webrtc.git", "74d8ca7ad261557bd492abb978f09d3450b705a6", [branch: "sgfn/extract-recorder"]}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.1.2", "8af73b7dc15ba55c9f5fbfc0453d4a8edfb007ade54b56c37d626be0d1189aba", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "7fe3e07361510445a29bee95336adde667c4162b76b7f4c8af3aeb3415292023"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"}, + "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, + "shmex": {:hex, :shmex, "0.5.1", "81dd209093416bf6608e66882cb7e676089307448a1afd4fc906c1f7e5b94cf4", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "c29f8286891252f64c4e1dac40b217d960f7d58def597c4e606ff8fbe71ceb80"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "unifex": {:hex, :unifex, "1.2.1", "6841c170a6e16509fac30b19e4e0a19937c33155a59088b50c15fc2c36251b6b", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}], "hexpm", "8c9d2e3c48df031e9995dd16865bab3df402c0295ba3a31f38274bb5314c7d37"}, + "zarex": {:hex, :zarex, "1.0.5", "58239e3ee5d75f343262bb4df5cf466555a1c689f920e5d3651a9333972f7c7e", [:mix], [], "hexpm", "9fb72ef0567c2b2742f5119a1ba8a24a2fabb21b8d09820aefbf3e592fa9a46a"}, +} From 3a1f26924a15c6544ac33047352abcd6320a25fa Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:35:18 +0100 Subject: [PATCH 2/4] Review fixes --- lib/ex_webrtc_recorder/app.ex | 10 ++++++++ lib/ex_webrtc_recorder/converter.ex | 26 ++++++++++----------- lib/ex_webrtc_recorder/s3/upload_handler.ex | 8 +++++-- 3 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 lib/ex_webrtc_recorder/app.ex diff --git a/lib/ex_webrtc_recorder/app.ex b/lib/ex_webrtc_recorder/app.ex new file mode 100644 index 0000000..cd1ed7a --- /dev/null +++ b/lib/ex_webrtc_recorder/app.ex @@ -0,0 +1,10 @@ +defmodule ExWebRTC.Recorder.App do + @moduledoc false + use Application + + @impl true + def start(_type, _args) do + children = [{Task.Supervisor, name: ExWebRTC.Recorder.TaskSupervisor}] + Supervisor.start_link(children, strategy: :one_for_one) + end +end diff --git a/lib/ex_webrtc_recorder/converter.ex b/lib/ex_webrtc_recorder/converter.ex index 7954fa4..8030833 100644 --- a/lib/ex_webrtc_recorder/converter.ex +++ b/lib/ex_webrtc_recorder/converter.ex @@ -86,10 +86,15 @@ defmodule ExWebRTC.Recorder.Converter do @type options :: [option()] @doc """ - Loads the recording manifest from file, then proceeds with `convert_manifest!/2`. + Converts the saved dumps of tracks in the manifest to WEBM files. + + If passed a path as the first argument, loads the recording manifest from file. """ - @spec convert_path!(Path.t(), options()) :: __MODULE__.Manifest.t() | no_return() - def convert_path!(recorder_manifest_path, options \\ []) do + @spec convert!(Path.t() | Recorder.Manifest.t(), options()) :: + __MODULE__.Manifest.t() | no_return() + def convert!(recorder_manifest_or_path, options \\ []) + + def convert!(recorder_manifest_path, options) when is_binary(recorder_manifest_path) do recorder_manifest_path = recorder_manifest_path |> Path.expand() @@ -106,17 +111,10 @@ defmodule ExWebRTC.Recorder.Converter do |> Jason.decode!() |> Recorder.Manifest.from_json!() - convert_manifest!(recorder_manifest, options) + convert!(recorder_manifest, options) end - @doc """ - Converts the saved dumps of tracks in the manifest to WEBM files. - """ - @spec convert_manifest!(Recorder.Manifest.t(), options()) :: - __MODULE__.Manifest.t() | no_return() - def convert_manifest!(recorder_manifest, options \\ []) - - def convert_manifest!(manifest, options) when map_size(manifest) > 0 do + def convert!(recorder_manifest, options) when map_size(recorder_manifest) > 0 do thumbnails_ctx = case Keyword.get(options, :thumbnails_ctx, nil) do nil -> @@ -143,7 +141,7 @@ defmodule ExWebRTC.Recorder.Converter do end output_manifest = - manifest + recorder_manifest |> fetch_remote_files!(download_path, download_config) |> do_convert_manifest!(output_path, thumbnails_ctx) @@ -170,7 +168,7 @@ defmodule ExWebRTC.Recorder.Converter do result_manifest end - def convert_manifest!(_empty_manifest, _options), do: %{} + def convert!(_empty_manifest, _options), do: %{} defp fetch_remote_files!(manifest, dl_path, dl_config) do Map.new(manifest, fn {track_id, %{location: location} = track_data} -> diff --git a/lib/ex_webrtc_recorder/s3/upload_handler.ex b/lib/ex_webrtc_recorder/s3/upload_handler.ex index e92f773..32c145c 100644 --- a/lib/ex_webrtc_recorder/s3/upload_handler.ex +++ b/lib/ex_webrtc_recorder/s3/upload_handler.ex @@ -56,8 +56,12 @@ if Enum.each([ExAws.S3, ExAws, SweetXml], &Code.ensure_loaded?/1) do {id, %{object_data | location: location}} end) - # FIXME: this links, ideally we should spawn a supervised task instead - task = Task.async(fn -> upload(manifest, bucket_name, s3_paths, s3_config_overrides) end) + # FIXME: this links, ideally we should use `async_nolink` instead + # but this may require a slight change of the current UploadHandler logic + task = + Task.Supervisor.async(ExWebRTC.Recorder.TaskSupervisor, fn -> + upload(manifest, bucket_name, s3_paths, s3_config_overrides) + end) {task.ref, %__MODULE__{handler | tasks: Map.put(handler.tasks, task.ref, download_manifest)}} From 6fbca91a35de85beba828f745d4121749bf311c6 Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Wed, 19 Feb 2025 15:12:41 +0100 Subject: [PATCH 3/4] Update README and mix.exs --- README.md | 15 ++++++++------- mix.exs | 11 +++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4bdc6c5..7a10dca 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,13 @@ def deps do end ``` -TODO add necessary steps +If you want to use Converter to generate WEBM files from the recordings, +you need to have the `ffmpeg` binary with the relevant libraries present in `PATH`. + +### S3 ExWebRTC Recorder comes with optional support for uploading the recordings to S3-compatible storage, -but it must be explicitely turned on by adding the following optional dependencies: -* `:ex_aws_s3` -* `:ex_aws` -* `:sweet_xml` -* an HTTP client (e.g. `:req`) +but it must be explicitly turned on by adding the following dependencies: ```elixir def deps do @@ -33,7 +32,9 @@ def deps do {:ex_aws_s3, "~> 2.5"}, {:ex_aws, "~> 2.5"}, {:sweet_xml, "~> 0.7"}, - {:req, "~> 0.5"} + {:req, "~> 0.5"} # or any other HTTP client supported by `ex_aws` ] end ``` + +See `ExWebRTC.Recorder.S3` for more info. diff --git a/mix.exs b/mix.exs index add1aff..faec6b4 100644 --- a/mix.exs +++ b/mix.exs @@ -16,6 +16,7 @@ defmodule ExWebRTC.Recorder.MixProject do deps: deps(), # docs + docs: docs(), source_url: @source_url, # dialyzer @@ -69,4 +70,14 @@ defmodule ExWebRTC.Recorder.MixProject do {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} ] end + + defp docs do + [ + main: "readme", + extras: ["README.md"], + source_ref: "v#{@version}", + formatters: ["html"], + nest_modules_by_prefix: [ExWebRTC.Recorder] + ] + end end From 3b8389587255402c20cad075abb47cb97a921917 Mon Sep 17 00:00:00 2001 From: Jakub Pisarek <99591440+sgfn@users.noreply.github.com> Date: Wed, 19 Feb 2025 16:00:31 +0100 Subject: [PATCH 4/4] Update ex_webrtc dep --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index faec6b4..98f10e6 100644 --- a/mix.exs +++ b/mix.exs @@ -56,7 +56,7 @@ defmodule ExWebRTC.Recorder.MixProject do defp deps do [ - {:ex_webrtc, github: "elixir-webrtc/ex_webrtc", branch: "sgfn/extract-recorder"}, + {:ex_webrtc, github: "elixir-webrtc/ex_webrtc"}, {:jason, "~> 1.4"}, {:ex_aws_s3, "~> 2.5", optional: true}, {:ex_aws, "~> 2.5", optional: true}, diff --git a/mix.lock b/mix.lock index ef91ec0..9921afc 100644 --- a/mix.lock +++ b/mix.lock @@ -21,7 +21,7 @@ "ex_sdp": {:hex, :ex_sdp, "1.1.1", "1a7b049491e5ec02dad9251c53d960835dc5631321ae978ec331831f3e4f6d5f", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "1b13a72ac9c5c695b8824dbdffc671be8cbb4c0d1ccb4ff76a04a6826759f233"}, "ex_stun": {:hex, :ex_stun, "0.2.0", "feb1fc7db0356406655b2a617805e6c712b93308c8ea2bf0ba1197b1f0866deb", [:mix], [], "hexpm", "1e01ba8290082ccbf37acaa5190d1f69b51edd6de2026a8d6d51368b29d115d0"}, "ex_turn": {:hex, :ex_turn, "0.2.0", "4e1f9b089e9a5ee44928d12370cc9ea7a89b84b2f6256832de65271212eb80de", [:mix], [{:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "08e884f0af2c4a147e3f8cd4ffe33e3452a256389f0956e55a8c4d75bf0e74cd"}, - "ex_webrtc": {:git, "https://github.com/elixir-webrtc/ex_webrtc.git", "74d8ca7ad261557bd492abb978f09d3450b705a6", [branch: "sgfn/extract-recorder"]}, + "ex_webrtc": {:git, "https://github.com/elixir-webrtc/ex_webrtc.git", "d3ec3c94f371e72ab5550c701ff023b0a2fd5905", []}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},