Serving Video from Sitecore for iPhones

The basis of the internet is compliance to standards, but often browsers are more tolerant of applications not quite playing by the rules and so sometimes issues are only encountered on some platforms for code that “works on my machine…” Serving video for iPhones is a case in point, many browsers will happily play video as an attachment, and without a specific mime-type (application/octet-stream covers a multitude of sins), but the iPhone will not. Presented with video not playing on iPhone via Sitecore, but playing if simply embedded on the server as a resource I fired up Fiddler to look at the HTTP requests, responses and headers. An embedded (working) video generated the following headers:

HTTP/1.1 206 Partial Content
Content-Type: video/mp4
Content-Range: bytes 0-18532686/18532687
Last-Modified: Thu, 31 Oct 2013 14:54:00 GMT
Accept-Ranges: bytes
ETag: “0b444849d6ce1:0”
Server: Microsoft-IIS/7.0
X-Powered-By: ASP.NET
Date: Thu, 23 Jan 2014 16:59:05 GMT
Content-Length: 18532687

The interesting parts here are the 206 status code Partial Content meaning that a stream of bytes is being returned and the Content-Range header showing how many bytes in this “chunk”. By contrast Sitecore was generating the following header:

HTTP/1.1 200 OK
Cache-Control: public
Content-Length: 468720
Content-Type: video/mp4
Expires: Thu, 23 Jan 2014 17:12:38 GMT
Server: Microsoft-IIS/7.5
Content-Disposition: attachment; filename=video.mp4
X-AspNet-Version: 4.0.30319
X-Powered-By: ASP.NET
Date: Thu, 23 Jan 2014 17:12:38 GMT

The iPhone looks at these headers and rejects the attached-video-as-file message. Judging from other posts on the internet the Content-Disposition header is causing most pain.

So, ideally we need to generate the same headers as the file that works, which means Partial Content and byte ranges. The code below does this, but leaves alone any mime types that it should not process (Sitecore offers us one handler for all media, so we want to leave alone requests for PDFs, JPGs, GIFs etc).

using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Web;
using Sitecore;
using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Events;
using Sitecore.Resources.Media;
using Sitecore.Web;
using Convert = System.Convert;

namespace MySite.RequestHandlers
{
    public class CustomMediaRequestHandler : MediaRequestHandler
    {
        public override void ProcessRequest(HttpContext context)
        {
            if (!EvaluateRequest(context))
            {
                context.Response.StatusCode = (int) HttpStatusCode.NotFound;
                context.Response.ContentType = "text/html";
            }
        }

        private bool EvaluateRequest(HttpContext context)
        {
            var request = MediaManager.ParseMediaRequest(context.Request);
            if (request == null)
            {
                return false;
            }
            var media = MediaManager.GetMedia(request.MediaUri);

            // if Video do it, otherwise call the base functionality
            if (media != null && IsVideoDownloadType(media))
            {
                return ProcessVideoRequest(context, request, media);
            }
            base.ProcessRequest(context);

            return true;
        }

        private static bool IsVideoDownloadType(Media media)
        {
            return (media.MimeType == "application/octet-stream" &&
      (string.Compare(media.Extension, "mp4", StringComparison.InvariantCultureIgnoreCase) == 0) ||
        string.Compare(media.Extension, "wmv", StringComparison.InvariantCultureIgnoreCase) == 0);
        }

        private static bool ProcessVideoRequest(HttpContext context, 
                                                 MediaRequest request, Media media)
        {
            Assert.ArgumentNotNull(context, "context");
            Assert.ArgumentNotNull(request, "request");
            Assert.ArgumentNotNull(media, "media");
            
            if (IsModified(context, media) == Tristate.False)
            {
                Event.RaiseEvent("media:request", new object[] {request});
                AddMediaHeaders(media, context);
                context.Response.StatusCode = (int) HttpStatusCode.NotModified;
                return true;
            }
            
            MediaStream stream = media.GetStream(request.Options);
            if (stream == null)
            {
                return false;
            }
            Event.RaiseEvent("media:request", new object[] {request});
            // If it's a byte-range request...
            if (!String.IsNullOrEmpty(context.Request.Headers["Range"]))
            {
                return ProcessByteRangeRequest(context, stream, media);
            }
            // "Accept-Ranges: none" if server doesnt support a byte-range requests
            //context.Response.AppendHeader("Accept-Ranges", "none");
            // Notify client that a byte-range requests is supported
            context.Response.AppendHeader("Accept-Ranges", "bytes");
            AddMediaHeaders(media, context);
            AddVideoStreamHeaders(stream, context);
            using (stream)
            {
                WebUtil.TransmitStream(stream.Stream, 
                         context.Response, Settings.Media.StreamBufferSize);
            }
            return true;
        }

