diff options
| author | LukePulverenti Luke Pulverenti luke pulverenti <LukePulverenti Luke Pulverenti luke.pulverenti@gmail.com> | 2012-08-10 09:07:58 -0400 |
|---|---|---|
| committer | LukePulverenti Luke Pulverenti luke pulverenti <LukePulverenti Luke Pulverenti luke.pulverenti@gmail.com> | 2012-08-10 09:07:58 -0400 |
| commit | 2536011247de55c75e8b1f893e23b606e4200159 (patch) | |
| tree | f0cfa3893da3eedea162b651355088d068ade0ef /MediaBrowser.Common/Net/Handlers | |
| parent | dce7706382aaacae5ee74ba5048b76269e5390e5 (diff) | |
Added the ability for the server to handle byte-range requests, and also added a static file handler to utilize it
Diffstat (limited to 'MediaBrowser.Common/Net/Handlers')
| -rw-r--r-- | MediaBrowser.Common/Net/Handlers/BaseHandler.cs | 287 | ||||
| -rw-r--r-- | MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs | 282 |
2 files changed, 502 insertions, 67 deletions
diff --git a/MediaBrowser.Common/Net/Handlers/BaseHandler.cs b/MediaBrowser.Common/Net/Handlers/BaseHandler.cs index 42d2f81908..5cb476e027 100644 --- a/MediaBrowser.Common/Net/Handlers/BaseHandler.cs +++ b/MediaBrowser.Common/Net/Handlers/BaseHandler.cs @@ -3,32 +3,36 @@ using System.Collections.Generic; using System.Collections.Specialized;
using System.IO;
using System.IO.Compression;
+using System.Linq;
using System.Net;
+using MediaBrowser.Common.Logging;
namespace MediaBrowser.Common.Net.Handlers
{
public abstract class BaseHandler
{
- /// <summary>
- /// Response headers
- /// </summary>
- public IDictionary<string, string> Headers = new Dictionary<string, string>();
-
private Stream CompressedStream { get; set; }
- public virtual bool UseChunkedEncoding
+ public virtual bool? UseChunkedEncoding
{
get
{
- return true;
+ return null;
}
}
- public virtual long? ContentLength
+ private bool _TotalContentLengthDiscovered = false;
+ private long? _TotalContentLength = null;
+ public long? TotalContentLength
{
get
{
- return null;
+ if (!_TotalContentLengthDiscovered)
+ {
+ _TotalContentLength = GetTotalContentLength();
+ }
+
+ return _TotalContentLength;
}
}
@@ -44,29 +48,18 @@ namespace MediaBrowser.Common.Net.Handlers }
}
- /// <summary>
- /// The action to write the response to the output stream
- /// </summary>
- public Action<Stream> WriteStream
+ protected virtual bool SupportsByteRangeRequests
{
get
{
- return s =>
- {
- WriteReponse(s);
-
- if (!IsAsyncHandler)
- {
- DisposeResponseStream();
- }
- };
+ return false;
}
}
/// <summary>
- /// The original RequestContext
+ /// The original HttpListenerContext
/// </summary>
- public RequestContext RequestContext { get; set; }
+ protected HttpListenerContext HttpListenerContext { get; private set; }
/// <summary>
/// The original QueryString
@@ -75,7 +68,54 @@ namespace MediaBrowser.Common.Net.Handlers {
get
{
- return RequestContext.Request.QueryString;
+ return HttpListenerContext.Request.QueryString;
+ }
+ }
+
+ protected List<KeyValuePair<long, long?>> _RequestedRanges = null;
+ protected IEnumerable<KeyValuePair<long, long?>> RequestedRanges
+ {
+ get
+ {
+ if (_RequestedRanges == null)
+ {
+ _RequestedRanges = new List<KeyValuePair<long, long?>>();
+
+ if (IsRangeRequest)
+ {
+ // Example: bytes=0-,32-63
+ string[] ranges = HttpListenerContext.Request.Headers["Range"].Split('=')[1].Split(',');
+
+ foreach (string range in ranges)
+ {
+ string[] vals = range.Split('-');
+
+ long start = 0;
+ long? end = null;
+
+ if (!string.IsNullOrEmpty(vals[0]))
+ {
+ start = long.Parse(vals[0]);
+ }
+ if (!string.IsNullOrEmpty(vals[1]))
+ {
+ end = long.Parse(vals[1]);
+ }
+
+ _RequestedRanges.Add(new KeyValuePair<long, long?>(start, end));
+ }
+ }
+ }
+
+ return _RequestedRanges;
+ }
+ }
+
+ protected bool IsRangeRequest
+ {
+ get
+ {
+ return HttpListenerContext.Request.Headers.AllKeys.Contains("Range");
}
}
@@ -87,13 +127,7 @@ namespace MediaBrowser.Common.Net.Handlers /// <summary>
/// Gets the status code to include in the response headers
/// </summary>
- public virtual int StatusCode
- {
- get
- {
- return 200;
- }
- }
+ protected int StatusCode { get; set; }
/// <summary>
/// Gets the cache duration to include in the response headers
@@ -106,18 +140,25 @@ namespace MediaBrowser.Common.Net.Handlers }
}
+ private bool _LastDateModifiedDiscovered = false;
+ private DateTime? _LastDateModified = null;
/// <summary>
/// Gets the last date modified of the content being returned, if this can be determined.
/// This will be used to invalidate the cache, so it's not needed if CacheDuration is 0.
/// </summary>
- public virtual DateTime? LastDateModified
+ public DateTime? LastDateModified
{
get
{
- return null;
+ if (!_LastDateModifiedDiscovered)
+ {
+ _LastDateModified = GetLastDateModified();
+ }
+
+ return _LastDateModified;
}
}
-
+
public virtual bool CompressResponse
{
get
@@ -130,7 +171,7 @@ namespace MediaBrowser.Common.Net.Handlers {
get
{
- string enc = RequestContext.Request.Headers["Accept-Encoding"] ?? string.Empty;
+ string enc = HttpListenerContext.Request.Headers["Accept-Encoding"] ?? string.Empty;
return enc.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1 || enc.IndexOf("gzip", StringComparison.OrdinalIgnoreCase) != -1;
}
@@ -140,7 +181,7 @@ namespace MediaBrowser.Common.Net.Handlers {
get
{
- string enc = RequestContext.Request.Headers["Accept-Encoding"] ?? string.Empty;
+ string enc = HttpListenerContext.Request.Headers["Accept-Encoding"] ?? string.Empty;
if (enc.IndexOf("deflate", StringComparison.OrdinalIgnoreCase) != -1)
{
@@ -155,64 +196,138 @@ namespace MediaBrowser.Common.Net.Handlers }
}
- protected virtual void PrepareResponseBeforeWriteOutput(HttpListenerResponse response)
+ public void ProcessRequest(HttpListenerContext ctx)
{
- // Don't force this to true. HttpListener will default it to true if supported by the client.
- if (!UseChunkedEncoding)
+ HttpListenerContext = ctx;
+
+ Logger.LogInfo("Http Server received request at: " + ctx.Request.Url.ToString());
+ Logger.LogInfo("Http Headers: " + string.Join(",", ctx.Request.Headers.AllKeys.Select(k => k + "=" + ctx.Request.Headers[k])));
+
+ ctx.Response.AddHeader("Access-Control-Allow-Origin", "*");
+
+ ctx.Response.KeepAlive = true;
+
+ if (SupportsByteRangeRequests && IsRangeRequest)
{
- response.SendChunked = false;
+ ctx.Response.Headers["Accept-Ranges"] = "bytes";
}
+
+ // Set the initial status code
+ // When serving a range request, we need to return status code 206 to indicate a partial response body
+ StatusCode = SupportsByteRangeRequests && IsRangeRequest ? 206 : 200;
+
+ ctx.Response.ContentType = ContentType;
- if (ContentLength.HasValue)
+ TimeSpan cacheDuration = CacheDuration;
+
+ if (ctx.Request.Headers.AllKeys.Contains("If-Modified-Since"))
{
- response.ContentLength64 = ContentLength.Value;
+ DateTime ifModifiedSince;
+
+ if (DateTime.TryParse(ctx.Request.Headers["If-Modified-Since"].Replace(" GMT", string.Empty), out ifModifiedSince))
+ {
+ // If the cache hasn't expired yet just return a 304
+ if (IsCacheValid(ifModifiedSince, cacheDuration, LastDateModified))
+ {
+ StatusCode = 304;
+ }
+ }
}
- if (CompressResponse && ClientSupportsCompression)
+ if (StatusCode == 200 || StatusCode == 206)
{
- response.AddHeader("Content-Encoding", CompressionMethod);
+ ProcessUncachedResponse(ctx, cacheDuration);
}
-
- TimeSpan cacheDuration = CacheDuration;
-
- if (cacheDuration.Ticks > 0)
+ else
{
- CacheResponse(response, cacheDuration, LastDateModified);
+ ctx.Response.StatusCode = StatusCode;
+ ctx.Response.SendChunked = false;
+ DisposeResponseStream();
}
}
- private void CacheResponse(HttpListenerResponse response, TimeSpan duration, DateTime? dateModified)
+ private void ProcessUncachedResponse(HttpListenerContext ctx, TimeSpan cacheDuration)
{
- DateTime lastModified = dateModified ?? DateTime.Now;
+ long? totalContentLength = TotalContentLength;
- response.Headers[HttpResponseHeader.CacheControl] = "public, max-age=" + Convert.ToInt32(duration.TotalSeconds);
- response.Headers[HttpResponseHeader.Expires] = DateTime.Now.Add(duration).ToString("r");
- response.Headers[HttpResponseHeader.LastModified] = lastModified.ToString("r");
- }
+ // By default, use chunked encoding if we don't know the content length
+ bool useChunkedEncoding = UseChunkedEncoding == null ? (totalContentLength == null) : UseChunkedEncoding.Value;
- private void WriteReponse(Stream stream)
- {
- PrepareResponseBeforeWriteOutput(RequestContext.Response);
+ // Don't force this to true. HttpListener will default it to true if supported by the client.
+ if (!useChunkedEncoding)
+ {
+ ctx.Response.SendChunked = false;
+ }
+
+ // Set the content length, if we know it
+ if (totalContentLength.HasValue)
+ {
+ ctx.Response.ContentLength64 = totalContentLength.Value;
+ }
+ // Add the compression header
if (CompressResponse && ClientSupportsCompression)
{
- if (CompressionMethod.Equals("deflate", StringComparison.OrdinalIgnoreCase))
+ ctx.Response.AddHeader("Content-Encoding", CompressionMethod);
+ }
+
+ // Add caching headers
+ if (cacheDuration.Ticks > 0)
+ {
+ CacheResponse(ctx.Response, cacheDuration, LastDateModified);
+ }
+
+ PrepareUncachedResponse(ctx, cacheDuration);
+
+ // Set the status code
+ ctx.Response.StatusCode = StatusCode;
+
+ if (StatusCode == 200 || StatusCode == 206)
+ {
+ // Finally, write the response data
+ Stream outputStream = ctx.Response.OutputStream;
+
+ if (CompressResponse && ClientSupportsCompression)
{
- CompressedStream = new DeflateStream(stream, CompressionLevel.Fastest, false);
+ if (CompressionMethod.Equals("deflate", StringComparison.OrdinalIgnoreCase))
+ {
+ CompressedStream = new DeflateStream(outputStream, CompressionLevel.Fastest, false);
+ }
+ else
+ {
+ CompressedStream = new GZipStream(outputStream, CompressionLevel.Fastest, false);
+ }
+
+ outputStream = CompressedStream;
}
- else
+
+ WriteResponseToOutputStream(outputStream);
+
+ if (!IsAsyncHandler)
{
- CompressedStream = new GZipStream(stream, CompressionLevel.Fastest, false);
+ DisposeResponseStream();
}
-
- WriteResponseToOutputStream(CompressedStream);
}
else
{
- WriteResponseToOutputStream(stream);
+ ctx.Response.SendChunked = false;
+ DisposeResponseStream();
}
}
+ protected virtual void PrepareUncachedResponse(HttpListenerContext ctx, TimeSpan cacheDuration)
+ {
+ }
+
+ private void CacheResponse(HttpListenerResponse response, TimeSpan duration, DateTime? dateModified)
+ {
+ DateTime lastModified = dateModified ?? DateTime.Now;
+
+ response.Headers[HttpResponseHeader.CacheControl] = "public, max-age=" + Convert.ToInt32(duration.TotalSeconds);
+ response.Headers[HttpResponseHeader.Expires] = DateTime.Now.Add(duration).ToString("r");
+ response.Headers[HttpResponseHeader.LastModified] = lastModified.ToString("r");
+ }
+
protected abstract void WriteResponseToOutputStream(Stream stream);
protected void DisposeResponseStream()
@@ -222,7 +337,45 @@ namespace MediaBrowser.Common.Net.Handlers CompressedStream.Dispose();
}
- RequestContext.Response.OutputStream.Dispose();
+ HttpListenerContext.Response.OutputStream.Dispose();
+ }
+
+ private bool IsCacheValid(DateTime ifModifiedSince, TimeSpan cacheDuration, DateTime? dateModified)
+ {
+ if (dateModified.HasValue)
+ {
+ DateTime lastModified = NormalizeDateForComparison(dateModified.Value);
+ ifModifiedSince = NormalizeDateForComparison(ifModifiedSince);
+
+ return lastModified <= ifModifiedSince;
+ }
+
+ DateTime cacheExpirationDate = ifModifiedSince.Add(cacheDuration);
+
+ if (DateTime.Now < cacheExpirationDate)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// When the browser sends the IfModifiedDate, it's precision is limited to seconds, so this will account for that
+ /// </summary>
+ private DateTime NormalizeDateForComparison(DateTime date)
+ {
+ return new DateTime(date.Year, date.Month, date.Day, date.Hour, date.Minute, date.Second);
+ }
+
+ protected virtual long? GetTotalContentLength()
+ {
+ return null;
+ }
+
+ protected virtual DateTime? GetLastDateModified()
+ {
+ return null;
}
}
}
\ No newline at end of file diff --git a/MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs b/MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs new file mode 100644 index 0000000000..9c99121529 --- /dev/null +++ b/MediaBrowser.Common/Net/Handlers/StaticFileHandler.cs @@ -0,0 +1,282 @@ +using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Logging;
+
+namespace MediaBrowser.Common.Net.Handlers
+{
+ public class StaticFileHandler : BaseHandler
+ {
+ public string Path
+ {
+ get
+ {
+ return QueryString["path"];
+ }
+ }
+
+ private bool FileStreamDiscovered = false;
+ private FileStream _FileStream = null;
+ private FileStream FileStream
+ {
+ get
+ {
+ if (!FileStreamDiscovered)
+ {
+ try
+ {
+ _FileStream = File.OpenRead(Path);
+ }
+ catch (FileNotFoundException)
+ {
+ StatusCode = 404;
+ }
+ catch (DirectoryNotFoundException)
+ {
+ StatusCode = 404;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ StatusCode = 403;
+ }
+ finally
+ {
+ FileStreamDiscovered = true;
+ }
+ }
+
+ return _FileStream;
+ }
+ }
+
+ protected override bool SupportsByteRangeRequests
+ {
+ get
+ {
+ return true;
+ }
+ }
+
+ public override bool CompressResponse
+ {
+ get
+ {
+ string contentType = ContentType;
+
+ // Can't compress these
+ if (IsRangeRequest)
+ {
+ return false;
+ }
+
+ // Don't compress media
+ if (contentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) || contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ // It will take some work to support compression within this handler
+ return false;
+ }
+ }
+
+ protected override long? GetTotalContentLength()
+ {
+ try
+ {
+ return FileStream.Length;
+ }
+ catch
+ {
+ return base.GetTotalContentLength();
+ }
+ }
+
+ protected override DateTime? GetLastDateModified()
+ {
+ try
+ {
+ return File.GetLastWriteTime(Path);
+ }
+ catch
+ {
+ return base.GetLastDateModified();
+ }
+ }
+
+ protected override bool IsAsyncHandler
+ {
+ get
+ {
+ return true;
+ }
+ }
+
+ public override string ContentType
+ {
+ get
+ {
+ return MimeTypes.GetMimeType(Path);
+ }
+ }
+
+ protected async override void WriteResponseToOutputStream(Stream stream)
+ {
+ try
+ {
+ if (FileStream != null)
+ {
+ if (IsRangeRequest)
+ {
+ KeyValuePair<long, long?> requestedRange = RequestedRanges.First();
+
+ // If the requested range is "0-" and we know the total length, we can optimize by avoiding having to buffer the content into memory
+ if (requestedRange.Value == null && TotalContentLength != null)
+ {
+ await ServeCompleteRangeRequest(requestedRange, stream);
+ }
+ else if (TotalContentLength.HasValue)
+ {
+ // This will have to buffer a portion of the content into memory
+ await ServePartialRangeRequestWithKnownTotalContentLength(requestedRange, stream);
+ }
+ else
+ {
+ // This will have to buffer the entire content into memory
+ await ServePartialRangeRequestWithUnknownTotalContentLength(requestedRange, stream);
+ }
+ }
+ else
+ {
+ await FileStream.CopyToAsync(stream);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogException("WriteResponseToOutputStream", ex);
+ }
+ finally
+ {
+ if (FileStream != null)
+ {
+ FileStream.Dispose();
+ }
+
+ DisposeResponseStream();
+ }
+ }
+
+ /// <summary>
+ /// Handles a range request of "bytes=0-"
+ /// This will serve the complete content and add the content-range header
+ /// </summary>
+ private async Task ServeCompleteRangeRequest(KeyValuePair<long, long?> requestedRange, Stream responseStream)
+ {
+ long totalContentLength = TotalContentLength.Value;
+
+ long rangeStart = requestedRange.Key;
+ long rangeEnd = totalContentLength - 1;
+ long rangeLength = 1 + rangeEnd - rangeStart;
+
+ // Content-Length is the length of what we're serving, not the original content
+ HttpListenerContext.Response.ContentLength64 = rangeLength;
+ HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength);
+
+ if (rangeStart > 0)
+ {
+ FileStream.Position = rangeStart;
+ }
+
+ await FileStream.CopyToAsync(responseStream);
+ }
+
+ /// <summary>
+ /// Serves a partial range request where the total content length is not known
+ /// </summary>
+ private async Task ServePartialRangeRequestWithUnknownTotalContentLength(KeyValuePair<long, long?> requestedRange, Stream responseStream)
+ {
+ // Read the entire stream so that we can determine the length
+ byte[] bytes = await ReadBytes(FileStream, 0, null);
+
+ long totalContentLength = bytes.LongLength;
+
+ long rangeStart = requestedRange.Key;
+ long rangeEnd = requestedRange.Value ?? (totalContentLength - 1);
+ long rangeLength = 1 + rangeEnd - rangeStart;
+
+ // Content-Length is the length of what we're serving, not the original content
+ HttpListenerContext.Response.ContentLength64 = rangeLength;
+ HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength);
+
+ await responseStream.WriteAsync(bytes, Convert.ToInt32(rangeStart), Convert.ToInt32(rangeLength));
+ }
+
+ /// <summary>
+ /// Serves a partial range request where the total content length is already known
+ /// </summary>
+ private async Task ServePartialRangeRequestWithKnownTotalContentLength(KeyValuePair<long, long?> requestedRange, Stream responseStream)
+ {
+ long totalContentLength = TotalContentLength.Value;
+ long rangeStart = requestedRange.Key;
+ long rangeEnd = requestedRange.Value ?? (totalContentLength - 1);
+ long rangeLength = 1 + rangeEnd - rangeStart;
+
+ // Only read the bytes we need
+ byte[] bytes = await ReadBytes(FileStream, Convert.ToInt32(rangeStart), Convert.ToInt32(rangeLength));
+
+ // Content-Length is the length of what we're serving, not the original content
+ HttpListenerContext.Response.ContentLength64 = rangeLength;
+
+ HttpListenerContext.Response.Headers["Content-Range"] = string.Format("bytes {0}-{1}/{2}", rangeStart, rangeEnd, totalContentLength);
+
+ await responseStream.WriteAsync(bytes, 0, Convert.ToInt32(rangeLength));
+ }
+
+ /// <summary>
+ /// Reads bytes from a stream
+ /// </summary>
+ /// <param name="input">The input stream</param>
+ /// <param name="start">The starting position</param>
+ /// <param name="count">The number of bytes to read, or null to read to the end.</param>
+ private async Task<byte[]> ReadBytes(Stream input, int start, int? count)
+ {
+ if (start > 0)
+ {
+ input.Position = start;
+ }
+
+ if (count == null)
+ {
+ byte[] buffer = new byte[16 * 1024];
+
+ using (MemoryStream ms = new MemoryStream())
+ {
+ int read;
+ while ((read = await input.ReadAsync(buffer, 0, buffer.Length)) > 0)
+ {
+ await ms.WriteAsync(buffer, 0, read);
+ }
+ return ms.ToArray();
+ }
+ }
+ else
+ {
+ byte[] buffer = new byte[count.Value];
+
+ using (MemoryStream ms = new MemoryStream())
+ {
+ int read = await input.ReadAsync(buffer, 0, buffer.Length);
+
+ await ms.WriteAsync(buffer, 0, read);
+
+ return ms.ToArray();
+ }
+ }
+
+ }
+ }
+}
|
