Add Shoutcast Metadata Protocol (ICY) extension

Based on the OkHttp extension.

Resolves #3735
This commit is contained in:
Sascha Peilicke 2018-10-22 23:02:01 +02:00
parent 1ef3efaa90
commit 95eff3b747
10 changed files with 688 additions and 0 deletions

View File

@ -28,6 +28,7 @@ include modulePrefix + 'testutils-robolectric'
include modulePrefix + 'extension-ffmpeg'
include modulePrefix + 'extension-flac'
include modulePrefix + 'extension-gvr'
include modulePrefix + 'extension-icy'
include modulePrefix + 'extension-ima'
include modulePrefix + 'extension-cast'
include modulePrefix + 'extension-cronet'
@ -50,6 +51,7 @@ project(modulePrefix + 'testutils-robolectric').projectDir = new File(rootDir, '
project(modulePrefix + 'extension-ffmpeg').projectDir = new File(rootDir, 'extensions/ffmpeg')
project(modulePrefix + 'extension-flac').projectDir = new File(rootDir, 'extensions/flac')
project(modulePrefix + 'extension-gvr').projectDir = new File(rootDir, 'extensions/gvr')
project(modulePrefix + 'extension-icy').projectDir = new File(rootDir, 'extensions/icy')
project(modulePrefix + 'extension-ima').projectDir = new File(rootDir, 'extensions/ima')
project(modulePrefix + 'extension-cast').projectDir = new File(rootDir, 'extensions/cast')
project(modulePrefix + 'extension-cronet').projectDir = new File(rootDir, 'extensions/cronet')

67
extensions/icy/README.md Normal file
View File

@ -0,0 +1,67 @@
# ExoPlayer Shoutcast Metadata Protocol (ICY) extension #
The Shoutcast Metadata Protocol extension provides **IcyHttpDataSource** and
**IcyHttpDataSourceFactory** which can parse ICY metadata information such as
stream name and genre as well as current song information from a music stream.
You can find the protocol description here:
- https://cast.readme.io/v1.0/docs/icy
- http://www.smackfu.com/stuff/programming/shoutcast.html
## Getting the extension ##
The easiest way to use the extension is to add it as a gradle dependency:
```gradle
implementation 'com.google.android.exoplayer:extension-icy:2.X.X'
```
where `2.X.X` is the version, which must match the version of the ExoPlayer
library being used.
Alternatively, you can clone the ExoPlayer repository and depend on the module
locally. Instructions for doing this can be found in ExoPlayer's
[top level README][].
[top level README]: https://github.com/google/ExoPlayer/blob/release-v2/README.md
## Using the extension ##
To receive information about the current music stream (such as name and genre,
see **IcyHeaders** class) as well as current song information (see
**IcyMetadata** class), pass an instance of **IcyHttpDataSourceFactory** instead
of an **DefaultHttpDataSourceFactory** like this (in Kotlin):
```kotlin
// ... exoPlayer instance already created
// Custom HTTP data source factory which requests Icy metadata and parses it if
// the stream server supports it
val client = OkHttpClient.Builder().build()
val icyHttpDataSourceFactory = IcyHttpDataSourceFactory.Builder(client)
.setUserAgent(userAgent)
.setIcyHeadersListener { icyHeaders ->
Log.d("XXX", "onIcyHeaders: %s".format(icyHeaders.toString()))
}
.setIcyMetadataChangeListener { icyMetadata ->
Log.d("XXX", "onIcyMetaData: %s".format(icyMetadata.toString()))
}
.build()
// Produces DataSource instances through which media data is loaded
val dataSourceFactory = DefaultDataSourceFactory(applicationContext, null, icyHttpDataSourceFactory)
// The MediaSource represents the media to be played
val mediaSource = ExtractorMediaSource.Factory(dataSourceFactory)
.setExtractorsFactory(DefaultExtractorsFactory())
.createMediaSource(sourceUri)
// exoPlayer?.prepare(mediaSource) ...
```
## Links ##
* [Javadoc][]: Classes matching `com.google.android.exoplayer2.ext.icy.*`
belong to this module.
[Javadoc]: https://google.github.io/ExoPlayer/doc/reference/index.html

View File

@ -0,0 +1,52 @@
// Copyright (C) 2018 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.
apply from: '../../constants.gradle'
apply plugin: 'com.android.library'
android {
compileSdkVersion project.ext.compileSdkVersion
buildToolsVersion project.ext.buildToolsVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
minSdkVersion project.ext.minSdkVersion
targetSdkVersion project.ext.targetSdkVersion
consumerProguardFiles 'proguard-rules.txt'
}
}
dependencies {
implementation project(modulePrefix + 'extension-okhttp')
implementation 'com.android.support:support-annotations:' + supportLibraryVersion
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
api 'com.google.android.exoplayer:extension-okhttp:' + releaseVersion
testImplementation 'junit:junit:' + junitVersion
testImplementation project(modulePrefix + 'testutils-robolectric')
}
ext {
javadocTitle = 'Shoutcast Metadata Protocol (ICY) extension'
}
apply from: '../../javadoc_library.gradle'
ext {
releaseArtifact = 'extension-icy'
releaseDescription = 'Shoutcast Metadata Protocol (ICY) extension for ExoPlayer.'
}
apply from: '../../publish.gradle'

