NewPipe/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java

1015 lines
42 KiB
Java

/*
* Based on ExoPlayer's DefaultHttpDataSource, version 2.17.1.
*
* Original source code copyright (C) 2016 The Android Open Source Project, licensed under the
* Apache License, Version 2.0.
*/
package org.schabi.newpipe.player.datasource;
import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS;
import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS;
import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Util.castNonNull;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
import static java.lang.Math.min;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.upstream.BaseDataSource;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DataSourceException;
import com.google.android.exoplayer2.upstream.DataSpec;
import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import com.google.android.exoplayer2.upstream.HttpDataSource;
import com.google.android.exoplayer2.upstream.HttpUtil;
import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import com.google.common.base.Predicate;
import com.google.common.collect.ForwardingMap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
import com.google.common.net.HttpHeaders;
import org.schabi.newpipe.DownloaderImpl;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.NoRouteToHostException;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.zip.GZIPInputStream;
/**
* An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}, based on
* {@link com.google.android.exoplayer2.upstream.DefaultHttpDataSource}, for YouTube streams.
*
* <p>
* It adds more headers to {@code videoplayback} URLs, such as {@code Origin}, {@code Referer}
* (only where it's relevant) and also more parameters, such as {@code rn} and replaces the use of
* the {@code Range} header by the corresponding parameter ({@code range}), if enabled.
* </p>
*
* There are many unused methods in this class because everything was copied from {@link
* com.google.android.exoplayer2.upstream.DefaultHttpDataSource} with as little changes as possible.
* SonarQube warnings were also suppressed for the same reason.
*/
@SuppressWarnings({"squid:S3011", "squid:S4738"})
public final class YoutubeHttpDataSource extends BaseDataSource implements HttpDataSource {
/**
* {@link DataSource.Factory} for {@link YoutubeHttpDataSource} instances.
*/
public static final class Factory implements HttpDataSource.Factory {
private final RequestProperties defaultRequestProperties;
@Nullable
private TransferListener transferListener;
@Nullable
private Predicate<String> contentTypePredicate;
private int connectTimeoutMs;
private int readTimeoutMs;
private boolean allowCrossProtocolRedirects;
private boolean keepPostFor302Redirects;
private boolean rangeParameterEnabled;
private boolean rnParameterEnabled;
/**
* Creates an instance.
*/
public Factory() {
defaultRequestProperties = new RequestProperties();
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS;
readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS;
}
@NonNull
@Override
public Factory setDefaultRequestProperties(
@NonNull final Map<String, String> defaultRequestPropertiesMap) {
defaultRequestProperties.clearAndSet(defaultRequestPropertiesMap);
return this;
}
/**
* Sets the connect timeout, in milliseconds.
*
* <p>
* The default is {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}.
* </p>
*
* @param connectTimeoutMsValue The connect timeout, in milliseconds, that will be used.
* @return This factory.
*/
public Factory setConnectTimeoutMs(final int connectTimeoutMsValue) {
connectTimeoutMs = connectTimeoutMsValue;
return this;
}
/**
* Sets the read timeout, in milliseconds.
*
* <p>The default is {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}.
*
* @param readTimeoutMsValue The connect timeout, in milliseconds, that will be used.
* @return This factory.
*/
public Factory setReadTimeoutMs(final int readTimeoutMsValue) {
readTimeoutMs = readTimeoutMsValue;
return this;
}
/**
* Sets whether to allow cross protocol redirects.
*
* <p>The default is {@code false}.
*
* @param allowCrossProtocolRedirectsValue Whether to allow cross protocol redirects.
* @return This factory.
*/
public Factory setAllowCrossProtocolRedirects(
final boolean allowCrossProtocolRedirectsValue) {
allowCrossProtocolRedirects = allowCrossProtocolRedirectsValue;
return this;
}
/**
* Sets whether the use of the {@code range} parameter instead of the {@code Range} header
* to request ranges of streams is enabled.
*
* <p>
* Note that it must be not enabled on streams which are using a {@link
* com.google.android.exoplayer2.source.ProgressiveMediaSource}, as it will break playback
* for them (some exceptions may be thrown).
* </p>
*
* @param rangeParameterEnabledValue whether the use of the {@code range} parameter instead
* of the {@code Range} header (must be only enabled when
* non-{@code ProgressiveMediaSource}s)
* @return This factory.
*/
public Factory setRangeParameterEnabled(final boolean rangeParameterEnabledValue) {
rangeParameterEnabled = rangeParameterEnabledValue;
return this;
}
/**
* Sets whether the use of the {@code rn}, which stands for request number, parameter is
* enabled.
*
* <p>
* Note that it should be not enabled on streams which are using {@code /} to delimit URLs
* parameters, such as the streams of HLS manifests.
* </p>
*
* @param rnParameterEnabledValue whether the appending the {@code rn} parameter to
* {@code videoplayback} URLs
* @return This factory.
*/
public Factory setRnParameterEnabled(final boolean rnParameterEnabledValue) {
rnParameterEnabled = rnParameterEnabledValue;
return this;
}
/**
* Sets a content type {@link Predicate}. If a content type is rejected by the predicate
* then a {@link HttpDataSource.InvalidContentTypeException} is thrown from
* {@link YoutubeHttpDataSource#open(DataSpec)}.
*
* <p>
* The default is {@code null}.
* </p>
*
* @param contentTypePredicateToSet The content type {@link Predicate}, or {@code null} to
* clear a predicate that was previously set.
* @return This factory.
*/
public Factory setContentTypePredicate(
@Nullable final Predicate<String> contentTypePredicateToSet) {
this.contentTypePredicate = contentTypePredicateToSet;
return this;
}
/**
* Sets the {@link TransferListener} that will be used.
*
* <p>The default is {@code null}.
*
* <p>See {@link DataSource#addTransferListener(TransferListener)}.
*
* @param transferListenerToUse The listener that will be used.
* @return This factory.
*/
public Factory setTransferListener(
@Nullable final TransferListener transferListenerToUse) {
this.transferListener = transferListenerToUse;
return this;
}
/**
* Sets whether we should keep the POST method and body when we have HTTP 302 redirects for
* a POST request.
*
* @param keepPostFor302RedirectsValue Whether we should keep the POST method and body when
* we have HTTP 302 redirects for a POST request.
* @return This factory.
*/
public Factory setKeepPostFor302Redirects(final boolean keepPostFor302RedirectsValue) {
this.keepPostFor302Redirects = keepPostFor302RedirectsValue;
return this;
}
@NonNull
@Override
public YoutubeHttpDataSource createDataSource() {
final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource(
connectTimeoutMs,
readTimeoutMs,
allowCrossProtocolRedirects,
rangeParameterEnabled,
rnParameterEnabled,
defaultRequestProperties,
contentTypePredicate,
keepPostFor302Redirects);
if (transferListener != null) {
dataSource.addTransferListener(transferListener);
}
return dataSource;
}
}
private static final String TAG = YoutubeHttpDataSource.class.getSimpleName();
private static final int MAX_REDIRECTS = 20; // Same limit as okhttp.
private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307;
private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308;
private static final long MAX_BYTES_TO_DRAIN = 2048;
private static final String RN_PARAMETER = "&rn=";
private static final String YOUTUBE_BASE_URL = "https://www.youtube.com";
private final boolean allowCrossProtocolRedirects;
private final boolean rangeParameterEnabled;
private final boolean rnParameterEnabled;
private final int connectTimeoutMillis;
private final int readTimeoutMillis;
@Nullable
private final RequestProperties defaultRequestProperties;
private final RequestProperties requestProperties;
private final boolean keepPostFor302Redirects;
@Nullable
private final Predicate<String> contentTypePredicate;
@Nullable
private DataSpec dataSpec;
@Nullable
private HttpURLConnection connection;
@Nullable
private InputStream inputStream;
private boolean opened;
private int responseCode;
private long bytesToRead;
private long bytesRead;
private long requestNumber;
@SuppressWarnings("checkstyle:ParameterNumber")
private YoutubeHttpDataSource(final int connectTimeoutMillis,
final int readTimeoutMillis,
final boolean allowCrossProtocolRedirects,
final boolean rangeParameterEnabled,
final boolean rnParameterEnabled,
@Nullable final RequestProperties defaultRequestProperties,
@Nullable final Predicate<String> contentTypePredicate,
final boolean keepPostFor302Redirects) {
super(true);
this.connectTimeoutMillis = connectTimeoutMillis;
this.readTimeoutMillis = readTimeoutMillis;
this.allowCrossProtocolRedirects = allowCrossProtocolRedirects;
this.rangeParameterEnabled = rangeParameterEnabled;
this.rnParameterEnabled = rnParameterEnabled;
this.defaultRequestProperties = defaultRequestProperties;
this.contentTypePredicate = contentTypePredicate;
this.requestProperties = new RequestProperties();
this.keepPostFor302Redirects = keepPostFor302Redirects;
this.requestNumber = 0;
}
@Override
@Nullable
public Uri getUri() {
return connection == null ? null : Uri.parse(connection.getURL().toString());
}
@Override
public int getResponseCode() {
return connection == null || responseCode <= 0 ? -1 : responseCode;
}
@NonNull
@Override
public Map<String, List<String>> getResponseHeaders() {
if (connection == null) {
return ImmutableMap.of();
}
// connection.getHeaderFields() always contains a null key with a value like
// ["HTTP/1.1 200 OK"]. The response code is available from
// HttpURLConnection#getResponseCode() and the HTTP version is fixed when establishing the
// connection.
// DataSource#getResponseHeaders() doesn't allow null keys in the returned map, so we need
// to remove it.
// connection.getHeaderFields() returns a special unmodifiable case-insensitive Map
// so we can't just remove the null key or make a copy without the null key. Instead we
// wrap it in a ForwardingMap subclass that ignores and filters out null keys in the read
// methods.
return new NullFilteringHeadersMap(connection.getHeaderFields());
}
@Override
public void setRequestProperty(@NonNull final String name, @NonNull final String value) {
checkNotNull(name);
checkNotNull(value);
requestProperties.set(name, value);
}
@Override
public void clearRequestProperty(@NonNull final String name) {
checkNotNull(name);
requestProperties.remove(name);
}
@Override
public void clearAllRequestProperties() {
requestProperties.clear();
}
/**
* Opens the source to read the specified data.
*/
@Override
public long open(@NonNull final DataSpec dataSpecParameter) throws HttpDataSourceException {
this.dataSpec = dataSpecParameter;
bytesRead = 0;
bytesToRead = 0;
transferInitializing(dataSpecParameter);
final HttpURLConnection httpURLConnection;
final String responseMessage;
try {
this.connection = makeConnection(dataSpec);
httpURLConnection = this.connection;
responseCode = httpURLConnection.getResponseCode();
responseMessage = httpURLConnection.getResponseMessage();
} catch (final IOException e) {
closeConnectionQuietly();
throw HttpDataSourceException.createForIOException(e, dataSpec,
HttpDataSourceException.TYPE_OPEN);
}
// Check for a valid response code.
if (responseCode < 200 || responseCode > 299) {
final Map<String, List<String>> headers = httpURLConnection.getHeaderFields();
if (responseCode == 416) {
final long documentSize = HttpUtil.getDocumentSize(
httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE));
if (dataSpecParameter.position == documentSize) {
opened = true;
transferStarted(dataSpecParameter);
return dataSpecParameter.length != C.LENGTH_UNSET
? dataSpecParameter.length
: 0;
}
}
final InputStream errorStream = httpURLConnection.getErrorStream();
byte[] errorResponseBody;
try {
errorResponseBody = errorStream != null
? Util.toByteArray(errorStream)
: Util.EMPTY_BYTE_ARRAY;
} catch (final IOException e) {
errorResponseBody = Util.EMPTY_BYTE_ARRAY;
}
closeConnectionQuietly();
final IOException cause = responseCode == 416 ? new DataSourceException(
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
: null;
throw new InvalidResponseCodeException(responseCode, responseMessage, cause, headers,
dataSpec, errorResponseBody);
}
// Check for a valid content type.
final String contentType = httpURLConnection.getContentType();
if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
closeConnectionQuietly();
throw new InvalidContentTypeException(contentType, dataSpecParameter);
}
final long bytesToSkip;
if (!rangeParameterEnabled) {
// If we requested a range starting from a non-zero position and received a 200 rather
// than a 206, then the server does not support partial requests. We'll need to
// manually skip to the requested position.
bytesToSkip = responseCode == 200 && dataSpecParameter.position != 0
? dataSpecParameter.position
: 0;
} else {
bytesToSkip = 0;
}
// Determine the length of the data to be read, after skipping.
final boolean isCompressed = isCompressed(httpURLConnection);
if (!isCompressed) {
if (dataSpecParameter.length != C.LENGTH_UNSET) {
bytesToRead = dataSpecParameter.length;
} else {
final long contentLength = HttpUtil.getContentLength(
httpURLConnection.getHeaderField(HttpHeaders.CONTENT_LENGTH),
httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE));
bytesToRead = contentLength != C.LENGTH_UNSET
? (contentLength - bytesToSkip)
: C.LENGTH_UNSET;
}
} else {
// Gzip is enabled. If the server opts to use gzip then the content length in the
// response will be that of the compressed data, which isn't what we want. Always use
// the dataSpec length in this case.
bytesToRead = dataSpecParameter.length;
}
try {
inputStream = httpURLConnection.getInputStream();
if (isCompressed) {
inputStream = new GZIPInputStream(inputStream);
}
} catch (final IOException e) {
closeConnectionQuietly();
throw new HttpDataSourceException(e, dataSpec,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
}
opened = true;
transferStarted(dataSpecParameter);
try {
skipFully(bytesToSkip, dataSpec);
} catch (final IOException e) {
closeConnectionQuietly();
if (e instanceof HttpDataSourceException) {
throw (HttpDataSourceException) e;
}
throw new HttpDataSourceException(e, dataSpec,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
}
return bytesToRead;
}
@Override
public int read(@NonNull final byte[] buffer, final int offset, final int length)
throws HttpDataSourceException {
try {
return readInternal(buffer, offset, length);
} catch (final IOException e) {
throw HttpDataSourceException.createForIOException(e, castNonNull(dataSpec),
HttpDataSourceException.TYPE_READ);
}
}
@Override
public void close() throws HttpDataSourceException {
try {
final InputStream connectionInputStream = this.inputStream;
if (connectionInputStream != null) {
final long bytesRemaining = bytesToRead == C.LENGTH_UNSET
? C.LENGTH_UNSET
: bytesToRead - bytesRead;
maybeTerminateInputStream(connection, bytesRemaining);
try {
connectionInputStream.close();
} catch (final IOException e) {
throw new HttpDataSourceException(e, castNonNull(dataSpec),
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_CLOSE);
}
}
} finally {
inputStream = null;
closeConnectionQuietly();
if (opened) {
opened = false;
transferEnded();
}
}
}
@NonNull
private HttpURLConnection makeConnection(@NonNull final DataSpec dataSpecToUse)
throws IOException {
URL url = new URL(dataSpecToUse.uri.toString());
@HttpMethod int httpMethod = dataSpecToUse.httpMethod;
@Nullable byte[] httpBody = dataSpecToUse.httpBody;
final long position = dataSpecToUse.position;
final long length = dataSpecToUse.length;
final boolean allowGzip = dataSpecToUse.isFlagSet(DataSpec.FLAG_ALLOW_GZIP);
if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) {
// HttpURLConnection disallows cross-protocol redirects, but otherwise performs
// redirection automatically. This is the behavior we want, so use it.
return makeConnection(url, httpMethod, httpBody, position, length, allowGzip, true,
dataSpecToUse.httpRequestHeaders);
}
// We need to handle redirects ourselves to allow cross-protocol redirects or to keep the
// POST request method for 302.
int redirectCount = 0;
while (redirectCount++ <= MAX_REDIRECTS) {
final HttpURLConnection httpURLConnection = makeConnection(url, httpMethod, httpBody,
position, length, allowGzip, false, dataSpecToUse.httpRequestHeaders);
final int httpURLConnectionResponseCode = httpURLConnection.getResponseCode();
final String location = httpURLConnection.getHeaderField("Location");
if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD)
&& (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE
|| httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM
|| httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP
|| httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER
|| httpURLConnectionResponseCode == HTTP_STATUS_TEMPORARY_REDIRECT
|| httpURLConnectionResponseCode == HTTP_STATUS_PERMANENT_REDIRECT)) {
httpURLConnection.disconnect();
url = handleRedirect(url, location, dataSpecToUse);
} else if (httpMethod == DataSpec.HTTP_METHOD_POST
&& (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE
|| httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM
|| httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP
|| httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER)) {
httpURLConnection.disconnect();
final boolean shouldKeepPost = keepPostFor302Redirects
&& responseCode == HttpURLConnection.HTTP_MOVED_TEMP;
if (!shouldKeepPost) {
// POST request follows the redirect and is transformed into a GET request.
httpMethod = DataSpec.HTTP_METHOD_GET;
httpBody = null;
}
url = handleRedirect(url, location, dataSpecToUse);
} else {
return httpURLConnection;
}
}
// If we get here we've been redirected more times than are permitted.
throw new HttpDataSourceException(
new NoRouteToHostException("Too many redirects: " + redirectCount),
dataSpecToUse,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
HttpDataSourceException.TYPE_OPEN);
}
/**
* Configures a connection and opens it.
*
* @param url The url to connect to.
* @param httpMethod The http method.
* @param httpBody The body data, or {@code null} if not required.
* @param position The byte offset of the requested data.
* @param length The length of the requested data, or {@link C#LENGTH_UNSET}.
* @param allowGzip Whether to allow the use of gzip.
* @param followRedirects Whether to follow redirects.
* @param requestParameters parameters (HTTP headers) to include in request.
* @return the connection opened
*/
@SuppressWarnings("checkstyle:ParameterNumber")
@NonNull
private HttpURLConnection makeConnection(
@NonNull final URL url,
@HttpMethod final int httpMethod,
@Nullable final byte[] httpBody,
final long position,
final long length,
final boolean allowGzip,
final boolean followRedirects,
final Map<String, String> requestParameters) throws IOException {
// This is the method that contains breaking changes with respect to DefaultHttpDataSource!
String requestUrl = url.toString();
// Don't add the request number parameter if it has been already added (for instance in
// DASH manifests) or if that's not a videoplayback URL
final boolean isVideoPlaybackUrl = url.getPath().startsWith("/videoplayback");
if (isVideoPlaybackUrl && rnParameterEnabled && !requestUrl.contains(RN_PARAMETER)) {
requestUrl += RN_PARAMETER + requestNumber;
++requestNumber;
}
if (rangeParameterEnabled && isVideoPlaybackUrl) {
final String rangeParameterBuilt = buildRangeParameter(position, length);
if (rangeParameterBuilt != null) {
requestUrl += rangeParameterBuilt;
}
}
final HttpURLConnection httpURLConnection = openConnection(new URL(requestUrl));
httpURLConnection.setConnectTimeout(connectTimeoutMillis);
httpURLConnection.setReadTimeout(readTimeoutMillis);
final Map<String, String> requestHeaders = new HashMap<>();
if (defaultRequestProperties != null) {
requestHeaders.putAll(defaultRequestProperties.getSnapshot());
}
requestHeaders.putAll(requestProperties.getSnapshot());
requestHeaders.putAll(requestParameters);
for (final Map.Entry<String, String> property : requestHeaders.entrySet()) {
httpURLConnection.setRequestProperty(property.getKey(), property.getValue());
}
if (!rangeParameterEnabled) {
final String rangeHeader = buildRangeRequestHeader(position, length);
if (rangeHeader != null) {
httpURLConnection.setRequestProperty(HttpHeaders.RANGE, rangeHeader);
}
}
if (isWebStreamingUrl(requestUrl)
|| isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) {
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty");
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_MODE, "cors");
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_SITE, "cross-site");
}
httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers");
final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl);
final boolean isIosStreamingUrl = isIosStreamingUrl(requestUrl);
if (isAndroidStreamingUrl) {
// Improvement which may be done: find the content country used to request YouTube
// contents to add it in the user agent instead of using the default
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getAndroidUserAgent(null));
} else if (isIosStreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getIosUserAgent(null));
} else {
// non-mobile user agent
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT);
}
httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING,
allowGzip ? "gzip" : "identity");
httpURLConnection.setInstanceFollowRedirects(followRedirects);
httpURLConnection.setDoOutput(httpBody != null);
// Mobile clients uses POST requests to fetch contents
httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl
? "POST"
: DataSpec.getStringForHttpMethod(httpMethod));
if (httpBody != null) {
httpURLConnection.setFixedLengthStreamingMode(httpBody.length);
httpURLConnection.connect();
final OutputStream os = httpURLConnection.getOutputStream();
os.write(httpBody);
os.close();
} else {
httpURLConnection.connect();
}
return httpURLConnection;
}
/**
* Creates an {@link HttpURLConnection} that is connected with the {@code url}.
*
* @param url the {@link URL} to create an {@link HttpURLConnection}
* @return an {@link HttpURLConnection} created with the {@code url}
*/
private HttpURLConnection openConnection(@NonNull final URL url) throws IOException {
return (HttpURLConnection) url.openConnection();
}
/**
* Handles a redirect.
*
* @param originalUrl The original URL.
* @param location The Location header in the response. May be {@code null}.
* @param dataSpecToHandleRedirect The {@link DataSpec}.
* @return The next URL.
* @throws HttpDataSourceException If redirection isn't possible.
*/
@NonNull
private URL handleRedirect(final URL originalUrl,
@Nullable final String location,
final DataSpec dataSpecToHandleRedirect)
throws HttpDataSourceException {
if (location == null) {
throw new HttpDataSourceException("Null location redirect", dataSpecToHandleRedirect,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
HttpDataSourceException.TYPE_OPEN);
}
// Form the new url.
final URL url;
try {
url = new URL(originalUrl, location);
} catch (final MalformedURLException e) {
throw new HttpDataSourceException(e, dataSpecToHandleRedirect,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
HttpDataSourceException.TYPE_OPEN);
}
// Check that the protocol of the new url is supported.
final String protocol = url.getProtocol();
if (!"https".equals(protocol) && !"http".equals(protocol)) {
throw new HttpDataSourceException("Unsupported protocol redirect: " + protocol,
dataSpecToHandleRedirect,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
HttpDataSourceException.TYPE_OPEN);
}
if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) {
throw new HttpDataSourceException(
"Disallowed cross-protocol redirect ("
+ originalUrl.getProtocol()
+ " to "
+ protocol
+ ")",
dataSpecToHandleRedirect,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
HttpDataSourceException.TYPE_OPEN);
}
return url;
}
/**
* Attempts to skip the specified number of bytes in full.
*
* @param bytesToSkip The number of bytes to skip.
* @param dataSpecToUse The {@link DataSpec}.
* @throws IOException If the thread is interrupted during the operation, or if the data ended
* before skipping the specified number of bytes.
*/
@SuppressWarnings("checkstyle:FinalParameters")
private void skipFully(long bytesToSkip, final DataSpec dataSpecToUse) throws IOException {
if (bytesToSkip == 0) {
return;
}
final byte[] skipBuffer = new byte[4096];
while (bytesToSkip > 0) {
final int readLength = (int) min(bytesToSkip, skipBuffer.length);
final int read = castNonNull(inputStream).read(skipBuffer, 0, readLength);
if (Thread.currentThread().isInterrupted()) {
throw new HttpDataSourceException(
new InterruptedIOException(),
dataSpecToUse,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
HttpDataSourceException.TYPE_OPEN);
}
if (read == -1) {
throw new HttpDataSourceException(
dataSpecToUse,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
HttpDataSourceException.TYPE_OPEN);
}
bytesToSkip -= read;
bytesTransferred(read);
}
}
/**
* Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at
* index {@code offset}.
*
* <p>
* This method blocks until at least one byte of data can be read, the end of the opened range
* is detected, or an exception is thrown.
* </p>
*
* @param buffer The buffer into which the read data should be stored.
* @param offset The start offset into {@code buffer} at which data should be written.
* @param readLength The maximum number of bytes to read.
* @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened
* range is reached.
* @throws IOException If an error occurs reading from the source.
*/
@SuppressWarnings("checkstyle:FinalParameters")
private int readInternal(final byte[] buffer, final int offset, int readLength)
throws IOException {
if (readLength == 0) {
return 0;
}
if (bytesToRead != C.LENGTH_UNSET) {
final long bytesRemaining = bytesToRead - bytesRead;
if (bytesRemaining == 0) {
return C.RESULT_END_OF_INPUT;
}
readLength = (int) min(readLength, bytesRemaining);
}
final int read = castNonNull(inputStream).read(buffer, offset, readLength);
if (read == -1) {
return C.RESULT_END_OF_INPUT;
}
bytesRead += read;
bytesTransferred(read);
return read;
}
/**
* On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can
* block for a long time if the stream has a lot of data remaining. Call this method before
* closing the input stream to make a best effort to cause the input stream to encounter an
* unexpected end of input, working around this issue. On other platform API levels, the method
* does nothing.
*
* @param connection The connection whose {@link InputStream} should be terminated.
* @param bytesRemaining The number of bytes remaining to be read from the input stream if its
* length is known. {@link C#LENGTH_UNSET} otherwise.
*/
private static void maybeTerminateInputStream(@Nullable final HttpURLConnection connection,
final long bytesRemaining) {
if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) {
return;
}
try {
final InputStream inputStream = connection.getInputStream();
if (bytesRemaining == C.LENGTH_UNSET) {
// If the input stream has already ended, do nothing. The socket may be re-used.
if (inputStream.read() == -1) {
return;
}
} else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) {
// There isn't much data left. Prefer to allow it to drain, which may allow the
// socket to be re-used.
return;
}
final String className = inputStream.getClass().getName();
if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream"
.equals(className)
|| "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream"
.equals(className)) {
final Class<?> superclass = inputStream.getClass().getSuperclass();
final Method unexpectedEndOfInput = checkNotNull(superclass).getDeclaredMethod(
"unexpectedEndOfInput");
unexpectedEndOfInput.setAccessible(true);
unexpectedEndOfInput.invoke(inputStream);
}
} catch (final Exception e) {
// If an IOException then the connection didn't ever have an input stream, or it was
// closed already. If another type of exception then something went wrong, most likely
// the device isn't using okhttp.
}
}
/**
* Closes the current connection quietly, if there is one.
*/
private void closeConnectionQuietly() {
if (connection != null) {
try {
connection.disconnect();
} catch (final Exception e) {
Log.e(TAG, "Unexpected error while disconnecting", e);
}
connection = null;
}
}
private static boolean isCompressed(@NonNull final HttpURLConnection connection) {
final String contentEncoding = connection.getHeaderField("Content-Encoding");
return "gzip".equalsIgnoreCase(contentEncoding);
}
/**
* Builds a {@code range} parameter for the given position and length.
*
* <p>
* To fetch its contents, YouTube use range requests which append a {@code range} parameter
* to videoplayback URLs instead of the {@code Range} header (even if the server respond
* correctly when requesting a range of a ressouce with it).
* </p>
*
* <p>
* The parameter works in the same way as the header.
* </p>
*
* @param position The request position.
* @param length The request length, or {@link C#LENGTH_UNSET} if the request is unbounded.
* @return The corresponding {@code range} parameter, or {@code null} if this parameter is
* unnecessary because the whole resource is being requested.
*/
@Nullable
private static String buildRangeParameter(final long position, final long length) {
if (position == 0 && length == C.LENGTH_UNSET) {
return null;
}
final StringBuilder rangeParameter = new StringBuilder();
rangeParameter.append("&range=");
rangeParameter.append(position);
rangeParameter.append("-");
if (length != C.LENGTH_UNSET) {
rangeParameter.append(position + length - 1);
}
return rangeParameter.toString();
}
private static final class NullFilteringHeadersMap
extends ForwardingMap<String, List<String>> {
private final Map<String, List<String>> headers;
NullFilteringHeadersMap(final Map<String, List<String>> headers) {
this.headers = headers;
}
@NonNull
@Override
protected Map<String, List<String>> delegate() {
return headers;
}
@Override
public boolean containsKey(@Nullable final Object key) {
return key != null && super.containsKey(key);
}
@Nullable
@Override
public List<String> get(@Nullable final Object key) {
return key == null ? null : super.get(key);
}
@NonNull
@Override
public Set<String> keySet() {
return Sets.filter(super.keySet(), Objects::nonNull);
}
@NonNull
@Override
public Set<Entry<String, List<String>>> entrySet() {
return Sets.filter(super.entrySet(), entry -> entry.getKey() != null);
}
@Override
public int size() {
return super.size() - (super.containsKey(null) ? 1 : 0);
}
@Override
public boolean isEmpty() {
return super.isEmpty() || (super.size() == 1 && super.containsKey(null));
}
@Override
public boolean containsValue(@Nullable final Object value) {
return super.standardContainsValue(value);
}
@Override
public boolean equals(@Nullable final Object object) {
return object != null && super.standardEquals(object);
}
@Override
public int hashCode() {
return super.standardHashCode();
}
}
}