MediaParserHlsMediaChunkExtractor.java

/*
 * Copyright 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.media3.exoplayer.hls;

import static android.media.MediaParser.PARAMETER_TS_IGNORE_AAC_STREAM;
import static android.media.MediaParser.PARAMETER_TS_IGNORE_AVC_STREAM;
import static android.media.MediaParser.PARAMETER_TS_IGNORE_SPLICE_INFO_STREAM;
import static android.media.MediaParser.PARAMETER_TS_MODE;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_EXPOSE_CAPTION_FORMATS;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_IGNORE_TIMESTAMP_OFFSET;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_IN_BAND_CRYPTO_INFO;
import static androidx.media3.exoplayer.source.mediaparser.MediaParserUtil.PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS;

import android.annotation.SuppressLint;
import android.media.MediaFormat;
import android.media.MediaParser;
import android.media.MediaParser.OutputConsumer;
import android.media.MediaParser.SeekPoint;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.media3.common.FileTypes;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.source.mediaparser.InputReaderAdapterV30;
import androidx.media3.exoplayer.source.mediaparser.MediaParserUtil;
import androidx.media3.exoplayer.source.mediaparser.OutputConsumerAdapterV30;
import androidx.media3.extractor.ExtractorInput;
import androidx.media3.extractor.ExtractorOutput;
import com.google.common.collect.ImmutableList;
import java.io.IOException;

/** {@link HlsMediaChunkExtractor} implemented on top of the platform's {@link MediaParser}. */
@RequiresApi(30)
@UnstableApi
public final class MediaParserHlsMediaChunkExtractor implements HlsMediaChunkExtractor {

  /**
   * {@link HlsExtractorFactory} implementation that produces {@link
   * MediaParserHlsMediaChunkExtractor} for all container formats except WebVTT, for which a {@link
   * BundledHlsMediaChunkExtractor} is returned.
   */
  public static final HlsExtractorFactory FACTORY =
      (uri,
          format,
          muxedCaptionFormats,
          timestampAdjuster,
          responseHeaders,
          sniffingExtractorInput,
          playerId) -> {
        if (FileTypes.inferFileTypeFromMimeType(format.sampleMimeType) == FileTypes.WEBVTT) {
          // The segment contains WebVTT. MediaParser does not support WebVTT parsing, so we use the
          // bundled extractor.
          return new BundledHlsMediaChunkExtractor(
              new WebvttExtractor(format.language, timestampAdjuster), format, timestampAdjuster);
        }

        boolean overrideInBandCaptionDeclarations = muxedCaptionFormats != null;
        ImmutableList.Builder<MediaFormat> muxedCaptionMediaFormatsBuilder =
            ImmutableList.builder();
        if (muxedCaptionFormats != null) {
          // The manifest contains captions declarations. We use those to determine which captions
          // will be exposed by MediaParser.
          for (int i = 0; i < muxedCaptionFormats.size(); i++) {
            muxedCaptionMediaFormatsBuilder.add(
                MediaParserUtil.toCaptionsMediaFormat(muxedCaptionFormats.get(i)));
          }
        } else {
          // The manifest does not declare any captions in the stream. Imitate the default HLS
          // extractor factory and declare a 608 track by default.
          muxedCaptionMediaFormatsBuilder.add(
              MediaParserUtil.toCaptionsMediaFormat(
                  new Format.Builder().setSampleMimeType(MimeTypes.APPLICATION_CEA608).build()));
        }

        ImmutableList<MediaFormat> muxedCaptionMediaFormats =
            muxedCaptionMediaFormatsBuilder.build();

        // TODO: Factor out code for optimizing the sniffing order across both factories.
        OutputConsumerAdapterV30 outputConsumerAdapter = new OutputConsumerAdapterV30();
        outputConsumerAdapter.setMuxedCaptionFormats(
            muxedCaptionFormats != null ? muxedCaptionFormats : ImmutableList.of());
        outputConsumerAdapter.setTimestampAdjuster(timestampAdjuster);
        MediaParser mediaParser =
            createMediaParserInstance(
                outputConsumerAdapter,
                format,
                overrideInBandCaptionDeclarations,
                muxedCaptionMediaFormats,
                playerId,
                MediaParser.PARSER_NAME_FMP4,
                MediaParser.PARSER_NAME_AC3,
                MediaParser.PARSER_NAME_AC4,
                MediaParser.PARSER_NAME_ADTS,
                MediaParser.PARSER_NAME_MP3,
                MediaParser.PARSER_NAME_TS);

        PeekingInputReader peekingInputReader = new PeekingInputReader(sniffingExtractorInput);
        // The chunk extractor constructor requires an instance with a known parser name, so we
        // advance once for MediaParser to sniff the content.
        mediaParser.advance(peekingInputReader);
        outputConsumerAdapter.setSelectedParserName(mediaParser.getParserName());

        return new MediaParserHlsMediaChunkExtractor(
            mediaParser,
            outputConsumerAdapter,
            format,
            overrideInBandCaptionDeclarations,
            muxedCaptionMediaFormats,
            /* leadingBytesToSkip= */ peekingInputReader.totalPeekedBytes,
            playerId);
      };

