Table of content
This article focuses on the implementation of
HTTP 206 Partial Content in ASP.NET Web API. I would like to describe how I work on it with
ApiController and deal with some potential performance issues. Our goal is to create a video file streaming service and an HTML5 page to play them.
In my
last article, We have discussed the characteristic of HTTP 206 and its related headers. Also we had a showcase of video streaming in Node.js and HTML5. This time we will move to ASP.NET Web API and will have some discussions regarding our implementation. If you would like to learn more details of this HTTP status code, last article could be a good reference to you. And we will not repeat it in this article.
First of all, we expect the URL for video streaming shall be like this:
http://localhost/movie/api/media/play?f=praise-our-lord.mp4
where
movie is our application name in IIS,
media is the controller name,
play is its action name and parameter
frepresents the video file we would like to play.
Based on this URL, we will start from the
MediaController under the namespace
Movie.Controllers, a class derived from
ApiController. Before we work on its actual action, we need several static fields and methods to help us in upcoming steps.
Hide Shrink
Copy Code
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Mime;
using System.Web.Configuration;
using System.Web.Http;
namespace Movie.Controllers
{
public class MediaController : ApiController
{
#region Fields
public const int ReadStreamBufferSize = 1024 * 1024;
public static readonly IReadOnlyDictionary<string, string> MimeNames;
public static readonly IReadOnlyCollection<char> InvalidFileNameChars;
public static readonly string InitialDirectory;
#endregion
#region Constructors
static MediaController()
{
var mimeNames = new Dictionary<string, string>();
mimeNames.Add(".mp3", "audio/mpeg"); mimeNames.Add(".mp4", "video/mp4");
mimeNames.Add(".ogg", "application/ogg");
mimeNames.Add(".ogv", "video/ogg");
mimeNames.Add(".oga", "audio/ogg");
mimeNames.Add(".wav", "audio/x-wav");
mimeNames.Add(".webm", "video/webm");
MimeNames = new ReadOnlyDictionary<string, string>(mimeNames);
InvalidFileNameChars = Array.AsReadOnly(Path.GetInvalidFileNameChars());
InitialDirectory = WebConfigurationManager.AppSettings["InitialDirectory"];
}
#endregion
#region Actions
#endregion
#region Others
private static bool AnyInvalidFileNameChars(string fileName)
{
return InvalidFileNameChars.Intersect(fileName).Any();
}
private static MediaTypeHeaderValue GetMimeNameFromExt(string ext)
{
string value;
if (MimeNames.TryGetValue(ext.ToLowerInvariant(), out value))
return new MediaTypeHeaderValue(value);
else
return new MediaTypeHeaderValue(MediaTypeNames.Application.Octet);
}
private static bool TryReadRangeItem(RangeItemHeaderValue range, long contentLength,
out long start, out long end)
{
if (range.From != null)
{
start = range.From.Value;
if (range.To != null)
end = range.To.Value;
else
end = contentLength - 1;
}
else
{
end = contentLength - 1;
if (range.To != null)
start = contentLength - range.To.Value;
else
start = 0;
}
return (start < contentLength && end < contentLength);
}
private static void CreatePartialContent(Stream inputStream, Stream outputStream,
long start, long end)
{
int count = 0;
long remainingBytes = end - start + 1;
long position = start;
byte[] buffer = new byte[ReadStreamBufferSize];
inputStream.Position = start;
do
{
try
{
if (remainingBytes > ReadStreamBufferSize)
count = inputStream.Read(buffer, 0, ReadStreamBufferSize);
else
count = inputStream.Read(buffer, 0, (int)remainingBytes);
outputStream.Write(buffer, 0, count);
}
catch (Exception error)
{
Debug.WriteLine(error);
break;
}
position = inputStream.Position;
remainingBytes = end - position + 1;
} while (position <= end);
}
#endregion
}
}
And we have:
AnyInvalidFileNameChars() helps us to check if there is any invalid file name character in URL parameter f (by the way, this is a good example of using LINQ on string). This can prevent some unnecessary file system accesses because a file with invalid file name won't exist at all.
GetMimeNameFromExt() helps us to get the corresponding Content-Type header value with file extension from the read-only dictionary MimeNames. If value can not be found, the default one isapplication/oct-stream.
TryReadRangeItem() helps us to read Range header from current HTTP request. Returned boolean value represents if range is available. If start or end position is greater than file length (parametercontentLength), it returns false.
CreatePartialContent() helps us to copy content from file stream to response stream with indicated range.
With these tools, to implement the action
Play() method will be much easier. The prototype is:
Hide Copy Code
[HttpGet]
public HttpResponseMessage Play(string f) { }
where parameter
f represents the URL parameter
f.
HttpGetAttribute declares that
GET is the only acceptable HTTP method. The response headers and content are sent in
HttpResponseMessage class. The logic flow behind this method can be described with following chart.