        private static bool ProcessByteRangeRequest(HttpContext context, 
                                                      MediaStream stream, Media media)
        {
            // Get range request data (start range bytes and end range bytes)
            RangeRequestData requestData = ParseRequestHeaderRanges(context, media);
            int contentLenght = ComputeContentLength(requestData);
            // Fill response headers
            AddMediaHeaders(media, context);
            AddVideoStreamHeaders(stream, context);
            SendByteRangeHeaders(context, media, requestData.StartRangeBytes[0],
                                   requestData.EndRangeBytes[0], contentLenght);
            // Send requested byte-range
            ReturnChunkedResponse(context, requestData, stream);
            return true;
        }

        private static int ComputeContentLength(RangeRequestData requestData)
        {
            return requestData.StartRangeBytes
                 .Select((t, i) => Convert.ToInt32(requestData.EndRangeBytes[i] - t) + 1)
                 .Sum();
        }

        protected static RangeRequestData ParseRequestHeaderRanges(HttpContext context, 
                                                                       Media media)
        {
            var request = new RangeRequestData();
            string rangeHeader = context.Request.Headers["Range"];
            // rangeHeader contains the value of the Range HTTP Header and can have values
            // Range: bytes=0-1       * Get bytes 0 and 1, inclusive
            // Range: bytes=0-500     * Get bytes 0 to 500 (the first 501 bytes), incl.
            // Range: bytes=400-1000  * Get bytes 500 to 1000 (501 bytes in total), incl.
            // Range: bytes=-200      * Get the last 200 bytes
            // Range: bytes=500-      * Get all bytes from byte 500 to the end
            //
            // Can also have multiple ranges delimited by commas, as in:
            //      Range: bytes=0-500,600-1000 * Get bytes 0-500 (the first 501 bytes),
            //      inclusive plus bytes 600-1000 (401 bytes) inclusive
            // Remove "Ranges" and break up the ranges
            string[] ranges = rangeHeader.Replace("bytes=", string.Empty)
                                         .Split(",".ToCharArray());
            request.StartRangeBytes = new long[ranges.Length];
            request.EndRangeBytes = new long[ranges.Length];

            // Multipart requests is not supported in this version
            if ((request.StartRangeBytes.Length > 1))
                throw new NotImplementedException();

            for (int i = 0; i < ranges.Length; i++)
            {
                const int start = 0, end = 1;
                // Get the START and END values for the current range
                string[] currentRange = ranges[i].Split("-".ToCharArray());
                if (string.IsNullOrEmpty(currentRange[end]))
                    // No end specified
                    request.EndRangeBytes[i] = media.MediaData.MediaItem.Size - 1;
                else
                    // An end was specified
                    request.EndRangeBytes[i] = long.Parse(currentRange[end]);
                if (string.IsNullOrEmpty(currentRange[start]))
                {
                    // No beginning specified, get last n bytes of file
                    request.StartRangeBytes[i] = media.MediaData.MediaItem.Size - 1 
                    - request.EndRangeBytes[i];
                    request.EndRangeBytes[i] = media.MediaData.MediaItem.Size - 1;
                }
                else
                {
                    // A normal begin value
                    request.StartRangeBytes[i] = long.Parse(currentRange[0]);
                }
            }
            return request;
        }

        private static void ReturnChunkedResponse(HttpContext context, 
                                    RangeRequestData requestData, MediaStream stream)
        {
            HttpResponse response = context.Response;
            var buffer = new byte[Settings.Media.StreamBufferSize];
            using (Stream s = stream.Stream)
            {
                for (int i = 0; i < requestData.StartRangeBytes.Length; i++)
                {
                    // Position the stream at the starting byte
                    s.Seek(requestData.StartRangeBytes[i], SeekOrigin.Begin);
                    int bytesToReadRemaining =
                        Convert.ToInt32(requestData.EndRangeBytes[i] 
                                - requestData.StartRangeBytes[i]) + 1;
                    // Stream out the requested chunks for the current range request
                    while (bytesToReadRemaining > 0)
                    {
                        if (response.IsClientConnected)
                        {
                            int chunkSize = s.Read(buffer, 0,
                              Settings.Media.StreamBufferSize < bytesToReadRemaining
                                                     ? Settings.Media.StreamBufferSize
                                                     : bytesToReadRemaining);
                            response.OutputStream.Write(buffer, 0, chunkSize);
                            bytesToReadRemaining -= chunkSize;
                            response.Flush();
                        }
                        else
                        {
                            // Client disconnected – quit
                            return;
                        }
                    }
                }
            }
        }

