diff --git a/lib/ex_hls/client.ex b/lib/ex_hls/client.ex index ac94623..a198c83 100644 --- a/lib/ex_hls/client.ex +++ b/lib/ex_hls/client.ex @@ -24,7 +24,8 @@ defmodule ExHLS.Client do :live_reader, :live_forwarder, :how_much_to_skip_ms, - :segment_format + :segment_format, + :live_edge_mode? ] defstruct @enforce_keys @@ -57,6 +58,13 @@ defmodule ExHLS.Client do the client will treat HLS segments based on the extension in their name, falling back `MPEG-TS` if the cannot recognize the extension. + Passing `live_edge_mode?: true` option turns on live edge mode of the client (please do not + confuse it with the Low Latency HLS extension which is not supported by the client!). + In this mode the client starts playing the playlist as fast as possible, and skips to the most + recent segment. + Please note that this is not compliant with the HLS specification and might cause playback stalls. + The live edge mode is turned off by default. + Note that there is no guarantee that exactly the specified amount of time will be skipped. The actual skipped duration may be slightly shorter, depending on the HLS segments durations. To get the actual skipped duration, you can use `get_skipped_segments_cumulative_duration_ms/1` @@ -71,10 +79,16 @@ defmodule ExHLS.Client do %{ parent_process: parent_process, how_much_to_skip_ms: how_much_to_skip_ms, - segment_format: segment_format + segment_format: segment_format, + live_edge_mode?: live_edge_mode? } = opts - |> Keyword.validate!(parent_process: self(), how_much_to_skip_ms: 0, segment_format: nil) + |> Keyword.validate!( + parent_process: self(), + how_much_to_skip_ms: 0, + segment_format: nil, + live_edge_mode?: false + ) |> Map.new() root_playlist_raw_content = Utils.download_or_read_file!(url) @@ -100,7 +114,8 @@ defmodule ExHLS.Client do live_reader: nil, live_forwarder: nil, how_much_to_skip_ms: how_much_to_skip_ms, - segment_format: segment_format + segment_format: segment_format, + live_edge_mode?: live_edge_mode? } |> maybe_resolve_media_playlist() end @@ -155,7 +170,8 @@ defmodule ExHLS.Client do ExHLS.Client.Live.Reader.start_link( client.media_playlist_url, forwarder, - client.segment_format + client.segment_format, + client.live_edge_mode? ) %{client | live_reader: reader, live_forwarder: forwarder, hls_mode: :live} diff --git a/lib/ex_hls/client/live/reader.ex b/lib/ex_hls/client/live/reader.ex index 893c16b..13d3e0a 100644 --- a/lib/ex_hls/client/live/reader.ex +++ b/lib/ex_hls/client/live/reader.ex @@ -9,12 +9,14 @@ defmodule ExHLS.Client.Live.Reader do alias ExHLS.Client.Live.Forwarder alias ExM3U8.Tags.{MediaInit, Segment} - @spec start_link(String.t(), Forwarder.t(), :ts | :cmaf | nil) :: {:ok, pid()} | {:error, any()} - def start_link(media_playlist_url, forwarder, segment_format) do + @spec start_link(String.t(), Forwarder.t(), :ts | :cmaf | nil, boolean()) :: + {:ok, pid()} | {:error, any()} + def start_link(media_playlist_url, forwarder, segment_format, live_edge_mode?) do GenServer.start_link(__MODULE__, %{ media_playlist_url: media_playlist_url, forwarder: forwarder, - segment_format: segment_format + segment_format: segment_format, + live_edge_mode?: live_edge_mode? }) end @@ -22,7 +24,8 @@ defmodule ExHLS.Client.Live.Reader do def init(%{ media_playlist_url: media_playlist_url, forwarder: forwarder, - segment_format: segment_format + segment_format: segment_format, + live_edge_mode?: live_edge_mode? }) do state = %{ forwarder: forwarder, @@ -39,7 +42,8 @@ defmodule ExHLS.Client.Live.Reader do playlist_check_scheduled?: false, timestamp_offset: nil, playing_started?: false, - segment_format: segment_format + segment_format: segment_format, + live_edge_mode?: live_edge_mode? } {:ok, state, {:continue, :setup}} @@ -112,7 +116,7 @@ defmodule ExHLS.Client.Live.Reader do end_list?: end_list? } = state.media_playlist.info - end_list? or + end_list? or state.live_edge_mode? or (is_number(media_sequence) and media_sequence >= 1) or segments_duration_sum(state) >= 2 * target_duration end @@ -165,7 +169,20 @@ defmodule ExHLS.Client.Live.Reader do {media_inits, %{state | media_init_downloaded?: true}} end - defp next_segment_to_download_seq_num(%{max_downloaded_seq_num: nil} = state) do + # in the live edge mode it skips to the most recent segment + defp next_segment_to_download_seq_num( + %{max_downloaded_seq_num: nil, live_edge_mode?: true} = state + ) do + how_many_segments = + state.media_playlist.timeline + |> Enum.count(&match?(%Segment{}, &1)) + + state.media_playlist.info.media_sequence + how_many_segments - 1 + end + + defp next_segment_to_download_seq_num( + %{max_downloaded_seq_num: nil, live_edge_mode?: false} = state + ) do {segments_with_end_times, duration_sum} = state.media_playlist.timeline |> Enum.flat_map_reduce(0, fn diff --git a/test/client_test.exs b/test/client_test.exs index f8907b4..9dc4b97 100644 --- a/test/client_test.exs +++ b/test/client_test.exs @@ -9,6 +9,7 @@ defmodule ExHLS.Client.Test do @fmp4_only_video_url @fixtures <> "fmp4_only_video/output.m3u8" @mpegts_only_video_url @fixtures <> "mpeg_ts_only_video/output_playlist.m3u8" @mpegts_url "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8" + @mpegts_live_url "./test/fixtures/mpeg_ts_live/output_playlist.m3u8" describe "if client reads video and audio chunks of the HLS" do test "(MPEGTS) stream" do @@ -203,6 +204,28 @@ defmodule ExHLS.Client.Test do 215, 198, 77, 184, 229, 170, 157, 115, 169, 223>> <> _rest = audio_chunk.payload end + test "(MPEGTS) stream with live edge mode" do + client = Client.new(@mpegts_live_url, live_edge_mode?: true) + + assert Client.get_variants(client) == %{} + assert {:ok, tracks_info, client} = Client.get_tracks_info(client) + + assert [%Membrane.RemoteStream{content_format: Membrane.H264, type: :bytestream}] = + tracks_info |> Map.values() + + chunks = Client.generate_stream(client) |> Enum.take(1) + [video_chunk | _rest_video_chunks] = chunks + + assert %{pts_ms: 11_081, dts_ms: 11_001} = video_chunk + assert byte_size(video_chunk.payload) == 28_699 + + assert <<0, 0, 0, 1, 9, 240, 0, 0, 0, 1, 103, 100, 0, 21, 172, 217, 65, 224, 143, 235, 1, 106, + 12, 2, 13, 110, 0, 0, 9, 154, 0, 1, 224, 0, 30, 44, 91, 44, 0, 0, 0, 1, 104, 234, + 225, 178, 200, 176, 0, 0>> <> _rest = video_chunk.payload + + assert video_chunk.metadata == %{discontinuity: false, is_aligned: false} + end + defp assert_chunks_are_in_proper_order(chunks) do iteration_state = %{ last_dts: %{audio: nil, video: nil}, diff --git a/test/fixtures/mpeg_ts_live/output_playlist.m3u8 b/test/fixtures/mpeg_ts_live/output_playlist.m3u8 new file mode 100644 index 0000000..ad1fc9d --- /dev/null +++ b/test/fixtures/mpeg_ts_live/output_playlist.m3u8 @@ -0,0 +1,10 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:5.760933, +video_segment_000.ts +#EXTINF:3.840633, +video_segment_001.ts +#EXTINF:0.400111, +video_segment_002.ts diff --git a/test/fixtures/mpeg_ts_live/video_segment_000.ts b/test/fixtures/mpeg_ts_live/video_segment_000.ts new file mode 100644 index 0000000..17fb2f5 Binary files /dev/null and b/test/fixtures/mpeg_ts_live/video_segment_000.ts differ diff --git a/test/fixtures/mpeg_ts_live/video_segment_001.ts b/test/fixtures/mpeg_ts_live/video_segment_001.ts new file mode 100644 index 0000000..a0e9f4e Binary files /dev/null and b/test/fixtures/mpeg_ts_live/video_segment_001.ts differ diff --git a/test/fixtures/mpeg_ts_live/video_segment_002.ts b/test/fixtures/mpeg_ts_live/video_segment_002.ts new file mode 100644 index 0000000..05ac6df Binary files /dev/null and b/test/fixtures/mpeg_ts_live/video_segment_002.ts differ