如何从 ExoPlayer 中的 HLS 流中提取定时 ID3 元数据?

How to extract timed ID3 metadata from HLS stream in ExoPlayer?

我有一个 M3U8 文件位于:https://vcloud.blueframetech.com/file/hls/13836.m3u8

此视频每秒包含定时元数据。我的目标是从 ExoPlayer 读取此元数据。目前我的 MainActivity.java 中有以下内容:

package com.test.exoplayermetadatatest;

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;

import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.metadata.MetadataOutput;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.TrackGroup;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.ui.PlayerView;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
import com.google.android.exoplayer2.util.Util;

public class MainActivity extends AppCompatActivity implements MetadataOutput, Player.EventListener
{

    @Override
    protected void onCreate ( Bundle savedInstanceState )
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Context context = getApplicationContext();

        SimpleExoPlayer player = ExoPlayerFactory.newSimpleInstance(context);

        PlayerView view = findViewById(R.id.player);

        view.setPlayer(player);

        DataSource.Factory dataSourceFactory =
            new DefaultHttpDataSourceFactory(Util.getUserAgent(context, "app-name"));

        HlsMediaSource hlsMediaSource =
            new HlsMediaSource.Factory(dataSourceFactory).createMediaSource(Uri.parse("https://vcloud.blueframetech.com/file/hls/13836.m3u8"));

        player.addMetadataOutput(this);
        player.addListener(this);

        player.prepare(hlsMediaSource);

        player.setPlayWhenReady(true);
    }

    @Override
    public void onTracksChanged( TrackGroupArray trackGroups, TrackSelectionArray trackSelections)
    {
        for ( int i = 0; i < trackGroups.length; i++ )
        {
            TrackGroup trackGroup = trackGroups.get(i);
            for ( int j = 0; j < trackGroup.length; j++ )
            {
                Metadata trackMetadata = trackGroup.getFormat(j).metadata;
                if ( trackMetadata != null )
                {
                    Log.d("METADATA TRACK", trackMetadata.toString());
                }
            }
        }
    }

    @Override
    public void onMetadata ( Metadata metadata )
    {
        Log.d("METADATA", metadata.toString());
    }

}

应用程序加载时,我看到 METADATA TRACK 日志出现一次,但 METADATA 日志从未出现过一次。我错过了什么或做错了什么?

我的回答有点长...

问题

首先,我注意到我的确切解决方案 在 ExoPlayer 2.1.1 中有效,但在 2.10.1 中无效。这让我认为 ID3 元数据出现了回归,因此我通过 GitHub 联系了 Google。他们很快做出回应,并注意到我视频中的元数据实际上存在问题。对于作为 ID3 标签的 start 的每个数据包,data_alignment_indicator 位应该为 1,对于作为前一个 ID3 标签的延续的每个数据包(在如果 ID3 标签太大,无法满足单个标签的 64 KB 限制)。对于我们的内容,此位总是 设置为 0 - 意味着任何地方都没有 "start of an ID3 tag"。

旧版本的 ExoPlayer 没有对此进行检查,因此无法正确支持超过 64 KB 的元数据。新版本 对此进行了检查,但因此无法读取我们损坏的视频


解决方案

显然 正确 答案是修复我们的内容,但我们有超过 100,000 个元数据格式错误的视频,因此修复它们需要花费很多时间和金钱。相反,我们想找到一个 player-side 解决方案。这是我能够做的:

1。将自定义 HlsExtractorFactory 传递给 HlsMediaSource.Factory 实例:

HlsMediaSource hlsMediaSource = new HlsMediaSource.Factory(dataSourceFactory)
    .setExtractorFactory(new HlsExtractorFactoryProxy())
    .createMediaSource(Uri.parse("https://vcloud.blueframetech.com/file/hls/13836.m3u8"));

2。创建自定义 HlsExtractorFactory