        private static void SendByteRangeHeaders(HttpContext context, Media media,
                            long startRangeBytes, long endRangeBytes, int contentLength)
        {
        context.Response.Status = "206 Partial Content";
        context.Response.AppendHeader("Content-Range", string.Format("bytes {0}-{1}/{2}",
                                            startRangeBytes, endRangeBytes.ToString(
                                            CultureInfo.InvariantCulture),
                                            media.MediaData.MediaItem.Size.ToString(
                                            CultureInfo.InvariantCulture)));
            
            SetMediaContentType(context, media);
            context.Response.AppendHeader("Content-Length", 
                                  ontentLength.ToString(CultureInfo.InvariantCulture));

            context.Response.AppendHeader("Accept-Ranges", "bytes");
            context.Response.Headers.Remove("Content-Disposition");
        }

        private static void SetMediaContentType(HttpContext context, Media media)
        {
            /* This code removes the Content-Type header for specific media
               the default remains application/octet-stream for types not listed here
             */

            // According to RFC 4337 the correct Content Type for MPEG-4 is video/mp4
            if(string.Compare(media.Extension, "mp4", 
                StringComparison.InvariantCultureIgnoreCase) == 0)
            {
                context.Response.Headers.Remove("Content-Type");
                context.Response.AppendHeader("Content-Type", "video/mp4");
            }

            // According to RFC 4337 the correct Content Type for Windows Media (Video)
            // is video/x-ms-wmv
            if (string.Compare(media.Extension, "wmv", 
                 StringComparison.InvariantCultureIgnoreCase) == 0)
            {
                context.Response.Headers.Remove("Content-Type");
                context.Response.AppendHeader("Content-Type", "video/x-ms-wmv");
            }
        }

        private static Tristate IsModified(HttpContext context, Media media)
        {
            DateTime time;
            string str = context.Request.Headers["If-None-Match"];
            if (!string.IsNullOrEmpty(str) && (str != media.MediaData.MediaId))
            {
                return Tristate.True;
            }
            string str2 = context.Request.Headers["If-Modified-Since"];
            if (!string.IsNullOrEmpty(str2) && DateTime.TryParse(str2, out time))
            {
                return MainUtil.GetTristate(time != media.MediaData.Updated);
            }
            return Tristate.Undefined;
        }

        private static void AddMediaHeaders(Media media, HttpContext context)
        {
            DateTime updated = media.MediaData.Updated;
            if (updated > DateTime.Now)
            {
                updated = DateTime.Now;
            }
            HttpCachePolicy cache = context.Response.Cache;
            cache.SetLastModified(updated);
            cache.SetETag(media.MediaData.MediaId);
            cache.SetCacheability(Settings.MediaResponse.Cacheability);
            TimeSpan maxAge = Settings.MediaResponse.MaxAge;
            if (maxAge > TimeSpan.Zero)
            {
                if (maxAge > TimeSpan.FromDays(365.0))
                {
                    maxAge = TimeSpan.FromDays(365.0);
                }
                cache.SetMaxAge(maxAge);
                cache.SetExpires(DateTime.Now + maxAge);
            }
            Tristate slidingExpiration = Settings.MediaResponse.SlidingExpiration;
            if (slidingExpiration != Tristate.Undefined)
            {
                cache.SetSlidingExpiration(slidingExpiration == Tristate.True);
            }
            string cacheExtensions = Settings.MediaResponse.CacheExtensions;
            if (cacheExtensions.Length > 0)
            {
                cache.AppendCacheExtension(cacheExtensions);
            }
        }

        private static void AddVideoStreamHeaders(MediaStream stream, 
                                                    HttpContext context)
        {
            stream.Headers.CopyTo(context.Response);
        }

        #region Nested type: RangeRequestData

        protected struct RangeRequestData
        {
            public long[] EndRangeBytes;
            public long[] StartRangeBytes;
        }

        #endregion
    }
}}

And the result is this set of Request Headers:

HTTP/1.1 206 Partial Content
Cache-Control: private, max-age=604800
Content-Length: 468720
Content-Type: video/mp4
Content-Range: bytes 0-468719/468720
Expires: Mon, 03 Feb 2014 10:10:06 GMT
Last-Modified: Wed, 30 Jan 2013 12:04:58 GMT
Accept-Ranges: bytes
Server: Microsoft-IIS/7.5
X-AspNet-Version: 4.0.30319
X-Powered-By: ASP.NET
Date: Mon, 27 Jan 2014 10:10:06 GMT

This code was based on this which unfortunately didn’t quite work, as it failed to delegate mime types that it didn’t process correctly to the base class, but this version should.