从ASP.NET MVC2向iPhone提供视频文件

9
我正在尝试从ASP.NET MVC向iPhone客户端提供视频文件。视频格式正确,如果我将其放在公共可访问的Web目录中,则可以正常工作。
从我所读的内容来看,核心问题是iPhone要求您拥有一个准备好恢复下载的环境,该环境允许您通过HTTP标头过滤字节范围。我认为这是为了让用户跳过视频。
当使用MVC提供文件时,这些标头不存在。我尝试模拟它,但没有成功。我们这里有IIS6,我无法进行许多标头操作。ASP.NET会抱怨说“此操作需要IIS集成管道模式。”
升级不是选项,我也不允许将文件移动到公共Web共享中。我感到受到我们的环境限制,但仍在寻找解决方案。
以下是我正在尝试缩短的一些示例代码...
public ActionResult Mobile(string guid = "x")
{
    guid = Path.GetFileNameWithoutExtension(guid);
    apMedia media = DB.apMedia_GetMediaByFilename(guid);
    string mediaPath = Path.Combine(Transcode.Swap_MobileDirectory, guid + ".m4v");

    if (!Directory.Exists(Transcode.Swap_MobileDirectory)) //Make sure it's there...
        Directory.CreateDirectory(Transcode.Swap_MobileDirectory);

    if(System.IO.File.Exists(mediaPath))
        return base.File(mediaPath, "video/x-m4v");

    return Redirect("~/Error/404");
}

我知道我需要像这样做,但是我无法在.NET MVC中实现。 这里有一个有效的HTTP响应头示例:http://dotnetslackers.com/articles/aspnet/Range-Specific-Requests-in-ASP-NET.aspx
Date    Mon, 08 Nov 2010 17:02:38 GMT
Server  Apache
Last-Modified   Mon, 08 Nov 2010 17:02:13 GMT
Etag    "14e78b2-295eff-4cd82d15"
Accept-Ranges   bytes
Content-Length  2711295
Content-Range   bytes 0-2711294/2711295
Keep-Alive  timeout=15, max=100
Connection  Keep-Alive
Content-Type    text/plain

这里是一个不成功的示例(来自.NET)

Server  ASP.NET Development Server/10.0.0.0
Date    Mon, 08 Nov 2010 18:26:17 GMT
X-AspNet-Version    4.0.30319
X-AspNetMvc-Version 2.0
Content-Range   bytes 0-2711294/2711295
Cache-Control   private
Content-Type    video/x-m4v
Content-Length  2711295
Connection  Close

有什么想法吗?谢谢。
4个回答

21

更新:这个项目已经迁移到 CodePlex 上了

好的,我已经在本地测试站点上使其工作,并且我可以将视频流传输到我的 iPad。这有点凌乱,因为它比我预期的要困难一些,现在它正在运行,我暂时没有时间整理它。关键部分:

Action 过滤器:

public class ByteRangeRequest : FilterAttribute, IActionFilter
{
    protected string RangeStart { get; set; }
    protected string RangeEnd { get; set; }

    public ByteRangeRequest(string RangeStartParameter, string RangeEndParameter)
    {
        RangeStart = RangeStartParameter;
        RangeEnd = RangeEndParameter;
    }

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        if (filterContext == null)
            throw new ArgumentNullException("filterContext");

        if (!filterContext.ActionParameters.ContainsKey(RangeStart))
            filterContext.ActionParameters.Add(RangeStart, null);
        if (!filterContext.ActionParameters.ContainsKey(RangeEnd))
            filterContext.ActionParameters.Add(RangeEnd, null);

        var headerKeys = filterContext.RequestContext.HttpContext.Request.Headers.AllKeys.Where(key => key.Equals("Range", StringComparison.InvariantCultureIgnoreCase));
        Regex rangeParser = new Regex(@"(\d+)-(\d+)", RegexOptions.Compiled);

