368 lines
14 KiB
C#
368 lines
14 KiB
C#
/*
|
|
Copyright 2013 Google Inc
|
|
|
|
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.
|
|
*/
|
|
|
|
using System;
|
|
using System.IO;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
using Google.Apis.Logging;
|
|
using Google.Apis.Media;
|
|
using Google.Apis.Services;
|
|
using Google.Apis.Util;
|
|
using System.Net.Http.Headers;
|
|
|
|
namespace Google.Apis.Download
|
|
{
|
|
/// <summary>
|
|
/// A media downloader implementation which handles media downloads.
|
|
/// </summary>
|
|
public class MediaDownloader : IMediaDownloader
|
|
{
|
|
static MediaDownloader()
|
|
{
|
|
UriPatcher.PatchUriQuirks();
|
|
}
|
|
|
|
private static readonly ILogger Logger = ApplicationContext.Logger.ForType<MediaDownloader>();
|
|
|
|
/// <summary>The service which this downloader belongs to.</summary>
|
|
private readonly IClientService service;
|
|
|
|
private const int MB = 0x100000;
|
|
|
|
/// <summary>Maximum chunk size. Default value is 10*MB.</summary>
|
|
public const int MaximumChunkSize = 10 * MB;
|
|
|
|
private int chunkSize = MaximumChunkSize;
|
|
/// <summary>
|
|
/// Gets or sets the amount of data that will be downloaded before notifying the caller of
|
|
/// the download's progress.
|
|
/// Must not exceed <see cref="MaximumChunkSize"/>.
|
|
/// Default value is <see cref="MaximumChunkSize"/>.
|
|
/// </summary>
|
|
public int ChunkSize
|
|
{
|
|
get { return chunkSize; }
|
|
set
|
|
{
|
|
if (value > MaximumChunkSize)
|
|
{
|
|
throw new ArgumentOutOfRangeException("ChunkSize");
|
|
}
|
|
chunkSize = value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The range header for the request, if any. This can be used to download specific parts
|
|
/// of the requested media.
|
|
/// </summary>
|
|
public RangeHeaderValue Range { get; set; }
|
|
|
|
#region Progress
|
|
|
|
/// <summary>
|
|
/// Download progress model, which contains the status of the download, the amount of bytes whose where
|
|
/// downloaded so far, and an exception in case an error had occurred.
|
|
/// </summary>
|
|
private class DownloadProgress : IDownloadProgress
|
|
{
|
|
/// <summary>Constructs a new progress instance.</summary>
|
|
/// <param name="status">The status of the download.</param>
|
|
/// <param name="bytes">The number of bytes received so far.</param>
|
|
public DownloadProgress(DownloadStatus status, long bytes)
|
|
{
|
|
Status = status;
|
|
BytesDownloaded = bytes;
|
|
}
|
|
|
|
/// <summary>Constructs a new progress instance.</summary>
|
|
/// <param name="exception">An exception which occurred during the download.</param>
|
|
/// <param name="bytes">The number of bytes received before the exception occurred.</param>
|
|
public DownloadProgress(Exception exception, long bytes)
|
|
{
|
|
Status = DownloadStatus.Failed;
|
|
BytesDownloaded = bytes;
|
|
Exception = exception;
|
|
}
|
|
|
|
/// <summary>Gets or sets the status of the download.</summary>
|
|
public DownloadStatus Status { get; private set; }
|
|
|
|
/// <summary>Gets or sets the amount of bytes that have been downloaded so far.</summary>
|
|
public long BytesDownloaded { get; private set; }
|
|
|
|
/// <summary>Gets or sets the exception which occurred during the download or <c>null</c>.</summary>
|
|
public Exception Exception { get; private set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the current progress and call the <see cref="ProgressChanged"/> event to notify listeners.
|
|
/// </summary>
|
|
private void UpdateProgress(IDownloadProgress progress)
|
|
{
|
|
ProgressChanged?.Invoke(progress);
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>Constructs a new downloader with the given client service.</summary>
|
|
public MediaDownloader(IClientService service)
|
|
{
|
|
this.service = service;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the callback for modifying requests made when downloading.
|
|
/// </summary>
|
|
public Action<HttpRequestMessage> ModifyRequest { get; set; }
|
|
|
|
#region IMediaDownloader Overrides
|
|
|
|
/// <inheritdoc/>
|
|
public event Action<IDownloadProgress> ProgressChanged;
|
|
|
|
#region Download (sync and async)
|
|
|
|
/// <inheritdoc/>
|
|
public IDownloadProgress Download(string url, Stream stream)
|
|
{
|
|
return DownloadCoreAsync(url, stream, CancellationToken.None).Result;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<IDownloadProgress> DownloadAsync(string url, Stream stream)
|
|
{
|
|
return await DownloadAsync(url, stream, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<IDownloadProgress> DownloadAsync(string url, Stream stream,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await DownloadCoreAsync(url, stream, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// CountedBuffer bundles together a byte buffer and a count of valid bytes.
|
|
/// </summary>
|
|
private class CountedBuffer
|
|
{
|
|
public byte[] Data { get; set; }
|
|
|
|
/// <summary>
|
|
/// How many bytes at the beginning of Data are valid.
|
|
/// </summary>
|
|
public int Count { get; private set; }
|
|
|
|
public CountedBuffer(int size)
|
|
{
|
|
Data = new byte[size];
|
|
Count = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the buffer contains no data.
|
|
/// </summary>
|
|
public bool IsEmpty { get { return Count == 0; } }
|
|
|
|
/// <summary>
|
|
/// Read data from stream until the stream is empty or the buffer is full.
|
|
/// </summary>
|
|
/// <param name="stream">Stream from which to read.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
public async Task Fill(Stream stream, CancellationToken cancellationToken)
|
|
{
|
|
// ReadAsync may return if it has *any* data available, so we loop.
|
|
while (Count < Data.Length)
|
|
{
|
|
int read = await stream.ReadAsync(Data, Count, Data.Length - Count, cancellationToken).ConfigureAwait(false);
|
|
if (read == 0) { break; }
|
|
Count += read;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove the first n bytes of the buffer. Move any remaining valid bytes to the beginning.
|
|
/// Trying to remove more bytes than the buffer contains just clears the buffer.
|
|
/// </summary>
|
|
/// <param name="n">The number of bytes to remove.</param>
|
|
public void RemoveFromFront(int n)
|
|
{
|
|
if (n >= Count)
|
|
{
|
|
Count = 0;
|
|
}
|
|
else
|
|
{
|
|
// Some valid data remains.
|
|
Array.Copy(Data, n, Data, 0, Count - n);
|
|
Count -= n;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The core download logic. We download the media and write it to an output stream
|
|
/// ChunkSize bytes at a time, raising the ProgressChanged event after each chunk.
|
|
///
|
|
/// The chunking behavior is largely a historical artifact: a previous implementation
|
|
/// issued multiple web requests, each for ChunkSize bytes. Now we do everything in
|
|
/// one request, but the API and client-visible behavior are retained for compatibility.
|
|
/// </summary>
|
|
/// <param name="url">The URL of the resource to download.</param>
|
|
/// <param name="stream">The download will download the resource into this stream.</param>
|
|
/// <param name="cancellationToken">A cancellation token to cancel this download in the middle.</param>
|
|
/// <returns>A task with the download progress object. If an exception occurred during the download, its
|
|
/// <see cref="IDownloadProgress.Exception "/> property will contain the exception.</returns>
|
|
private async Task<IDownloadProgress> DownloadCoreAsync(string url, Stream stream,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
url.ThrowIfNull("url");
|
|
stream.ThrowIfNull("stream");
|
|
if (!stream.CanWrite)
|
|
{
|
|
throw new ArgumentException("stream doesn't support write operations");
|
|
}
|
|
|
|
// Add alt=media to the query parameters.
|
|
var uri = new UriBuilder(url);
|
|
if (uri.Query == null || uri.Query.Length <= 1)
|
|
{
|
|
uri.Query = "alt=media";
|
|
}
|
|
else
|
|
{
|
|
// Remove the leading '?'. UriBuilder.Query doesn't round-trip.
|
|
uri.Query = uri.Query.Substring(1) + "&alt=media";
|
|
}
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Get, uri.ToString());
|
|
request.Headers.Range = Range;
|
|
ModifyRequest?.Invoke(request);
|
|
|
|
// Number of bytes sent to the caller's stream.
|
|
long bytesReturned = 0;
|
|
|
|
try
|
|
{
|
|
// Signal SendAsync to return as soon as the response headers are read.
|
|
// We'll stream the content ourselves as it becomes available.
|
|
var completionOption = HttpCompletionOption.ResponseHeadersRead;
|
|
|
|
using (var response = await service.HttpClient.SendAsync(request, completionOption, cancellationToken).ConfigureAwait(false))
|
|
{
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
throw await MediaApiErrorHandling.ExceptionForResponseAsync(service, response).ConfigureAwait(false);
|
|
}
|
|
|
|
OnResponseReceived(response);
|
|
|
|
using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
|
|
{
|
|
// We send ChunkSize bytes at a time to the caller, but we keep ChunkSize + 1 bytes
|
|
// buffered. That way we can tell when we've reached the end of the response, even if the
|
|
// response length is evenly divisible by ChunkSize, and we can avoid sending a Downloading
|
|
// event followed by a Completed event with no bytes downloaded in between.
|
|
//
|
|
// This maintains the client-visible behavior of a previous implementation.
|
|
var buffer = new CountedBuffer(ChunkSize + 1);
|
|
|
|
while (true)
|
|
{
|
|
await buffer.Fill(responseStream, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Send one chunk to the caller's stream.
|
|
int bytesToReturn = Math.Min(ChunkSize, buffer.Count);
|
|
OnDataReceived(buffer.Data, bytesToReturn);
|
|
await stream.WriteAsync(buffer.Data, 0, bytesToReturn, cancellationToken).ConfigureAwait(false);
|
|
bytesReturned += bytesToReturn;
|
|
|
|
buffer.RemoveFromFront(ChunkSize);
|
|
if (buffer.IsEmpty)
|
|
{
|
|
// We had <= ChunkSize bytes buffered, so we've read and returned the entire response.
|
|
// Skip sending a Downloading event. We'll send Completed instead.
|
|
break;
|
|
}
|
|
|
|
UpdateProgress(new DownloadProgress(DownloadStatus.Downloading, bytesReturned));
|
|
}
|
|
}
|
|
OnDownloadCompleted();
|
|
|
|
var finalProgress = new DownloadProgress(DownloadStatus.Completed, bytesReturned);
|
|
UpdateProgress(finalProgress);
|
|
return finalProgress;
|
|
}
|
|
}
|
|
catch (TaskCanceledException ex)
|
|
{
|
|
Logger.Error(ex, "Download media was canceled");
|
|
UpdateProgress(new DownloadProgress(ex, bytesReturned));
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Error(ex, "Exception occurred while downloading media");
|
|
var progress = new DownloadProgress(ex, bytesReturned);
|
|
UpdateProgress(progress);
|
|
return progress;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a successful HTTP response is received, allowing subclasses to examine headers.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// For unsuccessful responses, an appropriate exception is thrown immediately, without this method
|
|
/// being called.
|
|
/// </remarks>
|
|
/// <param name="response">HTTP response received.</param>
|
|
protected virtual void OnResponseReceived(HttpResponseMessage response)
|
|
{
|
|
// No-op
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when an HTTP response is received, allowing subclasses to examine data before it's
|
|
/// written to the client stream.
|
|
/// </summary>
|
|
/// <param name="data">Byte array containing the data downloaded.</param>
|
|
/// <param name="length">Length of data downloaded in this chunk, in bytes.</param>
|
|
protected virtual void OnDataReceived(byte[] data, int length)
|
|
{
|
|
// No-op
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a download has completed, allowing subclasses to perform any final validation
|
|
/// or transformation.
|
|
/// </summary>
|
|
protected virtual void OnDownloadCompleted()
|
|
{
|
|
// No-op
|
|
}
|
|
}
|
|
}
|