View File

@ -0,0 +1,2 @@
# Proguard rules specific to the Icy extension.

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2016 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.
-->
<manifest package="com.google.android.exoplayer2.ext.icy"/>

View File

@ -0,0 +1,387 @@
package com.google.android.exoplayer2.ext.icy;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.util.Predicate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import okhttp3.CacheControl;
import okhttp3.Call;
/**
* https://cast.readme.io/v1.0/docs/icy http://www.smackfu.com/stuff/programming/shoutcast.html
*/
public final class IcyHttpDataSource extends OkHttpDataSource {
private static final String TAG = IcyHttpDataSource.class.getSimpleName();
private static final String REQUEST_HEADER_ICY_METAINT_KEY = "Icy-MetaData";
private static final String REQUEST_HEADER_ICY_METAINT_VALUE = "1";
private static final String RESPONSE_HEADER_ICY_BR_KEY = "icy-br";
private static final String RESPONSE_HEADER_ICY_GENRE_KEY = "icy-genre";
private static final String RESPONSE_HEADER_ICY_NAME_KEY = "icy-name";
private static final String RESPONSE_HEADER_ICY_URL_KEY = "icy-url";
private static final String RESPONSE_HEADER_ICY_PUB_KEY = "icy-pub";
private static final String RESPONSE_HEADER_ICY_METAINT_KEY = "icy-metaint";
private static final String ICY_METADATA_STREAM_TITLE_KEY = "StreamTitle";
private static final String ICY_METADATA_STREAM_URL_KEY = "StreamUrl";
private IcyHeadersListener icyHeadersListener;
private IcyMetadataListener icyMetadataListener;
private int metaDataIntervalInBytes = -1;
private int remainingStreamDataUntilMetaDataBlock = -1;
private DataSpec dataSpec;
public interface IcyHeadersListener {
void onIcyHeaders(IcyHeaders icyHeaders);
}
public interface IcyMetadataListener {
void onIcyMetaData(IcyMetadata icyMetadata);
}
private IcyHttpDataSource(
@NonNull Call.Factory callFactory,
@Nullable final String userAgent,
@Nullable final Predicate<String> contentTypePredicate,
@Nullable CacheControl cacheControl,
@NonNull RequestProperties defaultRequestProperties) {
super(callFactory, userAgent, contentTypePredicate, cacheControl, defaultRequestProperties);
defaultRequestProperties.set(REQUEST_HEADER_ICY_METAINT_KEY, REQUEST_HEADER_ICY_METAINT_VALUE);
// See class Builder
}
@Override
public long open(DataSpec dataSpec) throws HttpDataSourceException {
this.dataSpec = dataSpec;
long bytesToRead = super.open(dataSpec);
Map<String, List<String>> responseHeaders = getResponseHeaders();
if (responseHeaders != null) {
IcyHeaders icyHeaders = new IcyHeaders();
Log.d(TAG, "open: responseHeaders=" + responseHeaders.toString());
List<String> headers = responseHeaders.get(RESPONSE_HEADER_ICY_BR_KEY);
if (headers != null && headers.size() == 1) {
icyHeaders.bitRate = Integer.parseInt(headers.get(0));
}
headers = responseHeaders.get(RESPONSE_HEADER_ICY_GENRE_KEY);
if (headers != null && headers.size() == 1) {
icyHeaders.genre = headers.get(0);
}
headers = responseHeaders.get(RESPONSE_HEADER_ICY_NAME_KEY);
if (headers != null && headers.size() == 1) {
icyHeaders.name = headers.get(0);
}
headers = responseHeaders.get(RESPONSE_HEADER_ICY_URL_KEY);
if (headers != null && headers.size() == 1) {
icyHeaders.url = headers.get(0);
}
headers = responseHeaders.get(RESPONSE_HEADER_ICY_PUB_KEY);
if (headers != null && headers.size() == 1) {
icyHeaders.isPublic = headers.get(0).equals("1");
}
headers = responseHeaders.get(RESPONSE_HEADER_ICY_METAINT_KEY);
if (headers != null && headers.size() == 1) {
metaDataIntervalInBytes = Integer.parseInt(headers.get(0));
remainingStreamDataUntilMetaDataBlock = metaDataIntervalInBytes;
}
if (icyHeadersListener != null) {
icyHeadersListener.onIcyHeaders(icyHeaders);
}
}
return bytesToRead;
}
@Override
public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException {
int bytesRead;
// Only read metadata if the server declared to send it...
if (metaDataIntervalInBytes < 0) {
bytesRead = super.read(buffer, offset, readLength);
} else {
bytesRead = super.read(buffer, offset,
remainingStreamDataUntilMetaDataBlock < readLength ? remainingStreamDataUntilMetaDataBlock
: readLength);
if (remainingStreamDataUntilMetaDataBlock == bytesRead) {
parseIcyMetadata();
} else {
remainingStreamDataUntilMetaDataBlock -= bytesRead;
}
}
return bytesRead;
}
private void parseIcyMetadata() throws HttpDataSourceException {
// We hit the metadata block, reset stream data counter
remainingStreamDataUntilMetaDataBlock = metaDataIntervalInBytes;
byte[] metaDataBuffer = new byte[1];
int bytesRead = super.read(metaDataBuffer, 0, 1);
if (bytesRead != 1) {
throw new HttpDataSourceException("parseIcyMetadata: Unable to read metadata length!",
dataSpec, HttpDataSourceException.TYPE_READ);
}
int metaDataBlockSize = metaDataBuffer[0];
if (metaDataBlockSize < 1) { // Either no metadata or end of file
return;
}
metaDataBlockSize <<= 4; // Multiply by 16 to get actual size
if (metaDataBuffer.length < metaDataBlockSize) {
metaDataBuffer = new byte[metaDataBlockSize]; // Make room for the full metadata block
}
// Read entire metadata block into buffer
int offset = 0;
int readLength = metaDataBlockSize;
while (readLength > 0 && (bytesRead = super.read(metaDataBuffer, offset, readLength)) != -1) {
offset += bytesRead;
readLength -= bytesRead;
}
metaDataBlockSize = offset;
// We read the metadata from the stream. Only parse it when we have a listener registered
// to return the contents.
if (icyMetadataListener != null) {
// Find null-terminator
for (int i = 0; i < metaDataBlockSize; i++) {
if (metaDataBuffer[i] == 0) {
metaDataBlockSize = i;
break;
}
}
try {
final String metaDataString = new String(metaDataBuffer, 0, metaDataBlockSize, "utf-8");
icyMetadataListener.onIcyMetaData(parseMetadata(metaDataString));
} catch (Exception e) {
Log.e(TAG, "parseIcyMetadata: Cannot convert bytes to String");
}
}
}
private IcyMetadata parseMetadata(final String metaDataString) {
String[] keyAndValuePairs = metaDataString.split(";");
IcyMetadata icyMetadata = new IcyMetadata();
for (String keyValuePair : keyAndValuePairs) {
int equalSignPosition = keyValuePair.indexOf('=');
if (equalSignPosition < 1) {
continue;
}
boolean isString = equalSignPosition + 1 < keyValuePair.length()
&& keyValuePair.charAt(keyValuePair.length() - 1) == '\''
&& keyValuePair.charAt(equalSignPosition + 1) == '\'';
String key = keyValuePair.substring(0, equalSignPosition);
String value = isString ?
keyValuePair.substring(equalSignPosition + 2, keyValuePair.length() - 1) :
equalSignPosition + 1 < keyValuePair.length() ?
keyValuePair.substring(equalSignPosition + 1) : "";
switch (key) {
case ICY_METADATA_STREAM_TITLE_KEY:
icyMetadata.streamTitle = value;
case ICY_METADATA_STREAM_URL_KEY:
icyMetadata.streamUrl = value;
}
icyMetadata.metadata.put(key, value);
}
return icyMetadata;
}
public final static class Builder {
private Call.Factory callFactory;
private String userAgent;
private Predicate<String> contentTypePredicate;
private CacheControl cacheControl;
private RequestProperties defaultRequestProperties = new RequestProperties();
private IcyHeadersListener icyHeadersListener;
private IcyMetadataListener icyMetadataListener;
public Builder(@NonNull Call.Factory callFactory) {
this.callFactory = callFactory;
}
public Builder setUserAgent(@NonNull final String userAgent) {
this.userAgent = userAgent;
return this;
}
public Builder setContentTypePredicate(@NonNull final Predicate<String> contentTypePredicate) {
this.contentTypePredicate = contentTypePredicate;
return this;
}
public Builder setCacheControl(@NonNull final CacheControl cacheControl) {
this.cacheControl = cacheControl;
return this;
}
public Builder setDefaultRequestProperties(
@NonNull final RequestProperties defaultRequestProperties) {
this.defaultRequestProperties = defaultRequestProperties;
return this;
}
public Builder setIcyHeadersListener(
@NonNull final IcyHttpDataSource.IcyHeadersListener icyHeadersListener) {
this.icyHeadersListener = icyHeadersListener;
return this;
}
public Builder setIcyMetadataListener(@NonNull final IcyMetadataListener icyMetadataListener) {
this.icyMetadataListener = icyMetadataListener;
return this;
}
IcyHttpDataSource build() {
final IcyHttpDataSource dataSource =
new IcyHttpDataSource(callFactory,
userAgent,
contentTypePredicate,
cacheControl,
defaultRequestProperties);
dataSource.icyHeadersListener = icyHeadersListener;
dataSource.icyMetadataListener = icyMetadataListener;
return dataSource;
}
}
/**
* Container for Icy headers such as stream genre or name.
*/
public final class IcyHeaders {
/**
* icy-br Bit rate in KB/s
*/
int bitRate;
/**
* icy-genre
*/
String genre;
/**
* icy-name
*/
String name;
/**
* icy-url
*/
String url;
/**
* icy-pub
*/
boolean isPublic;
/**
* @return The bit rate in kilobits per second (KB/s)
*/
public int getBitRate() {
return bitRate;
}
/**
* @return The musical genre of the stream
*/
public String getGenre() {
return genre;
}
/**
* @return The stream name
*/
public String getName() {
return name;
}
/**
* @return The URL of the music stream (can be a website or artwork)
*/
public String getUrl() {
return url;
}
/**
* @return Determines if this stream is public or listed in a catalog
*/
public boolean isPublic() {
return isPublic;
}
@Override
public String toString() {
return "IcyHeaders{" +
"bitRate='" + bitRate + '\'' +
", genre='" + genre + '\'' +
", name='" + name + '\'' +
", url='" + url + '\'' +
", isPublic=" + isPublic +
'}';
}
}
/**
* Container for stream title and URL.
* <p>
* The exact contents isn't specified and implementation specific. It's therefore up to the user
* to figure what format a given stream returns.
*/
public final class IcyMetadata {
String streamTitle;
String streamUrl;
HashMap<String, String> metadata = new HashMap<>();
/**
* @return The song title.
*/
public String getStreamTitle() {
return streamTitle;
}
/**
* @return Url to album artwork or more information about the current song.
*/
public String getStreamUrl() {
return streamUrl;
}
/**
* Provides a map of all stream metadata.
*
* @return Complete metadata
*/
public HashMap<String, String> getMetadata() {
return metadata;
}
@Override
public String toString() {
return "IcyMetadata{" +
"streamTitle='" + streamTitle + '\'' +
", streamUrl='" + streamUrl + '\'' +
", metadata='" + metadata + '\'' +
'}';
}
}
}