        foreach(string headerKey in headerKeys)
        {
            string value = filterContext.RequestContext.HttpContext.Request.Headers[headerKey];
            if (!string.IsNullOrEmpty(value))
            {
                if (rangeParser.IsMatch(value))
                {
                    Match match = rangeParser.Match(value);

                    filterContext.ActionParameters[RangeStart] = int.Parse(match.Groups[1].ToString());
                    filterContext.ActionParameters[RangeEnd] = int.Parse(match.Groups[2].ToString());
                    break;
                }
            }
        }
    }

    public void OnActionExecuted(ActionExecutedContext filterContext)
    {
    }
}

基于FileStreamResult的自定义结果:

public class ContentRangeResult : FileStreamResult
{
    public int StartIndex { get; set; }
    public int EndIndex { get; set; }
    public long TotalSize { get; set; }
    public DateTime LastModified { get; set; }

    public FileStreamResult(int startIndex, int endIndex, long totalSize, DateTime lastModified, string contentType, Stream fileStream)
        : base(fileStream, contentType)
    {
        StartIndex = startIndex;
        EndIndex = endIndex;
        TotalSize = totalSize;
        LastModified = lastModified;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context == null)
            throw new ArgumentNullException("context");

        HttpResponseBase response = context.HttpContext.Response;
        response.ContentType = this.ContentType;
        response.AddHeader(HttpWorkerRequest.GetKnownResponseHeaderName(HttpWorkerRequest.HeaderContentRange), string.Format("bytes {0}-{1}/{2}", StartIndex, EndIndex, TotalSize));
        response.StatusCode = 206;

        WriteFile(response);
    }

    protected override void WriteFile(HttpResponseBase response)
    {
        Stream outputStream = response.OutputStream;
        using (this.FileStream)
        {
            byte[] buffer = new byte[0x1000];
            int totalToSend = EndIndex - StartIndex;
            int bytesRemaining = totalToSend;
            int count = 0;

            FileStream.Seek(StartIndex, SeekOrigin.Begin);

            while (bytesRemaining > 0)
            {
                if (bytesRemaining <= buffer.Length)
                    count = FileStream.Read(buffer, 0, bytesRemaining);
                else
                    count = FileStream.Read(buffer, 0, buffer.Length);

                outputStream.Write(buffer, 0, count);
                bytesRemaining -= count;
            }
        }
    }      
}

我的MVC操作:

[ByteRangeRequest("StartByte", "EndByte")]
public FileStreamResult NextSegment(int? StartByte, int? EndByte)
{
    FileStream contentFileStream = System.IO.File.OpenRead(@"C:\temp\Gets.mp4");
    var time = System.IO.File.GetLastWriteTime(@"C:\temp\Gets.mp4");
    if (StartByte.HasValue && EndByte.HasValue)
        return new ContentRangeResult(StartByte.Value, EndByte.Value, contentFileStream.Length, time, "video/x-m4v", contentFileStream);

    return new ContentRangeResult(0, (int)contentFileStream.Length, contentFileStream.Length, time, "video/x-m4v", contentFileStream);
}

我真心希望这可以帮到你。我在这上面花费了很多时间!你可以尝试删除一些组件,直到它再次崩溃。如果能够删除ETag、修改日期等内容,那就太好了。但目前我没有时间来做这个。

祝你编程愉快!


你真牛逼,老兄,这个东西就像应该的那样工作。供大家参考,我观察了iPhone在流媒体视频时的请求头。它会随着视频的播放进度,不断发送许多后续请求,要求获取小而具体的字节范围。我猜它会即时将它们拼接在一起。非常感谢你的帮助,Erik。 - jocull
1
这只是一个打字错误,非常抱歉。在我将所有内容都整理好并使其正常工作后,我在新环境中重新实现了它,以便在博客文章中使用不同的名称。当我发布更新的源代码时,我手动编辑了名称,但当时已经很晚了,显然出了错!很高兴它对你有用。我写的博客文章:https://vikingerik.wordpress.com/2010/11/09/ios-media-streaming-for-aspnet-mvc/ - Erik Noren
2
我不确定你是否还感兴趣,但我已经完善了解决方案并将其放在了CodePlex上。我认为它更加简洁,使用它在你的操作中也更容易一些。有两个项目,一个是库,另一个是演示MVC应用程序,可以流式传输媒体到iOS设备。http://mediastreamingmvc.codeplex.com/ - Erik Noren
当然!我没有在真实环境中使用代码,因此我没有好的测试使用场景。如果您能提供任何帮助或意见,我将不胜感激。如果您有兴趣为CodePlex版做出贡献或者想要做其他事情,请告诉我。 - Erik Noren
这段代码非常稳定,但我不知道我的服务器出了什么问题。如果我在Firefox上使用可恢复下载扩展程序之类的东西,文件会变成二进制等效物。然而,iPhone 在某个地方仍然出现问题。它说媒体文件格式不受支持,但如果我直接通过 Apache 服务器访问它,它就可以工作。也许是内容类型的问题? - jocull
显示剩余3条评论

