如何从 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
。此方法运行 DefaultHlsExtractorFactory
的 createExtractor
方法,如果它生成 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 方法:createInitialPayloadReaders
和 createPayloadReader
。我只需要调整 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 标签
我有一个 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
。此方法运行 DefaultHlsExtractorFactory
的 createExtractor
方法,如果它生成 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 方法:createInitialPayloadReaders
和 createPayloadReader
。我只需要调整 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 标签