View File

@ -0,0 +1,85 @@
package com.google.android.exoplayer2.ext.icy;
import android.support.annotation.NonNull;
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.util.Predicate;
import okhttp3.CacheControl;
import okhttp3.Call;
/**
* A {@link HttpDataSource.Factory} that produces {@link IcyHttpDataSource} instances.
*/
public final class IcyHttpDataSourceFactory extends OkHttpDataSource.BaseFactory {
private Call.Factory callFactory;
private String userAgent;
private Predicate<String> contentTypePredicate;
private CacheControl cacheControl;
private IcyHttpDataSource.IcyHeadersListener icyHeadersListener;
private IcyHttpDataSource.IcyMetadataListener icyMetadataListener;
private IcyHttpDataSourceFactory() {
// See class Builder
}
/**
* Constructs a IcyHttpDataSourceFactory.
*/
public final static class Builder {
private final IcyHttpDataSourceFactory factory;
public Builder(@NonNull Call.Factory callFactory) {
// Apply defaults
factory = new IcyHttpDataSourceFactory();
factory.callFactory = callFactory;
}
public Builder setUserAgent(@NonNull final String userAgent) {
factory.userAgent = userAgent;
return this;
}
public Builder setContentTypePredicate(@NonNull final Predicate<String> contentTypePredicate) {
factory.contentTypePredicate = contentTypePredicate;
return this;
}
public Builder setCacheControl(@NonNull final CacheControl cacheControl) {
factory.cacheControl = cacheControl;
return this;
}
public Builder setIcyHeadersListener(
@NonNull final IcyHttpDataSource.IcyHeadersListener icyHeadersListener) {
factory.icyHeadersListener = icyHeadersListener;
return this;
}
public Builder setIcyMetadataChangeListener(
@NonNull final IcyHttpDataSource.IcyMetadataListener icyMetadataListener) {
factory.icyMetadataListener = icyMetadataListener;
return this;
}
public IcyHttpDataSourceFactory build() {
return factory;
}
}
@Override
protected IcyHttpDataSource createDataSourceInternal(
@NonNull HttpDataSource.RequestProperties defaultRequestProperties) {
return new IcyHttpDataSource.Builder(callFactory)
.setUserAgent(userAgent)
.setContentTypePredicate(contentTypePredicate)
.setCacheControl(cacheControl)
.setDefaultRequestProperties(defaultRequestProperties)
.setIcyHeadersListener(icyHeadersListener)
.setIcyMetadataListener(icyMetadataListener)
.build();
}
}