2

我试着找现有的扩展,但我没有立即找到(也许是因为我的搜索技巧不够好)。

我的第一个想法是你需要创建两个新类。

首先,创建一个从ActionMethodSelectorAttribute继承的类。这与HttpGetHttpPost等的基类相同。在这个类中,你将覆盖IsValidForRequest。在该方法中,检查头部以查看是否请求了范围。现在,你可以使用此属性装饰控制器中的方法,当有人请求流的一部分(iOS、Silverlight等)时,将调用该方法。

其次,创建一个从ActionResult或者也许是FileResult继承的类,并重写ExecuteResult方法,以添加你识别出的字节范围的标头。像返回JSON对象一样返回它,带有字节范围开始、结束、总大小的参数,以便它可以正确生成响应标头。

看一下FileContentResult的实现方式,以了解如何访问上下文的HttpResponse对象以更改标头。

看一下HttpGet,以了解它如何实现对IsValidForRequest的检查。源代码可在CodePlex上获得,或者像我刚刚做的那样使用Reflector。

你可以使用这些信息来进行更多的搜索,看看是否已经有人创建了这个自定义ActionResult

供参考,这是AcceptVerbs属性的样子:

public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo)
{
    if (controllerContext == null)
    {
        throw new ArgumentNullException("controllerContext");
    }
    string httpMethodOverride = controllerContext.HttpContext.Request.GetHttpMethodOverride();
    return this.Verbs.Contains<string>(httpMethodOverride, StringComparer.OrdinalIgnoreCase);
}

这是FileResult的样子。注意使用了AddHeader:

public override void ExecuteResult(ControllerContext context)
{
    if (context == null)
    {
        throw new ArgumentNullException("context");
    }
    HttpResponseBase response = context.HttpContext.Response;
    response.ContentType = this.ContentType;
    if (!string.IsNullOrEmpty(this.FileDownloadName))
    {
        string headerValue = ContentDispositionUtil.GetHeaderValue(this.FileDownloadName);
        context.HttpContext.Response.AddHeader("Content-Disposition", headerValue);
    }
    this.WriteFile(response);
}

我只是把这些东西拼凑在一起了。我不知道它是否适合你的需求(或者能不能工作)。
public class ContentRangeResult : FileStreamResult
{
    public int StartIndex { get; set; }
    public int EndIndex { get; set; }
    public int TotalSize { get; set; }

    public ContentRangeResult(int startIndex, int endIndex, string contentType, Stream fileStream)
        :base(fileStream, contentType)
    {
        StartIndex = startIndex;
        EndIndex = endIndex;
        TotalSize = endIndex - startIndex;
    }

    public ContentRangeResult(int startIndex, int endIndex, string contentType, string fileDownloadName, Stream fileStream)
        : base(fileStream, contentType)
    {
        StartIndex = startIndex;
        EndIndex = endIndex;
        TotalSize = endIndex - startIndex;
        FileDownloadName = fileDownloadName;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException("context");
        }

        HttpResponseBase response = context.HttpContext.Response;
        if (!string.IsNullOrEmpty(this.FileDownloadName))
        {
            System.Net.Mime.ContentDisposition cd = new System.Net.Mime.ContentDisposition() { FileName = FileDownloadName };
            context.HttpContext.Response.AddHeader("Content-Disposition", cd.ToString());
        }