  private final OutputConsumerAdapterV30 outputConsumerAdapter;
  private final InputReaderAdapterV30 inputReaderAdapter;
  private final MediaParser mediaParser;
  private final Format format;
  private final boolean overrideInBandCaptionDeclarations;
  private final ImmutableList<MediaFormat> muxedCaptionMediaFormats;
  private final PlayerId playerId;

  private int pendingSkipBytes;

  /**
   * Creates a new instance.
   *
   * @param mediaParser The {@link MediaParser} instance to use for extraction of segments. The
   *     provided instance must have completed sniffing, or must have been created by name.
   * @param outputConsumerAdapter The {@link OutputConsumerAdapterV30} with which {@code
   *     mediaParser} was created.
   * @param format The {@link Format} associated with the segment.
   * @param overrideInBandCaptionDeclarations Whether to ignore any in-band caption track
   *     declarations in favor of using the {@code muxedCaptionMediaFormats} instead. If false,
   *     caption declarations found in the extracted media will be used, causing {@code
   *     muxedCaptionMediaFormats} to be ignored instead.
   * @param muxedCaptionMediaFormats The list of in-band caption {@link MediaFormat MediaFormats}
   *     that {@link MediaParser} should expose.
   * @param leadingBytesToSkip The number of bytes to skip from the start of the input before
   *     starting extraction.
   * @param playerId The {@link PlayerId} of the player using this chunk extractor.
   */
  public MediaParserHlsMediaChunkExtractor(
      MediaParser mediaParser,
      OutputConsumerAdapterV30 outputConsumerAdapter,
      Format format,
      boolean overrideInBandCaptionDeclarations,
      ImmutableList<MediaFormat> muxedCaptionMediaFormats,
      int leadingBytesToSkip,
      PlayerId playerId) {
    this.mediaParser = mediaParser;
    this.outputConsumerAdapter = outputConsumerAdapter;
    this.overrideInBandCaptionDeclarations = overrideInBandCaptionDeclarations;
    this.muxedCaptionMediaFormats = muxedCaptionMediaFormats;
    this.format = format;
    this.playerId = playerId;
    pendingSkipBytes = leadingBytesToSkip;
    inputReaderAdapter = new InputReaderAdapterV30();
  }

  // ChunkExtractor implementation.

  @Override
  public void init(ExtractorOutput extractorOutput) {
    outputConsumerAdapter.setExtractorOutput(extractorOutput);
  }

  @Override
  public boolean read(ExtractorInput extractorInput) throws IOException {
    extractorInput.skipFully(pendingSkipBytes);
    pendingSkipBytes = 0;
    inputReaderAdapter.setDataReader(extractorInput, extractorInput.getLength());
    return mediaParser.advance(inputReaderAdapter);
  }