View File

@ -0,0 +1,41 @@
package com.google.android.exoplayer2.ext.icy;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import okhttp3.OkHttpClient;
import com.google.android.exoplayer2.ext.icy.test.Constants;
import static org.junit.Assert.assertNotNull;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public final class IcyHttpDataSourceFactoryTest {
private final IcyHttpDataSource.IcyHeadersListener TEST_ICY_HEADERS_LISTENER = icyHeaders -> {
};
private final IcyHttpDataSource.IcyMetadataListener TEST_ICY_METADATA_LISTENER = icyMetadata -> {
};
@Test
public void createDataSourceViaFactoryFromFactoryBuilder() {
// Arrange
OkHttpClient client = new OkHttpClient.Builder().build();
IcyHttpDataSourceFactory factory = new IcyHttpDataSourceFactory.Builder(client)
.setUserAgent(Constants.TEST_USER_AGENT)
.setIcyHeadersListener(TEST_ICY_HEADERS_LISTENER)
.setIcyMetadataChangeListener(TEST_ICY_METADATA_LISTENER)
.build();
HttpDataSource.RequestProperties requestProperties = new HttpDataSource.RequestProperties();
// Act
IcyHttpDataSource source = factory.createDataSourceInternal(requestProperties);
// Assert
assertNotNull(source);
}
}

View File

@ -0,0 +1,29 @@
package com.google.android.exoplayer2.ext.icy;
import com.google.android.exoplayer2.ext.icy.IcyHttpDataSource;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import okhttp3.OkHttpClient;
import com.google.android.exoplayer2.ext.icy.test.Constants;
import static org.junit.Assert.assertNotNull;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public final class IcyHttpDataSourceTest {
@Test
public void createDataSourceFromBuilder() {
// Arrange, act
OkHttpClient client = new OkHttpClient.Builder().build();
IcyHttpDataSource source = new IcyHttpDataSource.Builder(client)
.setUserAgent(Constants.TEST_USER_AGENT)
.build();
// Assert
assertNotNull(source);
}
}

View File

@ -0,0 +1,6 @@
package com.google.android.exoplayer2.ext.icy.test;
public final class Constants {
public static final String TEST_USER_AGENT = "test-agent";
}