Naturally, our first job is to see if the file exists. If not, will result in
HTTP 404 Not Found status. Next is to check if
Range header is present in current request. If not, the request will be treated as normal request and will result in
HTTP 200 OK status. Third step is to determine if
Range header can be fulfilled according to target file. If range is not present within file length,
HTTP 416 Requested Range Not Satisfiable status will be responded to browser. After these steps, last one is to transmit target file with indicated range, and the story ends with
HTTP 206 Partial Content status.
Here is complete code of
Play() action.
Hide Shrink
Copy Code
[HttpGet]
public HttpResponseMessage Play(string f)
{
if (string.IsNullOrWhiteSpace(f) || AnyInvalidFileNameChars(f))
throw new HttpResponseException(HttpStatusCode.NotFound);
FileInfo fileInfo = new FileInfo(Path.Combine(InitialDirectory, f));
if (!fileInfo.Exists)
throw new HttpResponseException(HttpStatusCode.NotFound);
long totalLength = fileInfo.Length;
RangeHeaderValue rangeHeader = base.Request.Headers.Range;
HttpResponseMessage response = new HttpResponseMessage();
response.Headers.AcceptRanges.Add("bytes");
if (rangeHeader == null || !rangeHeader.Ranges.Any())
{
response.StatusCode = HttpStatusCode.OK;
response.Content = new PushStreamContent((outputStream, httpContent, transpContext)
=>
{
using (outputStream) using (Stream inputStream = fileInfo.OpenRead())
{
try
{
inputStream.CopyTo(outputStream, ReadStreamBufferSize);
}
catch (Exception error)
{
Debug.WriteLine(error);
}
}
}, GetMimeNameFromExt(fileInfo.Extension));
response.Content.Headers.ContentLength = totalLength;
return response;
}
long start = 0, end = 0;
if (rangeHeader.Unit != "bytes" || rangeHeader.Ranges.Count > 1 ||
!TryReadRangeItem(rangeHeader.Ranges.First(), totalLength, out start, out end))
{
response.StatusCode = HttpStatusCode.RequestedRangeNotSatisfiable;
response.Content = new StreamContent(Stream.Null); response.Content.Headers.ContentRange = new ContentRangeHeaderValue(totalLength);
response.Content.Headers.ContentType = GetMimeNameFromExt(fileInfo.Extension);
return response;
}
var contentRange = new ContentRangeHeaderValue(start, end, totalLength);
response.StatusCode = HttpStatusCode.PartialContent;
response.Content = new PushStreamContent((outputStream, httpContent, transpContext)
=>
{
using (outputStream) using (Stream inputStream = fileInfo.OpenRead())
CreatePartialContent(inputStream, outputStream, start, end);
}, GetMimeNameFromExt(fileInfo.Extension));
response.Content.Headers.ContentLength = end - start + 1;
response.Content.Headers.ContentRange = contentRange;
return response;
}
Now it is time to play the video. We have a simple HTML5 page with a <video /> element and a <source /> element referring to the URL we have mentioned before.
Hide Copy Code
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript">
function onLoad() {
var sec = parseInt(document.location.search.substr(1));
if (!isNaN(sec))
mainPlayer.currentTime = sec;
}
</script>
<title>Partial Content Demonstration</title>
</head>
<body>
<h3>Partial Content Demonstration</h3>
<hr />
<video id="mainPlayer" width="640" height="360"
autoplay="autoplay" controls="controls" onloadeddata="onLoad()">
<source src="api/media/play?f=praise-our-lord.mp4" />
</video>
</body>
</html>
As you can see, the
onLoad() function allows us skipping to indicated second by adding parameter. If parameter is omitted, the <video /> element plays the video from zero. For example, if we want to watch the video started from 120th second, then we have:
http://localhost/movie/index.html?120
Let us try this URL in Chrome.

Then we press F12 to open the development tool, switch to Network tab to see what happened behind the scenes.

These headers explain almost everything. Once
onLoad() function gets triggered, the player sends a request including a
Range header and the start position is exactly equal to the byte position of 120th second in this video. And the response header
Content-Range describes start and end position, and total available bytes. This example shows the biggest benefit of Partial Content mechanism: when a video or audio file is too long, viewers can skip to any second they want.
You have probably noticed that we are using
PushStreamContent instead of
StreamContent in
Play() action (excepting empty content) to transfer file stream. Both of them are under the namespace
System.Net.Http and derived from
HttpContent class. The differences between them could be generally summarized as following points.
PushStreamContent vs. StreamContent
- Sequence - For
StreamContent, you have to generate content stream before action ends. ForPushStreamContent, you will generate it after exit from the action.
- File Access - For
StreamContent, you generate content stream from file before browser starts receiving. For PushStreamContent, you will do it after browser has received all HTTP headers and is ready to render content, which means if browser receives headers only but cancels rendering content, the file willnot be opened.
- Memory Usage - For
StreamContent, you have to generate partial content stream from file before action ends, which means it will be kept in memory temporarily until browser has received its all bytes. ForPushStreamContent, you can directly copy content from file to outgoing stream with specified range and without keeping content temporarily in memory.
Therefore, we choose
PushStreamContent for video file streaming. It could reduce memory usage and work more efficiently.
2014-11-25
- Fix a potential issue in
CreatePartialContent() method when file size is greater thanInt32.MaxValue.
- Add try-catch section in
Play() method to prevent unnecessary exception message in debug mode.
- Increase
ReadStreamBufferSize value to improve performance.