我无法扩展 DefaultHlsExtractorFactory 并且不想从头开始实现我自己的提取器工厂,所以我选择了 Proxy Pattern

    public class HlsExtractorFactoryProxy implements HlsExtractorFactory
    {

        private DefaultHlsExtractorFactory internal = new DefaultHlsExtractorFactory();

        @Override
        public HlsExtractorFactory.Result createExtractor (
            Extractor previousExtractor,
            Uri uri,
            Format format,
            List<Format> muxedCaptionFormats,
            DrmInitData drmInitData,
            TimestampAdjuster timestampAdjuster,
            Map<String, List<String>> responseHeaders,
            ExtractorInput extractorInput
        )
            throws InterruptedException, IOException
        {
            HlsExtractorFactory.Result result = internal.createExtractor(
                previousExtractor,
                uri,
                format,
                muxedCaptionFormats,
                drmInitData,
                timestampAdjuster,
                responseHeaders,
                extractorInput
            );

            if ( result.extractor instanceof TsExtractor )
            {
                return createNewTsExtractor(
                    0,
                    true,
                    format,
                    muxedCaptionFormats,
                    timestampAdjuster
                );
            }

            return result;
        }

        private HlsExtractorFactory.Result createNewTsExtractor (
            @DefaultTsPayloadReaderFactory.Flags int userProvidedPayloadReaderFactoryFlags,
            boolean exposeCea608WhenMissingDeclarations,
            Format format,
            List<Format> muxedCaptionFormats,
            TimestampAdjuster timestampAdjuster
        )
        {
            @DefaultTsPayloadReaderFactory.Flags
            int payloadReaderFactoryFlags =
                DefaultTsPayloadReaderFactory.FLAG_IGNORE_SPLICE_INFO_STREAM
                | userProvidedPayloadReaderFactoryFlags;
            if ( muxedCaptionFormats != null )
            {
                // The playlist declares closed caption renditions, we should ignore descriptors.
                payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_OVERRIDE_CAPTION_DESCRIPTORS;
            }
            else if ( exposeCea608WhenMissingDeclarations )
            {
                // The playlist does not provide any closed caption information. We preemptively declare a
                // closed caption track on channel 0.
                muxedCaptionFormats =
                    Collections.singletonList(
                        Format.createTextSampleFormat(
                            null,
                            MimeTypes.APPLICATION_CEA608,
                            0,
                            null
                        ));
            }
            else
            {
                muxedCaptionFormats = Collections.emptyList();
            }
            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)) )
                {
                    payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM;
                }
                if ( !MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs)) )
                {
                    payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM;
                }
            }

            TsExtractor extractor = new TsExtractor(
                TsExtractor.MODE_HLS,
                timestampAdjuster,
                new TsPayloadReaderFactoryProxy(payloadReaderFactoryFlags, muxedCaptionFormats)
            );

            return new HlsExtractorFactory.Result(
                extractor,
                false,
                true
            );
        }

    }

根据 HlsExtractorFactory 接口,此 class 仅公开一个 public 方法:createExtractor。此方法运行 DefaultHlsExtractorFactorycreateExtractor 方法,如果它生成 TsExtractor,则将其替换为自己的自定义版本 TsExtractor (TsExtractorProxy)。

为了创建这个自定义 TsExtractorProxy 我从 the DefaultHlsExtractorFactory class 复制了 createTsExtractor 方法的全部内容并更改了一个语句:

new TsExtractor(
        TsExtractor.MODE_HLS,
        timestampAdjuster,
new DefaultTsPayloadReaderFactory(payloadReaderFactoryFlags, muxedCaptionFormats));
new TsExtractor(
        TsExtractor.MODE_HLS,
        timestampAdjuster,
new TsPayloadReaderFactoryProxy(payloadReaderFactoryFlags, muxedCaptionFormats));

3。创建 TsPayloadReaderFactory 代理

如上所述,我需要在这里创建一个代理。这个暴露了两个 public 方法:createInitialPayloadReaderscreatePayloadReader。我只需要调整 createPayloadReader

的实现
    public class TsPayloadReaderFactoryProxy implements TsPayloadReader.Factory
    {

        private DefaultTsPayloadReaderFactory internal;

        public TsPayloadReaderFactoryProxy(int payloadReaderFactoryFlags, List<Format>  muxedCaptionFormats)
        {
            internal = new DefaultTsPayloadReaderFactory(payloadReaderFactoryFlags, muxedCaptionFormats);
        }

        @Override
        public SparseArray<TsPayloadReader> createInitialPayloadReaders ()
        {
            return internal.createInitialPayloadReaders();
        }

        @Override
        public TsPayloadReader createPayloadReader (
            int streamType, TsPayloadReader.EsInfo esInfo
        )
        {
            if ( streamType == TsExtractor.TS_STREAM_TYPE_ID3)
            {
                return new PesReader(new Id3ReaderProxy());
            }
            else
            {
                return internal.createPayloadReader(streamType, esInfo);
            }
        }

    }

正如您在这里可以更清楚地看到的那样,在处理类型为 TsExtractor.TS_STREAM_TYPE_ID3 的流时,我没有实例化 Id3Reader,而是实例化了 Id3ReaderProxy

4。创建 Id3Reader 代理

这个class有五个public个方法,但只需要调整一个:packetStarted。我没有传递 flags 参数,而是用 TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR

覆盖它
    class Id3ReaderProxy implements ElementaryStreamReader
    {

        private Id3Reader internal = new Id3Reader();

        @Override
        public void seek ()
        {
            internal.seek();
        }

        @Override
        public void createTracks (
            ExtractorOutput extractorOutput, TsPayloadReader.TrackIdGenerator idGenerator
        )
        {
            internal.createTracks(extractorOutput, idGenerator);
        }

        @Override
        public void packetStarted ( long pesTimeUs, int flags )
        {
            internal.packetStarted(pesTimeUs, TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR);
        }

        @Override
        public void consume ( ParsableByteArray data ) throws ParserException
        {
            internal.consume(data);
        }

        @Override
        public void packetFinished ()
        {
            internal.packetFinished();
        }

    }

完成所有这些艰苦的工作后,我现在可以获取元数据事件,尽管我破坏了 ID3 标签