        context.HttpContext.Response.AddHeader("Accept-Ranges", "bytes");
        context.HttpContext.Response.AddHeader("Content-Range", string.Format("bytes {0}-{1}/{2}", StartIndex, EndIndex, TotalSize));
        //Any other headers?


        this.WriteFile(response);
    }

    protected override void WriteFile(HttpResponseBase response)
    {
        Stream outputStream = response.OutputStream;
        using (this.FileStream)
        {
            byte[] buffer = new byte[0x1000];
            int totalToSend = EndIndex - StartIndex;
            int bytesRemaining = totalToSend;
            int count = 0;

            while (bytesRemaining > 0)
            {
                if (bytesRemaining <= buffer.Length)
                    count = FileStream.Read(buffer, 0, bytesRemaining);
                else
                    count = FileStream.Read(buffer, 0, buffer.Length);

                outputStream.Write(buffer, 0, count);

                bytesRemaining -= count;
            }
        }
    }
}

使用方式如下:

return new ContentRangeResult(50, 100, "video/x-m4v", "SomeOptionalFileName", contentFileStream);

我试图为“Accept-Requests: bytes”附加一个标头,但是.NET要么拒绝添加它,要么忽略它。 当我输出响应时,无法将其显示出来。 这是一个关键的部分,你有强制它的想法吗? - jocull
我刚刚发布了一些从反射器中提取的代码片段。第二个示例在上下文(context)上使用了AddHeader方法。你是这样做的吗?如果你没有修改上下文副本的响应,它可能不会持久化,这就是为什么我建议在这里创建自己的返回类型(而不是尝试将标题添加到另一个返回类型,如FileContentResult) 。 - Erik Noren
看了你提供的链接,似乎应该这样运行:装饰你的下载操作以查看是否请求了字节范围。检查请求对象以获取响应期间请求的范围。在响应中,添加头文件,即使客户端请求了它,也要接受字节。还包括有关所提供范围的标头(希望基于所请求的内容)。查看MVC 2框架中现有的对象,这应该都可以工作。在您的响应类型中,您是否使用AddHeader?它会抛出错误吗?标头是否实际显示在响应中? - Erik Noren
如果在选择要执行的控制器方法之前发送了一个范围请求,你可能不会在意。如果不在意的话,就不要继承自ActionMethodSelectorAttribute,而是实现IActionFilter接口或者继承自ActionFilter类,这样你就可以获得ActionExecutingContext,在其中检查请求并查看是否有范围请求。你可以将该值作为参数传递到控制器方法中,就像它是从表单提交的一样。 - Erik Noren
1
如果你还没有让它工作,请告诉我。我从YouTube上下载了一个视频,将一些东西组合在一起,现在我可以使用MVC成功地将其流式传输到我的iPad上。 - Erik Noren

0

你能够跳出MVC吗?这是一个情况,系统抽象会让你碰壁,但是一个普通的IHttpHandler应该有更多的选择。

话虽如此,在你实现自己的流媒体服务器之前,最好购买或租用一个...


我们有一个流媒体服务器... 我们的管理员在我需要完成之前永远无法正确设置它。我现在被困在MVC中。 - jocull

-1

工作中设置Content-type为text/plain的头部是正确的吗?还是打错了?任何人都可以尝试在操作中设置这些头部:

Response.Headers.Add(...)

Response.Headers.Add在IIS6中无法工作,因为我上面引用的错误。我认为它只是忽略了test/plain,这就是为什么它仍然可以工作的原因。 - jocull
在反编译器中查看,发现在Reponse类中有一个名为AddHeader的特定方法——如果头部信息是不可变的,这个方法对我来说似乎很奇怪。此外,其他响应类型修改了内容类型和编码,并且它们可以在IIS 6上运行(尽管它们直接访问特定函数而不是修改Headers属性)。 - Erik Noren
我相信 Response.AppendHeader(string,string) 可以工作。但是我对它没有其他的控制。 - jocull

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接