using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.IO; using MediaBrowser.Common.Net; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Net; using MediaBrowser.Model.Serialization; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Net.Cache; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Common.Implementations.HttpClientManager { /// /// Class HttpClientManager /// public class HttpClientManager : IHttpClient { /// /// The _logger /// private readonly ILogger _logger; /// /// The _app paths /// private readonly IApplicationPaths _appPaths; private readonly IJsonSerializer _jsonSerializer; private readonly FileSystemRepository _cacheRepository; /// /// Initializes a new instance of the class. /// /// The kernel. /// The logger. /// The json serializer. /// /// appPaths /// or /// logger /// public HttpClientManager(IApplicationPaths appPaths, ILogger logger, IJsonSerializer jsonSerializer) { if (appPaths == null) { throw new ArgumentNullException("appPaths"); } if (logger == null) { throw new ArgumentNullException("logger"); } _logger = logger; _jsonSerializer = jsonSerializer; _appPaths = appPaths; _cacheRepository = new FileSystemRepository(Path.Combine(_appPaths.CachePath, "downloads")); } /// /// Holds a dictionary of http clients by host. Use GetHttpClient(host) to retrieve or create a client for web requests. /// DON'T dispose it after use. /// /// The HTTP clients. private readonly ConcurrentDictionary _httpClients = new ConcurrentDictionary(); /// /// Gets /// /// The host. /// HttpClient. /// host private HttpClient GetHttpClient(string host) { if (string.IsNullOrEmpty(host)) { throw new ArgumentNullException("host"); } HttpClient client; if (!_httpClients.TryGetValue(host, out client)) { var handler = new WebRequestHandler { CachePolicy = new RequestCachePolicy(RequestCacheLevel.BypassCache), AutomaticDecompression = DecompressionMethods.None }; client = new HttpClient(handler); client.Timeout = TimeSpan.FromSeconds(15); _httpClients.TryAdd(host, client); } return client; } /// /// Performs a GET request and returns the resulting stream /// /// The options. /// Task{Stream}. /// /// public async Task Get(HttpRequestOptions options) { ValidateParams(options.Url, options.CancellationToken); HttpResponseInfo cachedInfo = null; var urlHash = options.Url.GetMD5().ToString(); var cachedInfoPath = _cacheRepository.GetResourcePath(urlHash + ".js"); var cachedReponsePath = _cacheRepository.GetResourcePath(urlHash + ".dat"); if (options.EnableResponseCache) { try { cachedInfo = _jsonSerializer.DeserializeFromFile(cachedInfoPath); } catch (FileNotFoundException) { } if (cachedInfo != null) { var now = DateTime.UtcNow; var isCacheValid = (!cachedInfo.MustRevalidate && !string.IsNullOrEmpty(cachedInfo.Etag) && (now - cachedInfo.RequestDate).TotalDays < 7) || (cachedInfo.Expires.HasValue && cachedInfo.Expires.Value > now); if (isCacheValid) { _logger.Debug("Cache is still valid for {0}", options.Url); try { return GetCachedResponse(cachedReponsePath); } catch (FileNotFoundException) { } } } } options.CancellationToken.ThrowIfCancellationRequested(); var message = GetHttpRequestMessage(options); if (options.EnableResponseCache && cachedInfo != null) { if (!string.IsNullOrEmpty(cachedInfo.Etag)) { message.Headers.Add("If-None-Match", cachedInfo.Etag); } else if (cachedInfo.LastModified.HasValue) { message.Headers.IfModifiedSince = new DateTimeOffset(cachedInfo.LastModified.Value); } } if (options.ResourcePool != null) { await options.ResourcePool.WaitAsync(options.CancellationToken).ConfigureAwait(false); } _logger.Info("HttpClientManager.Get url: {0}", options.Url); try { options.CancellationToken.ThrowIfCancellationRequested(); var response = await GetHttpClient(GetHostFromUrl(options.Url)).SendAsync(message, HttpCompletionOption.ResponseHeadersRead, options.CancellationToken).ConfigureAwait(false); if (options.EnableResponseCache) { if (response.StatusCode != HttpStatusCode.NotModified) { EnsureSuccessStatusCode(response); } options.CancellationToken.ThrowIfCancellationRequested(); cachedInfo = UpdateInfoCache(cachedInfo, options.Url, cachedInfoPath, response); if (response.StatusCode == HttpStatusCode.NotModified) { _logger.Debug("Server indicates not modified for {0}. Returning cached result.", options.Url); return GetCachedResponse(cachedReponsePath); } if (!string.IsNullOrEmpty(cachedInfo.Etag) || cachedInfo.LastModified.HasValue || (cachedInfo.Expires.HasValue && cachedInfo.Expires.Value > DateTime.UtcNow)) { await UpdateResponseCache(response, cachedReponsePath).ConfigureAwait(false); return GetCachedResponse(cachedReponsePath); } } else { EnsureSuccessStatusCode(response); options.CancellationToken.ThrowIfCancellationRequested(); } return await response.Content.ReadAsStreamAsync().ConfigureAwait(false); } catch (OperationCanceledException ex) { throw GetCancellationException(options.Url, options.CancellationToken, ex); } catch (HttpRequestException ex) { _logger.ErrorException("Error getting response from " + options.Url, ex); throw new HttpException(ex.Message, ex); } catch (Exception ex) { _logger.ErrorException("Error getting response from " + options.Url, ex); throw; } finally { if (options.ResourcePool != null) { options.ResourcePool.Release(); } } } /// /// Performs a GET request and returns the resulting stream /// /// The URL. /// The resource pool. /// The cancellation token. /// Task{Stream}. public Task Get(string url, SemaphoreSlim resourcePool, CancellationToken cancellationToken) { return Get(new HttpRequestOptions { Url = url, ResourcePool = resourcePool, CancellationToken = cancellationToken, }); } /// /// Gets the specified URL. /// /// The URL. /// The cancellation token. /// Task{Stream}. public Task Get(string url, CancellationToken cancellationToken) { return Get(url, null, cancellationToken); } /// /// Gets the cached response. /// /// The response path. /// Stream. private Stream GetCachedResponse(string responsePath) { return File.OpenRead(responsePath); } /// /// Updates the cache. /// /// The cached info. /// The URL. /// The path. /// The response. private HttpResponseInfo UpdateInfoCache(HttpResponseInfo cachedInfo, string url, string path, HttpResponseMessage response) { var fileExists = true; if (cachedInfo == null) { cachedInfo = new HttpResponseInfo(); fileExists = false; } cachedInfo.Url = url; cachedInfo.RequestDate = DateTime.UtcNow; var etag = response.Headers.ETag; if (etag != null) { cachedInfo.Etag = etag.Tag; } var modified = response.Content.Headers.LastModified; if (modified.HasValue) { cachedInfo.LastModified = modified.Value.UtcDateTime; } else if (response.Headers.Age.HasValue) { cachedInfo.LastModified = DateTime.UtcNow.Subtract(response.Headers.Age.Value); } var expires = response.Content.Headers.Expires; if (expires.HasValue) { cachedInfo.Expires = expires.Value.UtcDateTime; } else { var cacheControl = response.Headers.CacheControl; if (cacheControl != null) { if (cacheControl.MaxAge.HasValue) { var baseline = cachedInfo.LastModified ?? DateTime.UtcNow; cachedInfo.Expires = baseline.Add(cacheControl.MaxAge.Value); } cachedInfo.MustRevalidate = cacheControl.MustRevalidate; } } if (string.IsNullOrEmpty(cachedInfo.Etag) && !cachedInfo.Expires.HasValue && !cachedInfo.LastModified.HasValue) { // Nothing to cache if (fileExists) { File.Delete(path); } } else { _jsonSerializer.SerializeToFile(cachedInfo, path); } return cachedInfo; } /// /// Updates the response cache. /// /// The response. /// The path. /// Task. private async Task UpdateResponseCache(HttpResponseMessage response, string path) { using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) { using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) { await stream.CopyToAsync(fs).ConfigureAwait(false); } } } /// /// Performs a POST request /// /// The URL. /// Params to add to the POST data. /// The resource pool. /// The cancellation token. /// stream on success, null on failure /// postData /// public async Task Post(string url, Dictionary postData, SemaphoreSlim resourcePool, CancellationToken cancellationToken) { ValidateParams(url, cancellationToken); if (postData == null) { throw new ArgumentNullException("postData"); } cancellationToken.ThrowIfCancellationRequested(); var strings = postData.Keys.Select(key => string.Format("{0}={1}", key, postData[key])); var postContent = string.Join("&", strings.ToArray()); var content = new StringContent(postContent, Encoding.UTF8, "application/x-www-form-urlencoded"); if (resourcePool != null) { await resourcePool.WaitAsync(cancellationToken).ConfigureAwait(false); } _logger.Info("HttpClientManager.Post url: {0}", url); try { cancellationToken.ThrowIfCancellationRequested(); var msg = await GetHttpClient(GetHostFromUrl(url)).PostAsync(url, content, cancellationToken).ConfigureAwait(false); EnsureSuccessStatusCode(msg); return await msg.Content.ReadAsStreamAsync().ConfigureAwait(false); } catch (OperationCanceledException ex) { throw GetCancellationException(url, cancellationToken, ex); } catch (HttpRequestException ex) { _logger.ErrorException("Error getting response from " + url, ex); throw new HttpException(ex.Message, ex); } finally { if (resourcePool != null) { resourcePool.Release(); } } } /// /// Downloads the contents of a given url into a temporary location /// /// The options. /// Task{System.String}. /// progress /// /// public async Task GetTempFile(HttpRequestOptions options) { ValidateParams(options.Url, options.CancellationToken); var tempFile = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + ".tmp"); if (options.Progress == null) { throw new ArgumentNullException("progress"); } options.CancellationToken.ThrowIfCancellationRequested(); if (options.ResourcePool != null) { await options.ResourcePool.WaitAsync(options.CancellationToken).ConfigureAwait(false); } options.Progress.Report(0); _logger.Info("HttpClientManager.GetTempFile url: {0}, temp file: {1}", options.Url, tempFile); try { options.CancellationToken.ThrowIfCancellationRequested(); using (var response = await GetHttpClient(GetHostFromUrl(options.Url)).SendAsync(GetHttpRequestMessage(options), HttpCompletionOption.ResponseHeadersRead, options.CancellationToken).ConfigureAwait(false)) { EnsureSuccessStatusCode(response); options.CancellationToken.ThrowIfCancellationRequested(); var contentLength = GetContentLength(response); if (!contentLength.HasValue) { // We're not able to track progress using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) { using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) { await stream.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, options.CancellationToken).ConfigureAwait(false); } } } else { using (var stream = ProgressStream.CreateReadProgressStream(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), options.Progress.Report, contentLength.Value)) { using (var fs = new FileStream(tempFile, FileMode.Create, FileAccess.Write, FileShare.Read, StreamDefaults.DefaultFileStreamBufferSize, FileOptions.Asynchronous)) { await stream.CopyToAsync(fs, StreamDefaults.DefaultCopyToBufferSize, options.CancellationToken).ConfigureAwait(false); } } } options.Progress.Report(100); options.CancellationToken.ThrowIfCancellationRequested(); } } catch (Exception ex) { HandleTempFileException(ex, options, tempFile); } finally { if (options.ResourcePool != null) { options.ResourcePool.Release(); } } return tempFile; } /// /// Gets the message. /// /// The options. /// HttpResponseMessage. private HttpRequestMessage GetHttpRequestMessage(HttpRequestOptions options) { var message = new HttpRequestMessage(HttpMethod.Get, options.Url); if (!string.IsNullOrEmpty(options.UserAgent)) { message.Headers.Add("User-Agent", options.UserAgent); } if (!string.IsNullOrEmpty(options.AcceptHeader)) { message.Headers.Add("Accept", options.AcceptHeader); } return message; } /// /// Gets the length of the content. /// /// The response. /// System.Nullable{System.Int64}. private long? GetContentLength(HttpResponseMessage response) { IEnumerable lengthValues; if (!response.Headers.TryGetValues("content-length", out lengthValues) && !response.Content.Headers.TryGetValues("content-length", out lengthValues)) { return null; } return long.Parse(string.Join(string.Empty, lengthValues.ToArray()), UsCulture); } protected static readonly CultureInfo UsCulture = new CultureInfo("en-US"); /// /// Handles the temp file exception. /// /// The ex. /// The options. /// The temp file. /// Task. /// private void HandleTempFileException(Exception ex, HttpRequestOptions options, string tempFile) { var operationCanceledException = ex as OperationCanceledException; if (operationCanceledException != null) { // Cleanup if (File.Exists(tempFile)) { File.Delete(tempFile); } throw GetCancellationException(options.Url, options.CancellationToken, operationCanceledException); } _logger.ErrorException("Error getting response from " + options.Url, ex); var httpRequestException = ex as HttpRequestException; // Cleanup if (File.Exists(tempFile)) { File.Delete(tempFile); } if (httpRequestException != null) { throw new HttpException(ex.Message, ex); } throw ex; } /// /// Validates the params. /// /// The URL. /// The cancellation token. /// url private void ValidateParams(string url, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(url)) { throw new ArgumentNullException("url"); } if (cancellationToken == null) { throw new ArgumentNullException("cancellationToken"); } } /// /// Gets the host from URL. /// /// The URL. /// System.String. private string GetHostFromUrl(string url) { var start = url.IndexOf("://", StringComparison.OrdinalIgnoreCase) + 3; var len = url.IndexOf('/', start) - start; return url.Substring(start, len); } /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool dispose) { if (dispose) { foreach (var client in _httpClients.Values.ToList()) { client.Dispose(); } _httpClients.Clear(); } } /// /// Throws the cancellation exception. /// /// The URL. /// The cancellation token. /// The exception. /// Exception. private Exception GetCancellationException(string url, CancellationToken cancellationToken, OperationCanceledException exception) { // If the HttpClient's timeout is reached, it will cancel the Task internally if (!cancellationToken.IsCancellationRequested) { var msg = string.Format("Connection to {0} timed out", url); _logger.Error(msg); // Throw an HttpException so that the caller doesn't think it was cancelled by user code return new HttpException(msg, exception) { IsTimedOut = true }; } return exception; } /// /// Ensures the success status code. /// /// The response. /// private void EnsureSuccessStatusCode(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { throw new HttpException(response.ReasonPhrase) { StatusCode = response.StatusCode }; } } /// /// Posts the specified URL. /// /// The URL. /// The post data. /// The cancellation token. /// Task{Stream}. public Task Post(string url, Dictionary postData, CancellationToken cancellationToken) { return Post(url, postData, null, cancellationToken); } } }