  @Override
  public boolean isPackedAudioExtractor() {
    String parserName = mediaParser.getParserName();
    return MediaParser.PARSER_NAME_AC3.equals(parserName)
        || MediaParser.PARSER_NAME_AC4.equals(parserName)
        || MediaParser.PARSER_NAME_ADTS.equals(parserName)
        || MediaParser.PARSER_NAME_MP3.equals(parserName);
  }

  @Override
  public boolean isReusable() {
    String parserName = mediaParser.getParserName();
    return MediaParser.PARSER_NAME_FMP4.equals(parserName)
        || MediaParser.PARSER_NAME_TS.equals(parserName);
  }

  @Override
  public HlsMediaChunkExtractor recreate() {
    Assertions.checkState(!isReusable());
    return new MediaParserHlsMediaChunkExtractor(
        createMediaParserInstance(
            outputConsumerAdapter,
            format,
            overrideInBandCaptionDeclarations,
            muxedCaptionMediaFormats,
            playerId,
            mediaParser.getParserName()),
        outputConsumerAdapter,
        format,
        overrideInBandCaptionDeclarations,
        muxedCaptionMediaFormats,
        /* leadingBytesToSkip= */ 0,
        playerId);
  }

  @Override
  public void onTruncatedSegmentParsed() {
    mediaParser.seek(SeekPoint.START);
  }

  // Allow constants that are not part of the public MediaParser API.
  @SuppressLint({"WrongConstant"})
  private static MediaParser createMediaParserInstance(
      OutputConsumer outputConsumer,
      Format format,
      boolean overrideInBandCaptionDeclarations,
      ImmutableList<MediaFormat> muxedCaptionMediaFormats,
      PlayerId playerId,
      String... parserNames) {
    MediaParser mediaParser =
        parserNames.length == 1
            ? MediaParser.createByName(parserNames[0], outputConsumer)
            : MediaParser.create(outputConsumer, parserNames);
    mediaParser.setParameter(PARAMETER_EXPOSE_CAPTION_FORMATS, muxedCaptionMediaFormats);
    mediaParser.setParameter(
        PARAMETER_OVERRIDE_IN_BAND_CAPTION_DECLARATIONS, overrideInBandCaptionDeclarations);
    mediaParser.setParameter(PARAMETER_IN_BAND_CRYPTO_INFO, true);
    mediaParser.setParameter(PARAMETER_EAGERLY_EXPOSE_TRACK_TYPE, true);
    mediaParser.setParameter(PARAMETER_IGNORE_TIMESTAMP_OFFSET, true);
    mediaParser.setParameter(PARAMETER_TS_IGNORE_SPLICE_INFO_STREAM, true);
    mediaParser.setParameter(PARAMETER_TS_MODE, "hls");
    @Nullable String codecs = format.codecs;
    if (!TextUtils.isEmpty(codecs)) {
      // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really
      // exist. If we know from the codec attribute that they don't exist, then we can
      // explicitly ignore them even if they're declared.
      if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) {
        mediaParser.setParameter(PARAMETER_TS_IGNORE_AAC_STREAM, true);
      }
      if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) {
        mediaParser.setParameter(PARAMETER_TS_IGNORE_AVC_STREAM, true);
      }
    }
    if (Util.SDK_INT >= 31) {
      MediaParserUtil.setLogSessionIdOnMediaParser(mediaParser, playerId);
    }
    return mediaParser;
  }

  private static final class PeekingInputReader implements MediaParser.SeekableInputReader {

    private final ExtractorInput extractorInput;
    private int totalPeekedBytes;

    private PeekingInputReader(ExtractorInput extractorInput) {
      this.extractorInput = extractorInput;
    }

    @Override
    public int read(byte[] buffer, int offset, int readLength) throws IOException {
      int peekedBytes = extractorInput.peek(buffer, offset, readLength);
      totalPeekedBytes += peekedBytes;
      return peekedBytes;
    }

    @Override
    public long getPosition() {
      return extractorInput.getPeekPosition();
    }

    @Override
    public long getLength() {
      return extractorInput.getLength();
    }

    @Override
    public void seekToPosition(long position) {
      // Seeking is not allowed when sniffing the content.
      throw new UnsupportedOperationException();
    }
  }
}