lib/membrane_http_adaptive_stream/hls.ex

defmodule Membrane.HTTPAdaptiveStream.HLS do
  @moduledoc """
  `Membrane.HTTPAdaptiveStream.Manifest` implementation for HTTP Live Streaming.

  Currently supports up to one audio and video stream.
  """
  @behaviour Membrane.HTTPAdaptiveStream.Manifest

  use Ratio

  alias Membrane.HTTPAdaptiveStream.{BandwidthCalculator, Manifest}
  alias Membrane.Time

  @version 7

  @master_playlist_header """
                          #EXTM3U
                          #EXT-X-VERSION:#{@version}
                          #EXT-X-INDEPENDENT-SEGMENTS
                          """
                          |> String.trim()

  @empty_segments Qex.new()
  @default_audio_track_id "audio_default_id"
  @default_audio_track_name "audio_default_name"

  defmodule SegmentAttribute do
    @moduledoc """
    Implementation of `Membrane.HTTPAdaptiveStream.Manifest.SegmentAttribute` behaviour for HTTP Live Streaming
    """
    @behaviour Membrane.HTTPAdaptiveStream.Manifest.SegmentAttribute

    import Membrane.HTTPAdaptiveStream.Manifest.SegmentAttribute

    @impl true
    def serialize(discontinuity(header_name, number)) do
      [
        "#EXT-X-DISCONTINUITY-SEQUENCE:#{number}",
        "#EXT-X-DISCONTINUITY",
        "#EXT-X-MAP:URI=#{header_name}"
      ]
    end
  end

  @doc """
  Generates EXTM3U playlist for the given manifest
  """
  @impl true
  def serialize(%Manifest{} = manifest) do
    tracks_by_content =
      manifest.tracks
      |> Map.values()
      |> Enum.group_by(& &1.content_type)

    main_manifest_name = "#{manifest.name}.m3u8"

    if length(Map.get(tracks_by_content, :audio, [])) > 1 do
      raise ArgumentError, message: "Multiple audio tracks are not currently supported."
    end

    # Depending on tracks present in the manifest, generate master playlist and playlists for each track
    case tracks_by_content do
      # Handling muxed content - where audio and video is contained in a single CMAF Track
      %{muxed: muxed_tracks} ->
        List.flatten([
          {main_manifest_name, build_master_playlist({nil, muxed_tracks})},
          muxed_tracks
          |> Enum.filter(&(&1.segments != @empty_segments))
          |> Enum.map(&{build_media_playlist_path(&1), serialize_track(&1)})
        ])

      # Handle audio track and multiple renditions of video
      %{audio: [audio], video: videos} ->
        List.flatten([
          {main_manifest_name, build_master_playlist({audio, videos})},
          {"audio.m3u8", serialize_track(audio)},
          videos
          |> Enum.filter(&(&1.segments != @empty_segments))
          |> Enum.map(&{build_media_playlist_path(&1), serialize_track(&1)})
        ])

      # Handle only audio, without any video tracks
      %{audio: [audio]} ->
        [
          {main_manifest_name, build_master_playlist({audio, nil})},
          {"audio.m3u8", serialize_track(audio)}
        ]

      # Handle video without audio
      %{video: videos} ->
        List.flatten([
          {main_manifest_name, build_master_playlist({nil, videos})},
          videos
          |> Enum.filter(&(&1.segments != @empty_segments))
          |> Enum.map(&{build_media_playlist_path(&1), serialize_track(&1)})
        ])
    end
  end

  defp build_media_playlist_path(%Manifest.Track{} = track) do
    [track.content_type, "_", track.track_name, ".m3u8"] |> Enum.join("")
  end

  defp build_media_playlist_tag(%Manifest.Track{} = track) do
    case track do
      %Manifest.Track{content_type: :audio} ->
        """
        #EXT-X-MEDIA:TYPE=AUDIO,NAME="#{@default_audio_track_name}",GROUP-ID="#{@default_audio_track_id}",AUTOSELECT=YES,DEFAULT=YES,URI="audio.m3u8"
        """
        |> String.trim()

      %Manifest.Track{content_type: type} when type in [:video, :muxed] ->
        """
        #EXT-X-STREAM-INF:BANDWIDTH=#{BandwidthCalculator.calculate_bandwidth(track)},CODECS="avc1.42e00a"
        """
        |> String.trim()
    end
  end

  defp build_master_playlist(tracks) do
    case tracks do
      {audio, nil} ->
        [@master_playlist_header, build_media_playlist_tag(audio)]
        |> Enum.join("")

      {nil, videos} ->
        [
          @master_playlist_header
          | videos
            |> Enum.filter(&(&1.segments != @empty_segments))
            |> Enum.flat_map(&[build_media_playlist_tag(&1), build_media_playlist_path(&1)])
        ]
        |> Enum.join("\n")

      {audio, videos} ->
        video_tracks =
          videos
          |> Enum.filter(&(&1.segments != @empty_segments))
          |> Enum.flat_map(
            &[
              "#{build_media_playlist_tag(&1)},AUDIO=\"#{@default_audio_track_id}\"",
              build_media_playlist_path(&1)
            ]
          )

        [
          @master_playlist_header,
          build_media_playlist_tag(audio),
          video_tracks
        ]
        |> List.flatten()
        |> Enum.join("\n")
    end
  end

  defp serialize_track(%Manifest.Track{} = track) do
    target_duration = Ratio.ceil(track.target_segment_duration / Time.second()) |> trunc()

    """
    #EXTM3U
    #EXT-X-VERSION:#{@version}
    #EXT-X-TARGETDURATION:#{target_duration}
    #EXT-X-MEDIA-SEQUENCE:#{track.current_seq_num}
    #EXT-X-DISCONTINUITY-SEQUENCE:#{track.current_discontinuity_seq_num}
    #EXT-X-MAP:URI="#{track.header_name}"
    #{track.segments |> Enum.flat_map(&serialize_segment/1) |> Enum.join("\n")}
    #{if track.finished?, do: "#EXT-X-ENDLIST", else: ""}
    """
  end

  defp serialize_segment(segment) do
    time = Ratio.to_float(segment.duration / Time.second())

    Enum.flat_map(segment.attributes, &SegmentAttribute.serialize/1) ++
      ["#EXTINF:#{time},", segment.name]
  end
end