diff --git a/lib/ex_hls/client.ex b/lib/ex_hls/client.ex index 4ee9f0f..1f31390 100644 --- a/lib/ex_hls/client.ex +++ b/lib/ex_hls/client.ex @@ -22,7 +22,8 @@ defmodule ExHLS.Client do :vod_client, :live_reader, :live_forwarder, - :how_much_to_skip_ms + :how_much_to_skip_ms, + :segment_format ] defstruct @enforce_keys @@ -50,16 +51,29 @@ defmodule ExHLS.Client do of the beginning of the stream should be skipped. This option is only supported when the HLS stream is in the VoD mode. Defaults to `0`. + Apart from that you can also pass `:segment_format` to force treating HLS segments + as either `MPEG-TS` or `CMAF` container files. If you don't provide this option, + the client will treat HLS segments based on the extension in their name, + falling back `MPEG-TS` if the cannot recognize the extension. + 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` function. """ - @spec new(String.t(), parent_process: pid(), how_much_to_skip_ms: non_neg_integer()) :: client() + @spec new(String.t(), + parent_process: pid(), + how_much_to_skip_ms: non_neg_integer(), + segment_format: :ts | :cmaf + ) :: client() def new(url, opts \\ []) do - %{parent_process: parent_process, how_much_to_skip_ms: how_much_to_skip_ms} = + %{ + parent_process: parent_process, + how_much_to_skip_ms: how_much_to_skip_ms, + segment_format: segment_format + } = opts - |> Keyword.validate!(parent_process: self(), how_much_to_skip_ms: 0) + |> Keyword.validate!(parent_process: self(), how_much_to_skip_ms: 0, segment_format: nil) |> Map.new() root_playlist_raw_content = Utils.download_or_read_file!(url) @@ -79,7 +93,8 @@ defmodule ExHLS.Client do vod_client: nil, live_reader: nil, live_forwarder: nil, - how_much_to_skip_ms: how_much_to_skip_ms + how_much_to_skip_ms: how_much_to_skip_ms, + segment_format: segment_format } |> maybe_resolve_media_playlist() end @@ -109,7 +124,8 @@ defmodule ExHLS.Client do ExHLS.Client.VOD.new( client.media_playlist_url, client.media_playlist, - client.how_much_to_skip_ms + client.how_much_to_skip_ms, + client.segment_format ) %{client | vod_client: vod_client, hls_mode: :vod} @@ -128,7 +144,14 @@ defmodule ExHLS.Client do end {:ok, forwarder} = ExHLS.Client.Live.Forwarder.start_link(client.parent_process) - {:ok, reader} = ExHLS.Client.Live.Reader.start_link(client.media_playlist_url, forwarder) + + {:ok, reader} = + ExHLS.Client.Live.Reader.start_link( + client.media_playlist_url, + forwarder, + client.segment_format + ) + %{client | live_reader: reader, live_forwarder: forwarder, hls_mode: :live} end end @@ -185,7 +208,12 @@ defmodule ExHLS.Client do defp do_choose_variant(%__MODULE__{} = client, variant_id) do chosen_variant = get_variants(client) |> Map.fetch!(variant_id) - media_playlist_url = Path.join(client.base_url, chosen_variant.uri) + + media_playlist_url = + case URI.new!(chosen_variant.uri).host do + nil -> Path.join(client.base_url, chosen_variant.uri) + _some_host -> chosen_variant.uri + end media_playlist = media_playlist_url diff --git a/lib/ex_hls/client/live/reader.ex b/lib/ex_hls/client/live/reader.ex index 3415418..99457a3 100644 --- a/lib/ex_hls/client/live/reader.ex +++ b/lib/ex_hls/client/live/reader.ex @@ -10,16 +10,21 @@ defmodule ExHLS.Client.Live.Reader do alias ExM3U8.Tags.{MediaInit, Segment} - @spec start_link(String.t(), Forwarder.t()) :: {:ok, pid()} | {:error, any()} - def start_link(media_playlist_url, forwarder) do + @spec start_link(String.t(), Forwarder.t(), :ts | :cmaf | nil) :: {:ok, pid()} | {:error, any()} + def start_link(media_playlist_url, forwarder, segment_format) do GenServer.start_link(__MODULE__, %{ media_playlist_url: media_playlist_url, - forwarder: forwarder + forwarder: forwarder, + segment_format: segment_format }) end @impl true - def init(%{media_playlist_url: media_playlist_url, forwarder: forwarder}) do + def init(%{ + media_playlist_url: media_playlist_url, + forwarder: forwarder, + segment_format: segment_format + }) do state = %{ forwarder: forwarder, tracks_data: nil, @@ -34,7 +39,8 @@ defmodule ExHLS.Client.Live.Reader do max_downloaded_seq_num: nil, playlist_check_scheduled?: false, timestamp_offset: nil, - playing_started?: false + playing_started?: false, + segment_format: segment_format } {:ok, state, {:continue, :setup}} @@ -212,7 +218,12 @@ defmodule ExHLS.Client.Live.Reader do end defp download_and_consume_segment(segment, state) do - uri = Path.join(state.media_base_url, segment.uri) + uri = + case URI.new!(segment.uri).host do + nil -> Path.join(state.media_base_url, segment.uri) + _some_host -> segment.uri + end + Logger.debug("[ExHLS.Client] Downloading segment: #{uri}") segment_content = Utils.download_or_read_file!(uri) @@ -390,7 +401,7 @@ defmodule ExHLS.Client.Live.Reader do defp doesnt_exist_or_empty?([track_data]), do: track_data.empty? defp maybe_resolve_demuxing_engine(segment_uri, %{demuxing_engine: nil} = state) do - demuxing_engine_impl = Utils.resolve_demuxing_engine_impl(segment_uri) + demuxing_engine_impl = Utils.resolve_demuxing_engine_impl(segment_uri, state.segment_format) %{ state diff --git a/lib/ex_hls/client/utils.ex b/lib/ex_hls/client/utils.ex index 9bd245b..4908a7d 100644 --- a/lib/ex_hls/client/utils.ex +++ b/lib/ex_hls/client/utils.ex @@ -42,15 +42,28 @@ defmodule ExHLS.Client.Utils do def stream_format_to_media_type(%RemoteStream{content_format: H264}), do: :video def stream_format_to_media_type(%RemoteStream{content_format: AAC}), do: :audio - @spec resolve_demuxing_engine_impl(String.t()) :: atom() - def resolve_demuxing_engine_impl(segment_uri) do - URI.parse(segment_uri).path - |> Path.extname() - |> case do - ".ts" -> DemuxingEngine.MPEGTS - ".m4s" -> DemuxingEngine.CMAF - ".mp4" -> DemuxingEngine.CMAF - _other -> raise "Unsupported segment URI extension: #{segment_uri |> inspect()}" + @spec resolve_demuxing_engine_impl(String.t(), :ts | :cmaf | nil) :: atom() + def resolve_demuxing_engine_impl(segment_uri, nil) do + case Path.extname(segment_uri) do + ".ts" <> _id -> + DemuxingEngine.MPEGTS + + ".m4s" <> _id -> + DemuxingEngine.CMAF + + ".mp4" <> _id -> + DemuxingEngine.CMAF + + _other -> + Logger.warning(""" + Unsupported segment URI extension: #{segment_uri |> inspect()} + Falling back to recognizing segment as MPEG-TS container file. + You can force recognizing segment as CMAF container file + by providing `segment_format: :cmaf` to `ExHLS.Client/2`. + """) end end + + def resolve_demuxing_engine_impl(_segment_uri, :ts), do: DemuxingEngine.MPEGTS + def resolve_demuxing_engine_impl(_segment_uri, :cmaf), do: DemuxingEngine.CMAF end diff --git a/lib/ex_hls/client/vod.ex b/lib/ex_hls/client/vod.ex index 4d1b2e9..6ec3f1e 100644 --- a/lib/ex_hls/client/vod.ex +++ b/lib/ex_hls/client/vod.ex @@ -24,7 +24,8 @@ defmodule ExHLS.Client.VOD do :end_stream_executed?, :stream_ended_by_media_type, :how_much_to_skip_ms, - :skipped_segments_cumulative_duration_ms + :skipped_segments_cumulative_duration_ms, + :segment_format ] defstruct @enforce_keys @@ -37,8 +38,9 @@ defmodule ExHLS.Client.VOD do By default, it uses `DemuxingEngine.MPEGTS` as the demuxing engine implementation. """ - @spec new(String.t(), ExM3U8.MediaPlaylist.t(), non_neg_integer()) :: client() - def new(media_playlist_url, media_playlist, how_much_to_skip_ms) do + @spec new(String.t(), ExM3U8.MediaPlaylist.t(), non_neg_integer(), :ts | :cmaf | nil) :: + client() + def new(media_playlist_url, media_playlist, how_much_to_skip_ms, segment_format) do :ok = generate_discontinuity_warnings(media_playlist) last_timestamps = %{audio: %{returned: nil, read: nil}, video: %{returned: nil, read: nil}} @@ -54,7 +56,8 @@ defmodule ExHLS.Client.VOD do end_stream_executed?: false, stream_ended_by_media_type: %{audio: false, video: false}, how_much_to_skip_ms: how_much_to_skip_ms, - skipped_segments_cumulative_duration_ms: nil + skipped_segments_cumulative_duration_ms: nil, + segment_format: segment_format } |> skip_segments() end @@ -279,7 +282,7 @@ defmodule ExHLS.Client.VOD do end defp ensure_demuxing_engine_resolved(%{demuxing_engine: nil} = client, segment_uri) do - demuxing_engine_impl = Utils.resolve_demuxing_engine_impl(segment_uri) + demuxing_engine_impl = Utils.resolve_demuxing_engine_impl(segment_uri, client.segment_format) %{ client diff --git a/mix.exs b/mix.exs index 2a54728..ec91b52 100644 --- a/mix.exs +++ b/mix.exs @@ -37,7 +37,7 @@ defmodule ExHLS.Mixfile do defp deps do [ - {:ex_m3u8, "~> 0.15.3"}, + {:ex_m3u8, "~> 0.15.4"}, {:req, "~> 0.5.10"}, {:qex, "~> 0.5.1"}, {:membrane_mp4_plugin, "~> 0.36.0"}, diff --git a/mix.lock b/mix.lock index 5170225..0cc950e 100644 --- a/mix.lock +++ b/mix.lock @@ -8,7 +8,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.38.4", "ab48dff7a8af84226bf23baddcdda329f467255d924380a0cf0cee97bb9a9ede", [:mix], [{:earmark_parser, "~> 1.4.44", [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", "f7b62346408a83911c2580154e35613eb314e0278aeea72ed7fedef9c1f165b2"}, - "ex_m3u8": {:hex, :ex_m3u8, "0.15.3", "c10427f450b2ed7bfd85808d8dce21214f1fe9fa18927591cbbf96fea0a6a8aa", [:mix], [{:nimble_parsec, "~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "99f20c0b44bab130dc6aca71fefe0d1a174413ae9ac2763220994b29bd310939"}, + "ex_m3u8": {:hex, :ex_m3u8, "0.15.4", "66f6ec7e4fb7372c48032db1c2d4a3e6c2bbbde2d1d9a1098986e3caa0ab7a55", [:mix], [{:nimble_parsec, "~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "ec03aa516919e0c8ec202da55f609b763bd7960195a3388900090fcad270c873"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "heap": {:hex, :heap, "2.0.2", "d98cb178286cfeb5edbcf17785e2d20af73ca57b5a2cf4af584118afbcf917eb", [:mix], [], "hexpm", "ba9ea2fe99eb4bcbd9a8a28eaf71cbcac449ca1d8e71731596aace9028c9d429"},